diff --git a/apps/docs/src/content/docs/big-picture.mdx b/apps/docs/src/content/docs/big-picture.mdx index 1dbc628f..37fa3687 100644 --- a/apps/docs/src/content/docs/big-picture.mdx +++ b/apps/docs/src/content/docs/big-picture.mdx @@ -103,7 +103,7 @@ The default endpoint (`http://localhost:8000/v1`) is a convenience for a local i | Section | What it covers | | --- | --- | | [Interactive CLI](/cli/interactive/) | Day-to-day REPL: slash commands, flags, sessions | -| [Plan mode](/cli/plan-mode/) | Read-only exploration before you let the model edit | +| [Plan mode](/cli/plan-mode/) | Explore → approve saves a project checklist → implement in-session | | [How the gate is built](/loop/gate-floor/) | What acceptance check tsforge runs and how it picks tsc + ESLint | | [When the gate fails](/loop/validation/) | Repair loop, stop conditions, error feedback to the model | | [Greenfield scaffolding](/scaffold/boringstack/) | Standing up a new BoringStack full-stack app (the first adapter) | diff --git a/apps/docs/src/content/docs/cli/interactive.mdx b/apps/docs/src/content/docs/cli/interactive.mdx index 9faee122..b7890207 100644 --- a/apps/docs/src/content/docs/cli/interactive.mdx +++ b/apps/docs/src/content/docs/cli/interactive.mdx @@ -16,7 +16,7 @@ Most users run `tsforge` and stay in the interactive session. | **Interactive** | `tsforge` | default: open-ended tasks, steering, exploration | | **One-shot** | `tsforge "task" --accept "gate cmd"` | drive a single task to green and exit | | **New app** | `tsforge scaffold` | stand up a full-stack [BoringStack](/scaffold/boringstack/) project, then build it with the [greenfield loop](/loop/greenfield/) | -| **Plan mode** | default: **Shift+Tab** or `/plan` to switch | read-only exploration before implementing. See [Plan mode](/cli/plan-mode/). | +| **Plan mode** | default: **Shift+Tab** or `/plan` to switch | read-only explore → approve saves a project checklist → implement in-session. See [Plan mode](/cli/plan-mode/). | ## Flags @@ -28,7 +28,7 @@ Most users run `tsforge` and stay in the interactive session. | `--no-gate` | skip auto gate detection | | `--browser ` | append headless render check to gate | | `--plan` | force plan mode on for an interactive session. Plan is the default anyway, so this only matters to override a repo that configured an autonomous `policy.mode`; ignored by one-shot/headless | -| `--continue` / `-c` | resume latest saved session for this dir | +| `--continue` / `-c` | resume latest saved session for this dir (restores `activePlanId`; Tasks rail reloads that plan) | | `--resume ` | resume a specific session | | `--log` | append JSONL event stream to `~/.tsforge/logs/` | @@ -40,7 +40,7 @@ Model endpoint overrides: `TSFORGE_BASE_URL`, `TSFORGE_MODEL`. See [Environment | --- | --- | | `/help` | list commands | | `/scaffold` | create a new full-stack project here (BoringStack / Astro) via the wizard | -| `/plan` | toggle plan mode (on by default) | +| `/plan` | toggle plan mode (on by default; approve saves the checklist and implements) | | `/config` | settings hub: model (switch/add), mode, gate, editable scope, and tools (web, TDD); each with a description + live value | | `/gate ` | set gate command (`/gate` alone clears) | | `/files ` | set editable scope | diff --git a/apps/docs/src/content/docs/cli/plan-mode.mdx b/apps/docs/src/content/docs/cli/plan-mode.mdx index 83c5538e..6b7e135c 100644 --- a/apps/docs/src/content/docs/cli/plan-mode.mdx +++ b/apps/docs/src/content/docs/cli/plan-mode.mdx @@ -1,19 +1,59 @@ --- title: Plan mode -description: Read-only exploration before the model is allowed to edit files. +description: Read-only exploration, then approve to save a session-bound plan checklist and implement in the same session. --- -Plan mode is a safety rail for ambiguous work. The model can **read** your repo and **propose** a plan, but it cannot **edit** until you say go. +Plan mode is the default interactive posture. The model can **read** your repo and **propose** a plan, but it cannot **edit** until you approve. On approve, tsforge validates the model's fenced plan JSON, writes a session-bound plan file, shows it in the Tasks rail, and the same session implements it. -**It is the default for a fresh interactive session.** tsforge explores, asks the few clarifying questions that matter, and proposes a plan before it writes anything. The status bar shows the current mode as a `◆ plan` / `◆ normal` chip. +```text +plan mode (default) + → discuss or paste + → model calls present_plan { goal, items } + → TUI renders PLAN card (+ Tasks preview) + → approve / go / lgtm + → /.tsforge/worklist/plans/.json + → session.activePlanId binds that plan + → implement in-session (task_* tools + /gate as usual) + → finished = gate green AND checklist done + → tsforge --continue resumes the session (+ activePlanId + rail) +``` + +The status bar shows `◆ plan` / `◆ normal`. ## Switching modes - Press **Shift+Tab** to cycle the mode (plan → normal → …), or type **`/plan`** to toggle it -- When the plan looks right, reply **`approve`**, **`go`**, or **`lgtm`**. The model implements it +- When the plan looks right, reply **`approve`**, **`go`**, or **`lgtm`** There is no disable *flag*: it's a mode you cycle with Shift+Tab. (`tsforge --plan` forces plan mode on for an interactive session even in a repo that configured an autonomous `policy.mode`. One-shot and headless runs are autonomous regardless.) +## What gets saved on approve + +When the plan is ready, the model calls **`present_plan`** with a structured `{ goal, items }` tree (nested `children`, optional `detail` / `files` / `verify`). The harness validates it, holds it as a **pending proposal**, and renders a PLAN card in the TUI (not a JSON dump in chat). Revise by calling `present_plan` again. + +Approve (`approve` / `go` / `lgtm`): + +1. Takes the pending proposal from `present_plan` (fenced JSON in chat is a fallback only) +2. Writes **`/.tsforge/worklist/plans/.json`**, updates `index.json`, sets the session's **`activePlanId`** +3. Updates the Tasks side rail +4. Turns plan mode off and continues in the same session + +If there is no pending plan (and no valid fenced JSON fallback), approve is refused — stay in plan mode. + +Concurrent sessions in one project each bind their own `activePlanId`; plan files do not clobber. + +Resume with **`tsforge --continue`** (or `--resume `): the conversation and `activePlanId` return from the session store, and the Tasks rail reloads that plan. + +## Task tools (after approve) + +When `activePlanId` is set: + +- `task_list` — nested tree with ids +- `task_focus` — set the active item +- `task_complete` / `task_uncomplete` — status changes (**tools only**; not invented in prose) + +`task_complete` **runs the acceptance gate** and only marks the item done when green. If the gate is red, the item stays open and the errors are returned — fix, then complete again. Do not put “run tests / lint / the gate” as a checklist item; that is the harness’s job on every complete. A session cannot claim finished while the bound plan still has open items. + ## What the model can do in plan mode Read tools only: @@ -22,26 +62,15 @@ Read tools only: - `git_context`: structured, read-only repo history/diffs (see [Git context](/reference/flags/#git-context)) - `run` for **read-only** shell commands (no installs, no writes) -Blocked until approval: `edit`, `create`, `edit_lines`, scaffolders. - -## General plan mode - -Good for: "explore this repo and tell me how you'd refactor auth" or "what files would you touch for feature X?" - -After approval, tsforge switches to the normal implement loop with full write tools. +Blocked until approval: `edit`, `create`, `edit_lines`, scaffolders, and the `task_*` tools (offered only after a plan is bound). -## Whole-app builds +## Whole-app builds (different path) -Plan mode is for **exploring and approving a change** in a repo. Building a *new* -app from scratch is a different flow: [`tsforge scaffold`](/scaffold/boringstack/) -stands up BoringStack, then the [greenfield loop](/loop/greenfield/) drives the app -to green one resource at a time. The harness runs the generators + wiring and the -model fills the domain, each feature verified and frozen before the next. That loop -plans and checkpoints per feature on its own, so it doesn't need plan mode. +Plan mode is for **interactive work in a repo**. Building a *new* app from scratch is a separate headless loop: [`tsforge scaffold`](/scaffold/boringstack/) then [`tsforge --greenfield`](/loop/greenfield/), which keeps its own checklist under `.tsforge/greenfield/`. That path does not use plan-mode approve or the Tasks rail plan files. -## Leaving plan mode +## Leaving plan mode without approving -- **Shift+Tab** (or `/plan`) drops to normal mode for hands-on edits in a repo you know -- One-shot runs (`tsforge "task" --accept …`) and headless/eval runs are autonomous already. Plan mode is interactive-only, since it needs a human to approve +- **Shift+Tab** (or `/plan`) drops to normal mode for hands-on edits +- One-shot runs (`tsforge "task" --accept …`) and headless/eval runs are autonomous already -→ [Greenfield scaffolding](/scaffold/boringstack/) · [Interactive CLI](/cli/interactive/) +→ [Greenfield builds](/loop/greenfield/) · [Interactive CLI](/cli/interactive/) diff --git a/apps/docs/src/content/docs/loop/greenfield.mdx b/apps/docs/src/content/docs/loop/greenfield.mdx index eafca40f..b64f1f49 100644 --- a/apps/docs/src/content/docs/loop/greenfield.mdx +++ b/apps/docs/src/content/docs/loop/greenfield.mdx @@ -9,6 +9,8 @@ Most of tsforge drives **one change to green**. Greenfield mode drives a **whole tsforge --greenfield "build a kanban board" --accept "bun run build" ``` +This is the **autonomous / headless** checklist loop (scaffold and app builds). Interactive sessions use a different path: [plan mode](/cli/plan-mode/) → approve → `.tsforge/worklist/plans/.json` (session-bound) → implement in the same REPL (`--continue`). Do not confuse the two folders. + ## How it works 1. **Plan**: a planner model turns your one-line goal into a high-level spec and a flat feature checklist (sprints, not file-level steps). Written to `.tsforge/greenfield/`. diff --git a/apps/docs/src/content/docs/reference/commands.mdx b/apps/docs/src/content/docs/reference/commands.mdx index e55ee796..c38a3a62 100644 --- a/apps/docs/src/content/docs/reference/commands.mdx +++ b/apps/docs/src/content/docs/reference/commands.mdx @@ -76,7 +76,7 @@ tsforge --greenfield "build a kanban board" --accept "bun run build" tsforge --greenfield "..." --notify 'curl -s "$WEBHOOK?s=$TSFORGE_STATUS"' ``` -`--greenfield` (or a recipe with `mode: "greenfield"`) plans a feature checklist and drives it to all-green one feature at a time, persisting state under `.tsforge/greenfield/` so a long run resumes. Each feature is verified by the gate, the browser oracle, and a reject-by-default judge. `--notify ` runs a shell command on completion with the outcome in `$TSFORGE_STATUS`. See [Greenfield builds](/loop/greenfield/). +`--greenfield` (or a recipe with `mode: "greenfield"`) is the **headless** whole-app checklist loop: it plans features and drives them to all-green one at a time under `.tsforge/greenfield/`. It is separate from interactive [plan mode](/cli/plan-mode/), which saves a session-bound plan under `.tsforge/worklist/plans/` on approve and continues in the same session (`--continue`). Each greenfield feature is verified by the gate, the browser oracle, and a reject-by-default judge. `--notify ` runs a shell command on completion with the outcome in `$TSFORGE_STATUS`. See [Greenfield builds](/loop/greenfield/). ## Drive one change, then review it diff --git a/packages/core/ARCHITECTURE.md b/packages/core/ARCHITECTURE.md index fd6c5378..b9155121 100644 --- a/packages/core/ARCHITECTURE.md +++ b/packages/core/ARCHITECTURE.md @@ -2,7 +2,7 @@ -Derived from `packages/core/src`: **30 subsystems**, **549 files**, **98932 lines**, **135 cross-subsystem edges**. +Derived from `packages/core/src`: **30 subsystems**, **578 files**, **107055 lines**, **136 cross-subsystem edges**. This page is the exhaustive record: every subsystem, every cross-subsystem edge, and @@ -13,22 +13,22 @@ inventory, see the hand-drawn map on [Internals](/internals/). | Subsystem | Purpose | Tier | Files | Lines | Fan-in | Fan-out | | --- | --- | --- | --- | --- | --- | --- | -| `loop` | The drive-to-green engine: turns, tools, gate settling, steering, adapters | core | 105 | 30343 | 7 | 21 | +| `loop` | The drive-to-green engine: turns, tools, gate settling, steering, adapters | core | 115 | 32941 | 7 | 22 | | `rule-packs` | The ESLint rule packs the gate enforces, grouped by stack | core | 164 | 19000 | 3 | 3 | -| `cli` | Argument parsing, the interactive REPL, and per-mode wiring | core | 20 | 6506 | 2 | 18 | -| `render` | Terminal UI — status bar, menus, wizards, markdown, diffs, spinners | core | 21 | 4576 | 6 | 5 | +| `render` | Terminal UI — status bar, menus, wizards, markdown, diffs, spinners | core | 40 | 9396 | 7 | 5 | +| `cli` | Argument parsing, the interactive REPL, and per-mode wiring | core | 20 | 7058 | 2 | 18 | | `meta-rules` | Gate rules that need no AST — config shape, CI wiring, supply chain | core | 42 | 3424 | 1 | 2 | | `self-harness` | Lets the harness propose, trial, and keep edits to its own prompts and rules | optional | 15 | 3367 | 2 | 9 | | `inference` | OpenAI-compatible provider: streaming, tool calls, reasoning, token usage | core | 12 | 3022 | 10 | 3 | -| `agent` | Tool schemas, the model-as-agent wrapper, and the malformed-tool-call repair ladder | core | 10 | 2644 | 8 | 9 | +| `agent` | Tool schemas, the model-as-agent wrapper, and the malformed-tool-call repair ladder | core | 10 | 2768 | 8 | 9 | | `scaffold` | Stands up a new project from an archetype and configures its gate | optional | 15 | 2526 | 3 | 2 | | `editor` | The terminal input-line editor behind the REPL prompt | core | 10 | 2399 | 2 | 2 | -| `(root)` | CLI entry, model registry, session persistence — the loose files in src/ | core | 6 | 2315 | 5 | 15 | +| `(root)` | CLI entry, model registry, session persistence — the loose files in src/ | core | 6 | 2322 | 5 | 15 | | `config` | tsforge.config.json, profiles, recipes, agent specs, and external plugins | core | 9 | 2250 | 6 | 8 | | `reviewers` | Independent review panel that grades a change before it is trusted | optional | 9 | 2212 | 1 | 3 | | `eval` | Run scoring, failure classification, and the quality judge | optional | 10 | 1817 | 4 | 4 | | `files` | Reading, creating, and hash-anchored editing of workspace files | core | 9 | 1592 | 5 | 1 | -| `policy` | Decides which actions are allowed in the current mode before they run | core | 5 | 1249 | 5 | 3 | +| `policy` | Decides which actions are allowed in the current mode before they run | core | 5 | 1257 | 5 | 3 | | `gate` | Composes and runs the deterministic gate: linter, stages, tool paths | core | 10 | 1181 | 5 | 5 | | `architecture` ⚠️ | Derives this map from source so the docs cannot drift from the code | optional | 8 | 1118 | 0 | 0 | | `lib` | Shared primitives — fs, json, guards, scope globs, SSRF checks, clipboard | core | 17 | 1082 | 24 | 0 | @@ -38,7 +38,7 @@ inventory, see the hand-drawn map on [Internals](/internals/). | `browser` | Headless Chromium oracle that render-checks a page as a gate stage | optional | 3 | 683 | 1 | 1 | | `spec` | Task and spec shapes, spec parsing, and test generation from intent | core | 6 | 630 | 6 | 6 | | `stack-detection` | Detects the project's stack and picks which rule packs apply | core | 4 | 579 | 7 | 1 | -| `setup` | Onboarding wizard that writes a project's initial tsforge config | optional | 4 | 539 | 2 | 5 | +| `setup` | Onboarding wizard that writes a project's initial tsforge config | optional | 4 | 553 | 2 | 5 | | `validate` | Runs the gate command and parses tool output into structured errors | core | 7 | 475 | 7 | 2 | | `codebase` | Structural workspace map and hub ranking used to seed prompt context | core | 6 | 472 | 2 | 4 | | `proptest` ⚠️ | Derives property-based test inputs from TypeScript types | optional | 3 | 364 | 0 | 0 | @@ -56,17 +56,18 @@ buries the ones someone can actually go and break. | Pair | One way | The other | | --- | --- | --- | -| `(root)` ↔ `cli` | `cli.ts:29` → `./cli/args` | `cli/banner.ts:6` → `../session-store` | +| `(root)` ↔ `cli` | `cli.ts:29` → `./cli/args` | `cli/config-menu.ts:10` → `../models-config` | | `(root)` ↔ `inference` | `classify.ts:1` → `./inference` | `inference/image-gen.ts:4` → `../models-config` | | `(root)` ↔ `loop` | `cli.ts:13` → `./loop` | `loop/expert-handoff.ts:21` → `../models-config` | | `agent` ↔ `inference` | `agent/agent-runner.ts:13` → `../inference` | `inference/wire.ts:9` → `../agent` | | `agent` ↔ `loop` | `agent/agent-runner.ts:16` → `../loop/loop.types` | `loop/model-call.ts:6` → `../agent` | | `agent` ↔ `policy` | `agent/agent-runner.ts:15` → `../policy` | `policy/classify.ts:1` → `../agent` | | `agent` ↔ `spec` | `agent/agent.types.ts:1` → `../spec` | `spec/generate-tests.ts:5` → `../agent` | -| `cli` ↔ `render` | `cli/banner.ts:5` → `../render` | `render/command-menu.ts:2` → `../cli/commands` | +| `cli` ↔ `render` | `cli/banner.ts:9` → `../render` | `render/command-menu.ts:2` → `../cli/commands` | | `config` ↔ `rule-packs` | `config/external-plugins.ts:5` → `../rule-packs` | `rule-packs/index.ts:113` → `../config/plugin-fingerprint` | | `editor` ↔ `render` | `editor/view.ts:2` → `../render/style` | `render/width.ts:1` → `../editor/segments` | | `eval` ↔ `loop` | `eval/failure-class.ts:1` → `../loop/loop.types` | `loop/quality.ts:7` → `../eval` | +| `loop` ↔ `render` | `loop/worklist/panel.ts:3` → `../../render/frame/ansi-plain` | `render/agent-tree.ts:8` → `../loop/loop.types` | | `loop` ↔ `self-harness` | `loop/feedback/rule-docs.ts:3` → `../../self-harness/overlay` | `self-harness/build-evidence.ts:3` → `../loop` | | `loop` ↔ `spec` | `loop/feedback/feedback.ts:2` → `../../spec` | `spec/generate-tests.ts:4` → `../loop` | | `spec` ↔ `validate` | `spec/generate-tests.ts:8` → `../validate` | `validate/accept.ts:1` → `../spec` | @@ -98,7 +99,7 @@ Async functions returning an exit code, declared under the CLI — the commands. | `main` | `cli.ts:754` | | `mapMode` | `cli.ts:481` | | `recipesMode` | `cli.ts:500` | -| `repl` | `cli/repl.ts:552` | +| `repl` | `cli/repl.ts:609` | | `reviewMode` | `cli.ts:181` | | `runOnce` | `cli.ts:93` | | `runTraceCommand` | `cli/repl-commands.ts:109` | diff --git a/packages/core/src/agent/agent.constants.ts b/packages/core/src/agent/agent.constants.ts index 781c2641..808b34fe 100644 --- a/packages/core/src/agent/agent.constants.ts +++ b/packages/core/src/agent/agent.constants.ts @@ -35,6 +35,11 @@ export const TOOL_NAME = { generateImage: "generate_image", check: "check", askUser: "ask_user", + taskList: "task_list", + taskFocus: "task_focus", + taskComplete: "task_complete", + taskUncomplete: "task_uncomplete", + presentPlan: "present_plan", } as const; /** Per-tool capability flags — the single source of truth the plan-mode set and @@ -101,6 +106,17 @@ export const TOOL_SPECS: Readonly> = { // safe (clarifying while planning is fine). Not script-exposable: it's an // interactive control-flow tool, not a data call a program should make. [TOOL_NAME.askUser]: { readOnly: true, scriptExposable: false }, + // Session-bound checklist tools. `task_list` is a pure read of the plan file; + // focus/complete/uncomplete mutate the plan JSON (not workspace source) and are + // offered only after approve (activePlanId set) — withheld from plan mode by + // not advertising them until then. Not script-exposable (interactive scope). + [TOOL_NAME.taskList]: { readOnly: true, scriptExposable: false }, + [TOOL_NAME.taskFocus]: { readOnly: false, scriptExposable: false }, + [TOOL_NAME.taskComplete]: { readOnly: false, scriptExposable: false }, + [TOOL_NAME.taskUncomplete]: { readOnly: false, scriptExposable: false }, + // Propose a structured plan for human approve — no workspace / disk write until + // approve. Plan-mode-safe. Offered in plan mode only (see offeredToolsFor). + [TOOL_NAME.presentPlan]: { readOnly: true, scriptExposable: false }, }; function toolNamesWhere( @@ -749,6 +765,114 @@ export const ASK_USER_TOOL = { }, } as const; +const TASK_ID_PARAM = { + type: "object", + properties: { + id: { + type: "string", + description: "Checklist item UUID from task_list (not the title).", + }, + }, + required: ["id"], +} as const; + +export const TASK_LIST_TOOL = { + type: "function", + function: { + name: TOOL_NAME.taskList, + description: + "Show the session's approved plan checklist (nested tree with ids and status). Checklist status changes ONLY via task_focus / task_complete / task_uncomplete — never invent done items. Does not run the acceptance gate.", + parameters: { type: "object", properties: {} }, + }, +} as const; + +export const TASK_FOCUS_TOOL = { + type: "function", + function: { + name: TOOL_NAME.taskFocus, + description: + "Mark one open checklist item as the active focus (activeItemId). Call when you start work on the next item. Does not run the gate.", + parameters: TASK_ID_PARAM, + }, +} as const; + +export const TASK_COMPLETE_TOOL = { + type: "function", + function: { + name: TOOL_NAME.taskComplete, + description: + "Mark a checklist item done ONLY after the acceptance gate is green. Runs the full gate first; if red, the item stays open and you get the errors — fix them, then call again. Parents complete only when all children are done. Do NOT mark items done before validation. The gate is the authority; never invent done.", + parameters: TASK_ID_PARAM, + }, +} as const; + +export const TASK_UNCOMPLETE_TOOL = { + type: "function", + function: { + name: TOOL_NAME.taskUncomplete, + description: + "Re-open a previously completed checklist item (status → pending). Does not run the gate.", + parameters: TASK_ID_PARAM, + }, +} as const; + +const PLAN_ITEM_SCHEMA = { + type: "object", + properties: { + title: { type: "string", description: "Short actionable item title." }, + detail: { + type: "string", + description: "Optional prose / notes for this item.", + }, + files: { + type: "array", + items: { type: "string" }, + description: "Optional file/path hints.", + }, + verify: { + type: "string", + description: "Optional verify hint (not executed as a gate).", + }, + children: { + type: "array", + description: "Optional nested items (same shape).", + items: { type: "object" }, + }, + }, + required: ["title"], +} as const; + +export const PRESENT_PLAN_TOOL = { + type: "function", + function: { + name: TOOL_NAME.presentPlan, + description: + "Present the structured plan for human approval. Call this when the plan is ready — do NOT dump JSON into the chat. The harness validates the tree and renders it in the UI. Items are work units (files/features) — do NOT add a checklist item for running tests/lint/the gate; the harness gate validates every task_complete. The human replies with refinements (revise via another present_plan) or approve/go/lgtm. Does not write files or unlock editing until they approve.", + parameters: { + type: "object", + properties: { + goal: { + type: "string", + description: "One-line goal for this plan.", + }, + items: { + type: "array", + description: "Non-empty nested checklist (title required per node).", + items: PLAN_ITEM_SCHEMA, + }, + plan: { + type: "object", + description: "Alternative: pass { goal, items } as one object.", + properties: { + goal: { type: "string" }, + items: { type: "array", items: PLAN_ITEM_SCHEMA }, + }, + }, + }, + }, + }, +} as const; + /** * The model-invoked delegation tool (like Claude Code's Task tool). The * orchestrator calls it — the user never names an agent — to hand a focused, diff --git a/packages/core/src/cli/args.ts b/packages/core/src/cli/args.ts index 2262a747..c4b2585c 100644 --- a/packages/core/src/cli/args.ts +++ b/packages/core/src/cli/args.ts @@ -152,7 +152,13 @@ const VALUE_FLAGS = new Set([ /** True for any token the parser recognises as a flag, boolean or value-taking. */ function isKnownFlag(token: string): boolean { - return Object.hasOwn(BOOL_FLAGS, token) || VALUE_FLAGS.has(token); + return ( + Object.hasOwn(BOOL_FLAGS, token) || + VALUE_FLAGS.has(token) || + // Removed flags — still recognized so old aliases/scripts do not become task text. + token === "--tui-panes" || + token === "--no-tui-panes" + ); } /** @@ -277,6 +283,11 @@ export function parseArgs(argv: readonly string[]): ICliArgs { continue; } + // Pane console is the only interactive UI — old opt-in/out flags are no-ops. + if (arg === "--tui-panes" || arg === "--no-tui-panes") { + continue; + } + const boolKey = BOOL_FLAGS[arg]; if (boolKey !== undefined) { @@ -299,37 +310,44 @@ export function parseArgs(argv: readonly string[]): ICliArgs { } out.task = positional.join(" ").trim(); + applyPositionalSubcommand(positional, out); - // `tsforge review` / `tsforge map` are subcommands, not tasks: the first - // positional selects them. - if (positional[0] === "review") { + out.dir = isAbsolute(out.dir) ? out.dir : join(process.cwd(), out.dir); + + return out; +} + +/** `tsforge review` / `map` / … — first positional selects the mode, not the task. */ +function applyPositionalSubcommand( + positional: readonly string[], + out: ICliArgs +): void { + const head = positional[0]; + + if (head === "review") { out.review = true; out.task = positional.slice(1).join(" ").trim(); - } else if (positional[0] === "map") { + } else if (head === "map") { out.map = true; out.task = positional.slice(1).join(" ").trim(); - } else if (positional[0] === "trace") { + } else if (head === "trace") { out.trace = true; out.task = positional.slice(1).join(" ").trim(); - } else if (positional[0] === "recipes") { + } else if (head === "recipes") { out.recipes = true; - } else if (positional[0] === "agents") { + } else if (head === "agents") { // `tsforge agents` lists specs; `tsforge agents explore,verify "task"` // fans the named specs out over the task. out.agents = true; out.agentIds = positional[1] ?? ""; out.task = positional.slice(2).join(" ").trim(); - } else if (positional[0] === "setup") { + } else if (head === "setup") { out.setup = true; - } else if (positional[0] === "run") { + } else if (head === "run") { out.run = true; out.recipe = positional[1] ?? ""; out.task = positional.slice(2).join(" ").trim(); } - - out.dir = isAbsolute(out.dir) ? out.dir : join(process.cwd(), out.dir); - - return out; } /** Assign one `--flag value` into the args (mutates `out`). */ diff --git a/packages/core/src/cli/banner.ts b/packages/core/src/cli/banner.ts index 914283a2..b49d1a44 100644 --- a/packages/core/src/cli/banner.ts +++ b/packages/core/src/cli/banner.ts @@ -1,9 +1,12 @@ -/** The CLI's landing surface: welcome banner, the compact startup hint line, - * the plan-mode footer chip, and the resumed-transcript replay. */ -import { join } from "node:path"; -import { existsSync } from "node:fs"; -import { renderMessage, welcomeBanner, STYLE, paint } from "../render"; -import type { ISessionRecord } from "../session-store"; +/** Shared CLI chrome helpers: scope labels and the plan-mode footer chip. */ +import { + STYLE, + paint, + roleCardCols, + filledRoleBadge, + roleHairline, + roleBadgeCols, +} from "../render"; /** Human label for an editable scope (the whole-repo default reads nicer). */ export function scopeLabel(files: string[]): string { @@ -12,90 +15,33 @@ export function scopeLabel(files: string[]): string { : files.join(", "); } -/** A single compact "how to start" line under the banner — the only guidance the - * landing screen needs. The internals (cwd, scope, gate, session) live in /config. */ -function startupHint(): string { - const tip = (key: string, label: string): string => - `${paint(key, STYLE.brand + STYLE.bold, true)} ${paint(label, STYLE.dim, true)}`; - const sep = paint(" · ", STYLE.dim, true); - - return ` ${[ - tip("/help", "commands"), - tip("@", "files"), - tip("/setup", "guardrails"), - tip("/exit", "quit"), - ].join(sep)}`; -} - -/** The post-turn plan-mode footer — a compact styled chip (matches the startup - * plan line) instead of a plain full-width parenthetical. `ready` = the agent has - * proposed a plan (nudge toward approve); otherwise it's still exploring. */ -export function planHint(ready: boolean): string { - const chip = paint( - `◆ plan${ready ? " ready" : ""}`, - STYLE.brand + STYLE.bold, - true +/** Hollow orange chip around the approve keyword (inline outlined pill). */ +function approveChip(): string { + return ( + paint("[", STYLE.plan, true) + + paint(" APPROVE ", STYLE.plan + STYLE.bold, true) + + paint("]", STYLE.plan, true) ); - const reply = paint("reply to refine · type", STYLE.dim, true); - const approve = paint("approve", STYLE.green + STYLE.bold, true); - const tail = paint(ready ? "to build" : "when ready", STYLE.dim, true); - - return ` ${chip} ${paint("·", STYLE.dim, true)} ${reply} ${approve} ${tail}`; } -/** Print the welcome banner, a compact hint, and (when resuming) the prior transcript. */ -export function printHeader(info: { - dir: string; - id: string; - gateLabel: string; - files: string[]; - resumed: ISessionRecord | null; - model: { model: string; endpoint: string }; - updateNotice?: string | null; -}): void { - const { resumed, model, updateNotice } = info; - - if (process.stdout.isTTY) { - // Clean slate: wipe the visible screen AND scrollback so the banner never - // lands on top of leftover shell output (env dumps, prior command noise). - process.stdout.write("\x1b[2J\x1b[3J\x1b[H"); - } - - process.stdout.write(welcomeBanner(model)); - - if (updateNotice !== undefined && updateNotice !== null) { - process.stdout.write(`${updateNotice}\n`); - } - - process.stdout.write(`${startupHint()}\n\n`); - - if (resumed === null) { - return; - } - - // Replay the prior conversation so a resumed session has visible context. - process.stdout.write("\n── resuming conversation ──\n"); - - for (const message of resumed.messages) { - process.stdout.write( - renderMessage(message, { color: true, speaker: model.model }) - ); - } - - process.stdout.write("\n──────────────────────────\n"); -} - -/** One-line nudge when the repo has no config yet — setup adapts the guardrails - * to this repo's conventions. Just a hint; never auto-runs. */ -export function maybePrintNoConfigHint( - dir: string, - resumed: ISessionRecord | null -): void { - if (resumed === null && !existsSync(join(dir, "tsforge.config.json"))) { - const icon = paint("○", STYLE.yellow, true); - const run = paint("/setup", STYLE.brand + STYLE.bold, true); - const rest = paint("to adapt the guardrails to this repo", STYLE.dim, true); - - process.stdout.write(` ${icon} no project config — run ${run} ${rest}\n`); - } +/** The post-turn plan-mode footer — filled PLAN badge + orange rail, matching + * the agent-console plan strip. `ready` = plan proposed (nudge build); + * otherwise still exploring. */ +export function planHint(ready: boolean, columns?: number): string { + const cols = roleCardCols(columns); + const badge = filledRoleBadge("PLAN", true); + const top = + badge + roleHairline(cols, STYLE.plan, true, "", roleBadgeCols(badge)); + // Thin rail + inner pad — matches USER/AGENT breathing room. + const gutter = paint("│", STYLE.plan, true) + " "; + const action = ready ? "TO BUILD" : "TO CONTINUE"; + const body = [ + paint("REPLY TO REFINE", STYLE.plan + STYLE.bold, true), + paint(" | ", STYLE.plan, true), + paint("TYPE ", STYLE.plan, true), + approveChip(), + paint(` ${action}`, STYLE.plan, true), + ].join(""); + + return `${top}\n${gutter}\n${gutter}${body}`; } diff --git a/packages/core/src/cli/capability-menu.ts b/packages/core/src/cli/capability-menu.ts index 05855997..0a4a9aaf 100644 --- a/packages/core/src/cli/capability-menu.ts +++ b/packages/core/src/cli/capability-menu.ts @@ -15,6 +15,10 @@ export interface ICapabilityMenuDeps { readonly openWizard: (opener: "scaffold" | "recipe") => Promise; readonly render: (lines: readonly string[]) => void; readonly close: () => void; + /** Overlay width. Prefer main-pane inner cols when the pane console is live. */ + readonly columns?: number; + /** Max overlay rows. Prefer pane chrome budget when panes are live. */ + readonly viewportRows?: number; } /** @@ -69,6 +73,8 @@ export function runCapabilityMenu(deps: ICapabilityMenuDeps): Promise { title: "tsforge — what can I do?", render: deps.render, close: deps.close, + columns: deps.columns, + viewportRows: deps.viewportRows, }).then((selected) => { if (selected === null) { return Promise.resolve(); diff --git a/packages/core/src/cli/commands.ts b/packages/core/src/cli/commands.ts index 816023a5..d877584e 100644 --- a/packages/core/src/cli/commands.ts +++ b/packages/core/src/cli/commands.ts @@ -31,7 +31,12 @@ export const COMMANDS: readonly ICommandSpec[] = [ { name: "/plan", summary: - "toggle plan mode (on by default: explore → clarify → plan; 'approve' implements)", + "toggle plan mode (on by default: explore → checklist → approve saves + implements)", + }, + { + name: "/copy", + summary: + "leave the pane TUI (if active) and dump the transcript for selection", }, { name: "/gate", diff --git a/packages/core/src/cli/config-menu.ts b/packages/core/src/cli/config-menu.ts index dc0ad53b..8d781ce9 100644 --- a/packages/core/src/cli/config-menu.ts +++ b/packages/core/src/cli/config-menu.ts @@ -1,3 +1,5 @@ +import { CONSOLE } from "../render/frame/chrome"; +import { formatOverlayShell, menuClip, menuRule } from "../render/menu-chrome"; import { STYLE, paint } from "../render/style"; import { runInlineMenu } from "../render/inline-menu"; import type { IMenuRowData } from "../render/inline-menu"; @@ -78,8 +80,12 @@ export interface IConfigDeps { * effect for subsequent turns this session). */ readonly getEnv: (name: string) => string | undefined; readonly setEnv: (name: string, value: string | undefined) => void; - /** The inline menu view (statusBar overlay + close). */ + /** The inline menu view (pane overlay + close). */ readonly view?: IConfigMenuView; + /** Overlay width. Prefer main-pane inner cols when the pane console is live. */ + readonly columns?: number; + /** Max overlay rows. Prefer pane chrome budget when panes are live. */ + readonly viewportRows?: number; } const NON_EMPTY = (label: string) => (v: string) => @@ -307,6 +313,38 @@ function buildMenuRows(settings: ISetting[]): IMenuRowData[] { })); } +/** Pure edit-screen lines — shared overlay shell (title / rule / footer). */ +export function formatConfigEditLines(opts: { + readonly settingLabel: string; + readonly fieldIndex: number; + readonly fieldTotal: number; + readonly fieldLabel: string; + readonly valueShown: string; + readonly error: string | null; + readonly columns: number; + readonly color: boolean; +}): string[] { + const width = Math.max(20, opts.columns); + const title = `${opts.settingLabel} · field ${String(opts.fieldIndex + 1)} of ${String(opts.fieldTotal)}`; + const caret = paint("▏", CONSOLE.bright, opts.color); + const bodyLines = [ + menuRule(width, opts.color), + menuClip(opts.fieldLabel, width), + ` ${opts.valueShown}${caret}`, + ...(opts.error === null + ? [] + : ["", paint(opts.error, STYLE.yellow, opts.color)]), + ]; + + return formatOverlayShell({ + title, + bodyLines, + footer: "type · enter next · esc cancel", + columns: width, + color: opts.color, + }); +} + // ── the driver ─────────────────────────────────────────────────────────────── /** @@ -324,7 +362,12 @@ export function runConfigMenu(deps: IConfigDeps): Promise { const settings = buildSettings(deps); let editState: IEditState | null = null; - const columns = process.stdout.columns > 0 ? process.stdout.columns : 80; + const columns = + deps.columns !== undefined && deps.columns > 0 + ? deps.columns + : process.stdout.columns > 0 + ? process.stdout.columns + : 80; const drawEdit = (): void => { if (editState === null) { @@ -337,17 +380,18 @@ export function runConfigMenu(deps: IConfigDeps): Promise { const error = fieldError(editState); const total = editState.setting.fields?.length ?? 1; - const lines: string[] = [ - `${paint(editState.setting.label, STYLE.bold, deps.color)} · field ${editState.fieldIndex + 1} of ${total}`, - "─".repeat(columns), - field.label, - ` ${shown}${paint("▏", STYLE.brand, deps.color)}`, - ...(error === null ? [] : ["", paint(error, STYLE.yellow, deps.color)]), - "", - paint("type enter next esc cancel", STYLE.dim, deps.color), - ]; - - view.render(lines); + view.render( + formatConfigEditLines({ + settingLabel: editState.setting.label, + fieldIndex: editState.fieldIndex, + fieldTotal: total, + fieldLabel: field.label, + valueShown: shown, + error, + columns, + color: deps.color, + }) + ); }; const handleEditKey = (str: string | undefined, key: IKeyInfo): boolean => { @@ -423,6 +467,8 @@ export function runConfigMenu(deps: IConfigDeps): Promise { close: () => { view.close(); }, + columns, + viewportRows: deps.viewportRows, }).then((selected) => { if (!running) { return; diff --git a/packages/core/src/cli/repl-recipe.ts b/packages/core/src/cli/repl-recipe.ts index 98a311d7..84270a3d 100644 --- a/packages/core/src/cli/repl-recipe.ts +++ b/packages/core/src/cli/repl-recipe.ts @@ -12,6 +12,10 @@ export interface IReplRecipeDeps { readonly close: () => void; readonly runRecipe: (recipe: ITaskRecipe) => void; readonly out: (s: string) => void; + /** Overlay width. Prefer main-pane inner cols when the pane console is live. */ + readonly columns?: number; + /** Max overlay rows. Prefer pane chrome budget when panes are live. */ + readonly viewportRows?: number; } /** @@ -46,6 +50,8 @@ export async function openRecipePicker(deps: IReplRecipeDeps): Promise { title: "recipes", render: deps.render, close: deps.close, + columns: deps.columns, + viewportRows: deps.viewportRows, }); if (selected !== null) { diff --git a/packages/core/src/cli/repl-scaffold.ts b/packages/core/src/cli/repl-scaffold.ts index 572001aa..21e14f84 100644 --- a/packages/core/src/cli/repl-scaffold.ts +++ b/packages/core/src/cli/repl-scaffold.ts @@ -1,5 +1,5 @@ import { runWizard } from "../render/wizard"; -import type { IWizardStep } from "../render/wizard.types"; +import type { IWizardStep, IWizardView } from "../render/wizard.types"; import { buildScaffoldSteps, stateToAnswers, @@ -16,6 +16,12 @@ export interface IReplScaffoldDeps { readonly suspend: () => void; readonly resume: () => void; readonly out: (s: string) => void; + /** Pane overlay — when set, scaffold wizards skip nested alt-screen. */ + readonly view?: IWizardView; + /** Overlay width. Prefer main-pane inner cols when the pane console is live. */ + readonly columns?: number; + /** Max overlay rows. Prefer pane chrome budget when panes are live. */ + readonly viewportRows?: number; } /** Free-text step: the folder name for the new project (created under cwd). */ @@ -169,12 +175,23 @@ export async function openScaffoldInRepl( const color = process.stdout.isTTY; const manifest = loadBundledManifest(); - // Step 1: Run archetype selection wizard - const archetypeState = await runWizard([archetypeStep()], color, { + const wizardOpts = { title: "tsforge scaffold", manageInput: false, out: deps.out, - }); + ...(deps.view === undefined ? {} : { view: deps.view }), + ...(deps.columns === undefined ? {} : { columns: deps.columns }), + ...(deps.viewportRows === undefined + ? {} + : { viewportRows: deps.viewportRows }), + }; + + // Step 1: Run archetype selection wizard + const archetypeState = await runWizard( + [archetypeStep()], + color, + wizardOpts + ); if (archetypeState.status !== "apply") { deps.out("scaffold: cancelled — nothing was created.\n"); @@ -199,11 +216,7 @@ export async function openScaffoldInRepl( const configState = await runWizard( [projectDirStep(), ...superuserSteps, ...configSteps], color, - { - title: "tsforge scaffold", - manageInput: false, - out: deps.out, - } + wizardOpts ); if (configState.status !== "apply") { diff --git a/packages/core/src/cli/repl.ts b/packages/core/src/cli/repl.ts index 92f48f4b..a52b5f24 100644 --- a/packages/core/src/cli/repl.ts +++ b/packages/core/src/cli/repl.ts @@ -1,6 +1,6 @@ /** * The interactive REPL: a persistent gate-anchored conversation. Owns the - * status bar, the multi-line editor / readline fallback, the slash-command + * pane console, the multi-line editor / readline fallback, the slash-command * dispatcher, plan-mode flow, and the inline overlays (palette, @ picker, * /config, /help). Extracted from cli.ts; the entry point stays `repl(args)`. */ @@ -39,6 +39,7 @@ import { import { Session, PLAN_APPROVED_NOTE, + isEphemeralUserInject, type Reporter, type ILoopEvent, } from "../loop"; @@ -71,15 +72,23 @@ import { userBubble, agentCardTop, agentCardBottom, + agentCardPadRow, agentBar, + agentRight, + agentRailInnerCols, makeAgentRail, - StatusBar, - MIN_ROWS, STYLE, paint, - PROMPT_COLS, + FORGE_EDITOR_GUTTER, + renderMessage, + inputContentCols, renderAgentTree, AgentTreeModel, + PaneScreen, + stripMouseReports, + canUsePaneTui, + PANE_MIN_ROWS, + INPUT_INNER_ROWS_MAX, type IStatusInfo, type IAgentRow, } from "../render"; @@ -113,12 +122,7 @@ import { runModelCommand, modelForRun, } from "./model-setup"; -import { - scopeLabel, - planHint, - printHeader, - maybePrintNoConfigHint, -} from "./banner"; +import { scopeLabel, planHint } from "./banner"; import { resolveGate, type AutoGateResolver } from "./gate-setup"; import { printSessions, @@ -127,6 +131,17 @@ import { runReviewCommand, runTraceCommand, } from "./repl-commands"; +import { + formatWorklistLines, + formatPlanProposal, + worklistBadge, + pendingPlanBadge, + seedWorklistFromPlan, + persistPlanDocument, + goalFromMessages, + loadPlan, +} from "../loop/worklist"; +import type { IPlanDocument } from "../loop/worklist"; /** A unique-enough id for a new session (time + a little randomness). */ function newSessionId(): string { @@ -371,6 +386,26 @@ export function humanAtKeyboard(): boolean { return process.stdin.isTTY; } +/** + * When non-null, an interactive TTY cannot host the pane console — caller should + * print the reason and exit. Non-TTY / pipes return null (plain path, no panes). + */ +export function paneConsoleRejectReason(opts: { + stdinTty: boolean; + stdoutTty: boolean; + rows: number; +}): string | null { + if (!opts.stdinTty || !opts.stdoutTty) { + return null; + } + + if (canUsePaneTui(opts.rows)) { + return null; + } + + return `tsforge: need a terminal at least ${String(PANE_MIN_ROWS)} rows high (got ${String(opts.rows)})`; +} + // The /help body is generated from the command registry (src/cli/commands.ts) so // the help text and the interactive `/` palette can never drift. const HELP = formatHelp(); @@ -484,6 +519,10 @@ async function initReplSession(args: ICliArgs): Promise<{ // gate so it re-gates on the first send — never silently dropped across --continue // (WS-C; the persisted counterpart of the /clear carry). ...(resumed?.pausedWithEdit === true ? { pausedWithEdit: true } : {}), + ...(typeof resumed?.activePlanId === "string" && + resumed.activePlanId.length > 0 + ? { activePlanId: resumed.activePlanId } + : {}), ...(thinkingTokenBudget === undefined ? {} : { thinkingTokenBudget }), ...(autoCompactAt === undefined ? {} : { autoCompactAt }), // `--policy-mode` (validated) overrides the config file's policy.mode. @@ -548,6 +587,24 @@ export function resumedProfileArg( return cliProfile.length === 0 && isProfileId(saved) ? saved : cliProfile; } +/** One-line plan-mode banner for a fresh interactive session. */ +function maybeWritePlanModeIntro(planMode: boolean): void { + if (!planMode) { + return; + } + + const chip = paint("◆ plan mode (default)", STYLE.brand + STYLE.bold, true); + const body = paint( + "— I'll explore and propose a plan; reply", + STYLE.dim, + true + ); + const approve = paint("approve", STYLE.green + STYLE.bold, true); + const tail = paint("to build", STYLE.dim, true); + + process.stdout.write(` ${chip} ${body} ${approve} ${tail}\n`); +} + /** Interactive REPL: a persistent gate-anchored conversation. */ export async function repl(args: ICliArgs): Promise { // Interactive sessions get web tools ON by default (an assistant that can't look @@ -564,7 +621,6 @@ export async function repl(args: ICliArgs): Promise { gateLabel: initialGateLabel, logFile, resumed, - files, activeModelEntry, autoGate, } = await initReplSession(args); @@ -609,6 +665,7 @@ export async function repl(args: ICliArgs): Promise { planMode, // Persist a still-pending deferred gate so --continue re-gates it (WS-C). pausedWithEdit: session.hasDeferredGate, + activePlanId: session.getActivePlanId(), messages: [...session.messages], }); }; @@ -620,26 +677,26 @@ export async function repl(args: ICliArgs): Promise { refreshUpdateCacheInBackground(); - printHeader({ - dir: args.dir, - id, - gateLabel, - files, - resumed, - model: modelInfo(provider.config), - updateNotice, + // Landing is seeded into PaneScreen after enter() — never print a banner into + // the primary buffer (it would flash, then get wiped). + + const interactiveTty = process.stdin.isTTY && process.stdout.isTTY; + const rejectPane = paneConsoleRejectReason({ + stdinTty: process.stdin.isTTY, + stdoutTty: process.stdout.isTTY, + rows: process.stdout.rows > 0 ? process.stdout.rows : 0, }); - maybePrintNoConfigHint(args.dir, resumed); + if (rejectPane !== null) { + process.stderr.write(`${rejectPane}\n`); + + return 1; + } - // Pin an editable input row only on a real TTY tall enough to host the bar. - // In that mode readline does line-EDITING but must not RENDER (we paint the - // row ourselves), so it gets a discard sink for output; otherwise it writes to - // stdout as before (pipes, small terminals — behaviour unchanged). - const useInputRow = - process.stdin.isTTY && - process.stdout.isTTY && - process.stdout.rows >= MIN_ROWS; + // Interactive TTY always hosts the pane console (height gated above). Readline + // does line-EDITING but must not RENDER — we paint via PaneScreen. Pipes use + // plain stdout (no panes). + const useInputRow = interactiveTty; // In editor mode, do NOT create readline — the editor owns stdin exclusively. // In fallback mode (non-TTY or basicInput), readline is the only consumer. @@ -700,23 +757,11 @@ export async function repl(args: ICliArgs): Promise { // (@file/image expansion still apply — it is not sent byte-for-byte verbatim). let awaitingUserAnswer = false; // The current interactive mode (Shift+Tab cycles it; /plan toggles it). Kept in - // sync with `planMode`; shown as a chip in the status bar. + // sync with `planMode`; shown as a chip in the pane footer. let currentModeId = planMode ? "plan" : "normal"; session.setPlanMode(planMode); - - if (planMode) { - const chip = paint("◆ plan mode (default)", STYLE.brand + STYLE.bold, true); - const body = paint( - "— I'll explore and propose a plan; reply", - STYLE.dim, - true - ); - const approve = paint("approve", STYLE.green + STYLE.bold, true); - const tail = paint("to build", STYLE.dim, true); - - process.stdout.write(` ${chip} ${body} ${approve} ${tail}\n`); - } + maybeWritePlanModeIntro(planMode); // Model-driven delegation: the orchestrator can spawn read-only specialist // subagents via the `spawn_agent` tool — the user never names an agent. @@ -761,9 +806,9 @@ export async function repl(args: ICliArgs): Promise { // Image capabilities: offer read_image/generate_image when their backends are // configured, and wire the inline preview for generated images. The preview - // emits the terminal's inline-image escape (iTerm2 today) via the StatusBar - // stream so the pinned bar re-anchors below it; unsupported terminal → no-op - // (the tool still reports the saved path). + // emits the terminal's inline-image escape (iTerm2 today) via the pane stream + // so it lands in scrollback; unsupported terminal → no-op (the tool still + // reports the saved path). // A small budget stops a runaway loop from flooding the scrollback. Re-applied // after /clear (like setSetupWeb/wireDelegation) since /clear rebuilds session. const imageProtocol = detectImageProtocol(); @@ -772,18 +817,39 @@ export async function repl(args: ICliArgs): Promise { // consumed (described + cleared) on the next send by resolveImageInput. const pendingImages: string[] = []; + /** Pane-console chrome facade (overlays / agent tree / input). */ + interface ILiveChrome { + hasChrome(): boolean; + setOverlay(lines: readonly string[]): void; + clearOverlay(): void; + setEditorOverlay(lines: readonly string[]): void; + clearEditorOverlay(): void; + setAgentTree(lines: readonly string[]): void; + clearAgentTree(): void; + setInput(line: string, cursor: number): void; + setEditor( + lines: readonly string[], + cursorRow: number, + cursorCol: number + ): void; + } + + // Assigned once PaneScreen exists; pasteFromClipboard closes over it. + let liveChrome: ILiveChrome | null = null; + // Ctrl+V in the editor: a clipboard IMAGE becomes a `[image #N]` chip + a pending // attachment (described on send); otherwise fall back to pasting clipboard text. // (Cmd+V is swallowed by the terminal — for text it arrives as a bracketed paste; // an image on the clipboard never reaches an in-terminal app, hence Ctrl+V.) + // Uses `liveChrome` (assigned after PaneScreen exists) for the hint. const pasteFromClipboard = async (): Promise => { // Reading the clipboard shells out (osascript can take ~1s), so show a // transient hint above the input so the pause reads as "working", not hung. // (Install `pngpaste` to make it instant — the reader prefers it.) - const hinting = statusBar.active; + const hinting = liveChrome?.hasChrome() === true; if (hinting) { - statusBar.setEditorOverlay(["📋 reading clipboard…"]); + liveChrome?.setEditorOverlay(["📋 reading clipboard…"]); } try { @@ -802,7 +868,7 @@ export async function repl(args: ICliArgs): Promise { return text.trim().length > 0 ? text : null; } finally { if (hinting) { - statusBar.clearEditorOverlay(); + liveChrome?.clearEditorOverlay(); } } }; @@ -821,12 +887,10 @@ export async function repl(args: ICliArgs): Promise { const escape = renderInlineImage(base64, imageProtocol, { name }); if (escape !== null) { - // Route through the StatusBar stream channel (NOT raw stdout): it commits the - // content to scrollback and re-anchors the pinned bar/input row at the cursor - // the terminal left below the image. A raw write left the bar's cursor - // tracking stale, so it painted over the image (overlapping text). Bracket - // with newlines so the image sits on its own committed lines. - statusBar.writeStream(`\n${escape}\n`); + // Route through the pane stream (NOT raw stdout) so the image lands in + // scrollback and the next paint keeps chrome intact. Bracket with newlines + // so the image sits on its own committed lines. + streamOut(`\n${escape}\n`); } }; @@ -902,9 +966,9 @@ export async function repl(args: ICliArgs): Promise { } finally { spinner.stop(); active = null; - // Seal the agent card's `╰` bottom cap the moment streaming ends, so any - // post-turn hint (plan-mode notice, PLAN review, etc.) lands BELOW the card - // instead of inside it — which would break the rail. Idempotent. + // Close the agent card the moment streaming ends, so any post-turn hint + // (plan-mode notice, PLAN review, etc.) lands BELOW the card instead of + // inside it — which would break the rail. Idempotent. closeAgentTurn(); resetTree(); // clear the live agent tree once the turn's delegation is done } @@ -954,6 +1018,74 @@ export async function repl(args: ICliArgs): Promise { return session.send(`${contextBlock}${composed}`, opts); }); + const resolveApprovedPlan = (): IPlanDocument | null => { + const pending = session.takePendingPlan(); + + if (pending !== null) { + return persistPlanDocument(args.dir, pending); + } + + const last = session.messages.at(-1); + const planText = + last?.role === "assistant" && typeof last.content === "string" + ? last.content + : ""; + const seeded = seedWorklistFromPlan( + args.dir, + planText, + goalFromMessages(session.messages) + ); + + if (!seeded.ok) { + echo(` ✗ ${seeded.error}\n`); + + return null; + } + + return seeded.plan; + }; + + const approveBoundPlan = async (): Promise => { + const plan = resolveApprovedPlan(); + + if (plan === null) { + return; + } + + session.setActivePlanId(plan.id); + syncWorklistPanel(plan); + echo( + ` ✓ plan saved — ${plan.id} (${String(plan.items.length)} top-level items)\n` + ); + planMode = false; + planDiscussed = false; + session.setPlanMode(false); + await persist(); + echo(" ✓ plan approved — implementing\n"); + await drive((opts) => session.send(PLAN_APPROVED_NOTE, opts)); + }; + + const discussInPlanMode = async (line: string): Promise => { + await runSend(line); + planDiscussed = true; + + // present_plan already paints the card + approve footer mid-turn. Only + // nudge here for the legacy ## Plan heading path (no present_plan call). + const last = session.messages.at(-1); + const plannedHeading = + last?.role === "assistant" && /^##\s*plan\b/im.test(last.content); + + if (plannedHeading && session.getPendingPlan() === null) { + const cols = panesLive() + ? paneScreen.mainInnerCols() + : process.stdout.columns > 0 + ? process.stdout.columns + : 80; + + echo(`\n${planHint(true, cols)}\n`); + } + }; + const dispatch = async (line: string): Promise => { const route = classifyReplRoute(line, { planMode, @@ -973,15 +1105,10 @@ export async function repl(args: ICliArgs): Promise { return; } - // GENERAL plan mode, approval: unlock the tools and implement the plan that - // is already the latest assistant message. Only an explicit approval word - // counts ("yes" may be answering one of the model's clarifying questions). + // GENERAL plan mode, approval: bind present_plan proposal (or fenced JSON + // fallback), unlock tools, implement. Only an explicit approval word counts. if (route === "plan-approval") { - planMode = false; - planDiscussed = false; - session.setPlanMode(false); - echo(" ✓ plan approved — implementing\n"); - await drive((opts) => session.send(PLAN_APPROVED_NOTE, opts)); + await approveBoundPlan(); return; } @@ -989,14 +1116,7 @@ export async function repl(args: ICliArgs): Promise { // GENERAL plan mode, discussion: the agent explores read-only, asks its // clarifying questions, and proposes/revises a plan. Stays in plan mode. if (route === "plan-discuss") { - await runSend(line); - planDiscussed = true; - - const last = session.messages.at(-1); - const planned = - last?.role === "assistant" && /^##\s*plan\b/im.test(last.content); - - echo(`\n${planHint(planned)}\n`); + await discussInPlanMode(line); return; } @@ -1027,6 +1147,62 @@ export async function repl(args: ICliArgs): Promise { // Placeholder declarations; defined after runLine / editorControl are available. let handleHelp: () => Promise; let openScaffold: () => Promise; + // Assigned after the pane console exists (same pattern as handleHelp). + let handleCopy: () => void = () => undefined; + + const clearConversation = async (): Promise => { + // Rebuild the session with the current state (config is not reused; + // repl's /clear creates a fresh Session.create call) + const profile = resolveCliProfile(args.profile); + // Carry a still-unvalidated pre-pause edit across the rebuild so /clear does not + // silently drop the deferred gate: the gate fires on mutation state (`edited`), + // not merely a dirty tree, so a fresh session would otherwise never re-validate + // the on-disk edit on a conversational send (WS-C). + const carryDeferredGate = session.hasDeferredGate; + const carryPlanId = session.getActivePlanId(); + + session = await Session.create({ + provider, + cwd: args.dir, + files: session.scope, + accept: session.gate, + contextWindow, + report: makeReporter(logFile, id, id), + enableThinking: false, + // Keep ask_user (WS-C) offered after /clear when a human is present — but gated + // on the TTY like the init session, so a piped REPL doesn't advertise a pause + // nobody can answer. + interactive: humanAtKeyboard(), + // Keep the SCOPED format janitor on across /clear — else the rebuilt session + // silently reverts to no formatting for the rest of the session. + coreFormat: true, + // Keep the AUTO gate re-detecting across /clear — else the rebuild freezes on + // the last static command and stops picking up new framework packs. Withheld + // once a manual /gate has taken over (autoGateActive false), so the rebuild + // never silently re-arms the auto gate over the user's command. + ...autoGateCarry(autoGate, session.autoGateActive), + // Plain boolean (no branch): the constructor only seeds the flag when true. + pausedWithEdit: carryDeferredGate, + ...(profile === undefined ? {} : { profile }), + ...(carryPlanId !== null ? { activePlanId: carryPlanId } : {}), + }); + wireDelegation(); // re-offer spawn_agent on the rebuilt session + wireImages(); // re-offer read_image/generate_image + preview on the rebuild + wirePlanRail(); + // Drop any un-sent clipboard captures — /clear wipes the buffer (and its + // chips), so their temp files are now orphaned. + void discardClipboardImages(pendingImages.splice(0)); + session.setPlanMode(planMode); // a /clear must not silently drop the mode + planDiscussed = false; + // /clear rebuilds the Session, so the pending ask_user QUESTION is gone — drop the + // answer-routing flag. (The still-unvalidated EDIT behind that pause is not lost: + // it's carried into the new session via pausedWithEdit above, so its gate still + // fires on the first send.) + awaitingUserAnswer = false; + await persist(); + clearScreen(); // wipe the visible terminal + scrollback, not just the state + echo("conversation cleared\n"); + }; // Slash-command dispatch. Returns true to EXIT the REPL. Kept as a closure so // it can rebuild `session` (e.g. /clear) and reach config/persist. @@ -1042,57 +1218,13 @@ export async function repl(args: ICliArgs): Promise { await handleHelp(); break; - case "clear": { - // Rebuild the session with the current state (config is not reused; - // repl's /clear creates a fresh Session.create call) - const profile = resolveCliProfile(args.profile); - // Carry a still-unvalidated pre-pause edit across the rebuild so /clear does not - // silently drop the deferred gate: the gate fires on mutation state (`edited`), - // not merely a dirty tree, so a fresh session would otherwise never re-validate - // the on-disk edit on a conversational send (WS-C). - const carryDeferredGate = session.hasDeferredGate; - - session = await Session.create({ - provider, - cwd: args.dir, - files: session.scope, - accept: session.gate, - contextWindow, - report: makeReporter(logFile, id, id), - enableThinking: false, - // Keep ask_user (WS-C) offered after /clear when a human is present — but gated - // on the TTY like the init session, so a piped REPL doesn't advertise a pause - // nobody can answer. - interactive: humanAtKeyboard(), - // Keep the SCOPED format janitor on across /clear — else the rebuilt session - // silently reverts to no formatting for the rest of the session. - coreFormat: true, - // Keep the AUTO gate re-detecting across /clear — else the rebuild freezes on - // the last static command and stops picking up new framework packs. Withheld - // once a manual /gate has taken over (autoGateActive false), so the rebuild - // never silently re-arms the auto gate over the user's command. - ...autoGateCarry(autoGate, session.autoGateActive), - // Plain boolean (no branch): the constructor only seeds the flag when true. - pausedWithEdit: carryDeferredGate, - ...(profile === undefined ? {} : { profile }), - }); - wireDelegation(); // re-offer spawn_agent on the rebuilt session - wireImages(); // re-offer read_image/generate_image + preview on the rebuild - // Drop any un-sent clipboard captures — /clear wipes the buffer (and its - // chips), so their temp files are now orphaned. - void discardClipboardImages(pendingImages.splice(0)); - session.setPlanMode(planMode); // a /clear must not silently drop the mode - planDiscussed = false; - // /clear rebuilds the Session, so the pending ask_user QUESTION is gone — drop the - // answer-routing flag. (The still-unvalidated EDIT behind that pause is not lost: - // it's carried into the new session via pausedWithEdit above, so its gate still - // fires on the first send.) - awaitingUserAnswer = false; - await persist(); - clearScreen(); // wipe the visible terminal + scrollback, not just the state - process.stdout.write("conversation cleared\n"); + case "copy": + handleCopy(); + break; + + case "clear": + await clearConversation(); break; - } case "compact": { // Compaction is a full model round-trip (can take many seconds). Drive the @@ -1155,16 +1287,41 @@ export async function repl(args: ICliArgs): Promise { case "setup": { const { runSetup } = await import("../setup/run-setup"); - // runSetup prints its own apply/cancel summary — don't add a second, - // possibly-misleading line (it would claim success even on cancel). - await runSetup({ - cwd: args.dir, - yes: false, - color: process.stdout.isTTY, - // The REPL editor/readline owns stdin — don't let the wizard pause it - // on exit (that would quit the whole process). - manageInput: false, - }); + // Same suspend + overlay pattern as `/config`: without it, setup opens a + // nested alt-screen (fights PaneScreen) and Enter races the editor. + editorControl?.suspend(); + editorControl?.setInputInert(true); + + try { + // runSetup prints its own apply/cancel summary — don't add a second, + // possibly-misleading line (it would claim success even on cancel). + await runSetup({ + cwd: args.dir, + yes: false, + color: process.stdout.isTTY, + // The REPL editor/readline owns stdin — don't let the wizard pause it + // on exit (that would quit the whole process). + manageInput: false, + out: (s) => { + streamOut(s); + }, + view: { + render: (lines) => { + chrome.setOverlay(lines); + }, + close: () => { + chrome.clearOverlay(); + }, + }, + columns: transcriptCols(), + viewportRows: overlayBudget(), + }); + } finally { + editorControl?.setInputInert(false); + editorControl?.resume(); + editorControl?.getBuffer().setText(""); + } + break; } @@ -1271,8 +1428,7 @@ export async function repl(args: ICliArgs): Promise { return false; }; - // Current state as the status surface sees it — shared by the pinned bar and - // the inline fallback so both show identical content. + // Current state as the status surface sees it — pane footer and plain prompt. const statusInfo = (): IStatusInfo => ({ model: modelInfo(provider.config).model, contextTokens: session.contextTokens, @@ -1288,9 +1444,195 @@ export async function repl(args: ICliArgs): Promise { : {}), }); - // Pinned bottom status bar when we're on a real terminal; otherwise the bar is - // inactive and `prompt()` falls back to the inline status line (pipes, --log). - const statusBar = new StatusBar(process.stdout, true, true, useInputRow); + // Pane console — the only interactive UI on a TTY. + const paneScreen = new PaneScreen(process.stdout); + let exitCode = 0; + + /** Main-pane content width (or full tty when panes are not live). Menus, + * bubbles, and hairlines must use this — full stdout.columns punches through + * the side panel. */ + const transcriptCols = (): number => { + if (paneScreen.active) { + return Math.max(20, paneScreen.mainInnerCols()); + } + + const cols = process.stdout.columns; + + return cols > 0 ? cols : 80; + }; + + /** Max overlay rows — pane chrome budget when live, else tty rows. */ + const overlayBudget = (): number => { + if (paneScreen.active) { + return paneScreen.overlayBudgetRows(); + } + + const rows = process.stdout.rows; + + return rows > 0 ? rows : 24; + }; + + /** True while the alt-screen pane console owns the terminal. */ + const panesLive = (): boolean => paneScreen.active; + + // Overlays / agent tree / editor → PaneScreen only. + const chrome: ILiveChrome = { + hasChrome: () => panesLive(), + setOverlay(lines) { + if (panesLive()) { + paneScreen.setOverlay(lines); + } + }, + clearOverlay() { + if (panesLive()) { + paneScreen.clearOverlay(); + } + }, + setEditorOverlay(lines) { + if (panesLive()) { + paneScreen.setOverlay(lines); + } + }, + clearEditorOverlay() { + if (panesLive()) { + paneScreen.clearOverlay(); + } + }, + setAgentTree(lines) { + if (panesLive()) { + paneScreen.setAgentTree(lines); + } + }, + clearAgentTree() { + if (panesLive()) { + paneScreen.clearAgentTree(); + } + }, + setInput(line, cursor) { + if (panesLive()) { + paneScreen.setInput({ + lines: [line], + cursorRow: 0, + cursorCol: cursor, + }); + } + }, + setEditor(lines, cursorRow, cursorCol) { + if (panesLive()) { + paneScreen.setInput({ lines, cursorRow, cursorCol }); + } + }, + }; + + liveChrome = chrome; + + const syncPaneChrome = (): void => { + if (!panesLive()) { + return; + } + + paneScreen.setStatus(statusInfo()); + }; + + /** Keep pane footer metrics in sync. */ + const refreshStatus = (): void => { + syncPaneChrome(); + }; + + /** Dump transcript to the primary buffer for copy, then re-enter panes. */ + const dumpPanesTranscript = (): void => { + if (!panesLive()) { + return; + } + + const transcript = paneScreen.dumpTranscript(); + + paneScreen.leave(); + process.stdout.write( + (transcript.length > 0 ? `${transcript}\n` : "") + + "(transcript dumped above for copy)\n" + ); + + if (paneScreen.enter()) { + syncPaneChrome(); + } + }; + + handleCopy = (): void => { + if (panesLive()) { + dumpPanesTranscript(); + } else { + process.stdout.write("(nothing to dump — pane TUI is not active)\n"); + } + }; + + /** Paint the Tasks rail from the session-bound plan (or empty-rail hints). */ + const syncWorklistPanel = (plan: IPlanDocument | null): void => { + if (!panesLive()) { + return; + } + + const cols = Math.max(12, paneScreen.panelInnerCols()); + const maxPending = Math.max(4, paneScreen.panelListBudgetRows()); + + paneScreen.setPanel( + formatWorklistLines(plan, { + columns: cols, + maxPending, + color: true, + }) + ); + paneScreen.setWorklistBadge(worklistBadge(plan)); + + syncPaneChrome(); + }; + + const wirePlanRail = (): void => { + session.setOnPlanChanged((plan) => { + syncWorklistPanel(plan); + }); + session.setOnPlanPresented((plan) => { + planDiscussed = true; + const cols = panesLive() + ? paneScreen.mainInnerCols() + : process.stdout.columns > 0 + ? process.stdout.columns + : 80; + + echo(`\n${formatPlanProposal(plan, cols, true)}\n`); + + // Preview in Tasks rail before approve (pending badge). + if (panesLive()) { + paneScreen.setPanel( + formatWorklistLines(plan, { + columns: Math.max(12, paneScreen.panelInnerCols()), + maxPending: Math.max(4, paneScreen.panelListBudgetRows()), + color: true, + }) + ); + paneScreen.setWorklistBadge(pendingPlanBadge(plan)); + syncPaneChrome(); + } + + echo(`\n${planHint(true, cols)}\n`); + }); + }; + + wirePlanRail(); + + /** Stream conversation text: main pane when live, else plain stdout (pipes). */ + const streamOut = (text: string): void => { + if (panesLive()) { + paneScreen.appendMain(text); + } else { + process.stdout.write(text); + } + }; + + /** Raw terminal modes (bracketed paste, kitty keys) — never transcript. */ + const writeTerm = (text: string): void => { + process.stdout.write(text); + }; // --- live agent tree ------------------------------------------------------ // When the orchestrator delegates (`spawn_agent`), its subagents render as a @@ -1343,14 +1685,14 @@ export async function repl(args: ICliArgs): Promise { }; const repaintTree = (): void => { - if (!statusBar.active) { + if (!chrome.hasChrome()) { return; } const rows = agentTree.rows(); if (rows.length === 0) { - statusBar.setAgentTree([]); + chrome.setAgentTree([]); return; } @@ -1363,7 +1705,7 @@ export async function repl(args: ICliArgs): Promise { ...(focusedAgentId === null ? {} : { selectedId: focusedAgentId }), }); - statusBar.setAgentTree([...tree, ...detailPane(rows)]); + chrome.setAgentTree([...tree, ...detailPane(rows)]); }; const pushAgentOutput = (agentId: string, text: string): void => { @@ -1392,10 +1734,9 @@ export async function repl(args: ICliArgs): Promise { if (id !== undefined && event.kind === "agent_spawned") { // Only DIVERT a subagent's output to the (invisible) detail buffer when the - // tree can actually render it. With the bar inactive (non-TTY / tiny - // terminal) we leave the sink unset so output routes to the parent/stdout - // and stays visible instead of being swallowed. - if (statusBar.active) { + // pane console can render the tree. Otherwise leave the sink unset so + // output routes to the parent/stdout and stays visible. + if (panesLive()) { outputRouter.setAgentSink(id, (t) => { pushAgentOutput(id, t); }); @@ -1462,14 +1803,14 @@ export async function repl(args: ICliArgs): Promise { focusedAgentId = null; userPickedFocus = false; treeActive = false; - statusBar.clearAgentTree(); + chrome.clearAgentTree(); }; observeEvents(feedTree); // Switch the interactive mode (via the extensible registry) and reflect it in - // the status bar. The single entry point for /plan, Shift+Tab, and startup — - // so `planMode`, `currentModeId`, and the bar never drift apart. + // the pane footer. The single entry point for /plan, Shift+Tab, and startup — + // so `planMode`, `currentModeId`, and the chrome never drift apart. const setMode = (id: string): void => { const mode = modeById(id); @@ -1478,9 +1819,7 @@ export async function repl(args: ICliArgs): Promise { planMode = mode.id === "plan"; planDiscussed = false; - if (statusBar.active) { - statusBar.update(statusInfo()); - } + refreshStatus(); }; // `/plan` toggles between plan and normal. Extracted so the slash-command @@ -1552,12 +1891,14 @@ export async function repl(args: ICliArgs): Promise { setEnv, view: { render: (lines) => { - statusBar.setOverlay(lines, statusInfo()); + chrome.setOverlay(lines); }, close: () => { - statusBar.clearOverlay(statusInfo()); + chrome.clearOverlay(); }, }, + columns: transcriptCols(), + viewportRows: overlayBudget(), }); } finally { editorControl?.setInputInert(false); @@ -1565,9 +1906,7 @@ export async function repl(args: ICliArgs): Promise { editorControl?.getBuffer().setText(""); } - if (statusBar.active) { - statusBar.update(statusInfo()); - } + refreshStatus(); await persist(); }; @@ -1581,17 +1920,32 @@ export async function repl(args: ICliArgs): Promise { // wizard — the editor itself is created inside the loop's nested scope. let editorControl: IEditorHandle | null = null; - // Each agent turn renders as a left-accent card: a rounded `╭ ` cap, every - // body line prefixed with the `│ ` rail (wrapping inside it), and a `╰` cap when - // the turn ends. The cap is emitted once, on the turn's first streamed output. - // The card's content budget leaves the rail (2) + 2 spare columns, so no terminal - // — however it treats the right margin — ever wraps a row and drops the rail. - const railInnerWidth = (): number => - (process.stdout.columns > 0 ? process.stdout.columns : 80) - - PROMPT_COLS - - 2; + // Each agent turn: `┌AGENT┐` badge + hairline + dim model subheader, then every + // body line on the thin `│ ` rail (wrapping inside it). No bottom cap — spacing + // is a trailing blank. The cap is emitted once, on the turn's first streamed + // output. Content budget leaves the rail (2) + 2 spare columns so the terminal + // never hard-wraps a row and drops the rail. + // Prefer forge> width for the pane console (editor is built before enter(), + // so panesLive() is still false at construction time). + const promptGutterCols = (): number => FORGE_EDITOR_GUTTER; + + /** Draft wrap width — pane input matches the agent card, not full tty. */ + const editorColumns = (ttyCols: number): number => { + if (panesLive()) { + return Math.max(1, inputContentCols(paneScreen.mainInnerCols())); + } + + return Math.max(1, ttyCols - promptGutterCols()); + }; + + /** Content budget inside `│ … │` (left gutter 3 + right rail 1). */ + const railInnerWidth = (): number => agentRailInnerCols(transcriptCols()); let agentTurnOpen = false; - let agentRail = makeAgentRail(agentBar(true), railInnerWidth); + let agentRail = makeAgentRail( + agentBar(true), + railInnerWidth, + agentRight(true) + ); // Route streamed agent output through the bar so it scrolls above the pinned // input row; cleared on loop exit so later/headless writes go straight to stdout. @@ -1599,11 +1953,19 @@ export async function repl(args: ICliArgs): Promise { outputRouter.setParentSink((text): void => { if (!agentTurnOpen) { agentTurnOpen = true; - agentRail = makeAgentRail(agentBar(true), railInnerWidth); // fresh per turn - statusBar.writeStream(`\n${agentCardTop(statusInfo().model, true)}\n`); + agentRail = makeAgentRail( + agentBar(true), + railInnerWidth, + agentRight(true) + ); + const cols = transcriptCols(); + + streamOut( + `\n${agentCardTop(true, cols)}\n${agentCardPadRow(true, cols)}\n` + ); } - statusBar.writeStream(agentRail.feed(text)); + streamOut(agentRail.feed(text)); }); } @@ -1612,11 +1974,21 @@ export async function repl(args: ICliArgs): Promise { agentTurnOpen = false; }; - // Close the current agent card (rounded bottom cap) once its turn is done. A + // Close the current agent card (trailing blank) once its turn is done. A // no-op for turns that produced no streamed output (e.g. slash commands). const closeAgentTurn = (): void => { if (agentTurnOpen && useInputRow) { - statusBar.writeStream(`${agentCardBottom(true)}\n`); + const held = agentRail.flush(); + + if (held.length > 0) { + streamOut(held); + } + + const cols = transcriptCols(); + + streamOut( + `${agentCardPadRow(true, cols)}\n${agentCardBottom(true, cols)}\n` + ); agentTurnOpen = false; } }; @@ -1626,50 +1998,42 @@ export async function repl(args: ICliArgs): Promise { const syncInput = (): void => { if (useInputRow && rl !== null) { setImmediate(() => { - statusBar.setInput(rl.line, rl.cursor); + chrome.setInput(rl.line, rl.cursor); }); } }; - // Echo a CLI-side line (queued-steer notice, etc.) into the scroll region so it - // doesn't clobber the pinned input row; plain write when the row isn't active. + // Echo a CLI-side line into pane scrollback when live; plain write otherwise. const echo = (text: string): void => { - if (useInputRow) { - statusBar.writeStream(text); + if (panesLive() || useInputRow) { + streamOut(text); } else { process.stdout.write(text); } }; - // In the interactive REPL a readline prompt owns stdin for the WHOLE session, so - // the spinner's carriage-return inline write would clobber whatever the user is - // typing mid-turn — regardless of whether the pinned bar is active. So suppress - // the inline write unconditionally here: when the bar is up (≥5 rows) it shows the - // activity itself via statusInfo; on a sub-5-row TTY there's simply no inline - // spinner (correct — better silent than corrupting the input line). The default - // `() => true` gate still applies to any non-interactive spinner use. + // In the interactive REPL a readline/editor owns stdin for the WHOLE session, so + // the spinner's carriage-return inline write would clobber input mid-turn. + // Suppress it; activity shows via pane statusInfo instead. spinner.setInlineGate(() => false); - // A drag-resize fires SIGWINCH continuously while the terminal reflows. Painting - // the bar into that moving target strands copies of it (the multi-bar / stray-rule - // mess a circular corner-drag produced). So we DEBOUNCE: while resizes are still - // arriving we suppress ALL bar repaints (spinner ticks included) and repaint once, - // cleanly, only after the size settles (~120ms of quiet). + // Debounce SIGWINCH: suppress repaints mid-drag, then resize panes once settled. const RESIZE_SETTLE_MS = 120; let resizing = false; let resizeTimer: ReturnType | null = null; - // Repaint the bar on every spinner tick so tok/s and the context meter update - // live mid-turn (both read live session state) — but NOT during a resize storm. + // Pane footer + agent tree on every spinner tick — not during a resize storm. spinner.onTick(() => { - if (statusBar.active && !resizing) { - statusBar.update(statusInfo()); + if (resizing) { + return; + } - // Advance the tree's spinner so running agent rows animate in step. - if (treeActive) { - treeFrame += 1; - repaintTree(); - } + syncPaneChrome(); + + // Advance the tree's spinner so running agent rows animate in step. + if (treeActive) { + treeFrame += 1; + repaintTree(); } }); @@ -1679,7 +2043,6 @@ export async function repl(args: ICliArgs): Promise { // needed; the editor's resize ignores non-positive values regardless. const handleResize = (): void => { resizing = true; - statusBar.pauseForResize(); // buffer streamed output; draw nothing mid-storm if (resizeTimer !== null) { clearTimeout(resizeTimer); @@ -1688,56 +2051,61 @@ export async function repl(args: ICliArgs): Promise { resizeTimer = setTimeout(() => { resizing = false; resizeTimer = null; - statusBar.resize(statusInfo()); + // Geometry first (no paint), then one chrome sync — avoids resize+status + // double frames on every SIGWINCH settle. + paneScreen.resize(process.stdout.rows, process.stdout.columns, { + paint: false, + }); + syncPaneChrome(); // The editor wraps/windows at the dimensions it was created with; without // this it keeps using the pre-resize size and can clip the current line. resizeEditor?.(process.stdout.columns, process.stdout.rows); - statusBar.flushStream(); // replay buffered output into the settled region }, RESIZE_SETTLE_MS); }; process.stdout.on("resize", handleResize); - // Restore the terminal even on an unexpected exit (teardown is idempotent). + // Editor handle lives inside the prompt loop below; this ref lets the process + // exit hook tear down Kitty/modifyOtherKeys even on an unexpected exit. + // Without that cleanup the shell sees Ctrl+C as literal `;5;99~` junk. + let editorForExit: { close: () => void } | null = null; + + // Restore the terminal even on an unexpected exit (leave/close are idempotent). process.on("exit", () => { - statusBar.teardown(); + editorForExit?.close(); + paneScreen.leave(); }); - // Wipe the visible terminal + scrollback (2J + 3J + home), re-pinning the status - // bar around it so its scroll region stays correct. Used by /clear so the screen - // is a clean slate, not just the conversation state. + // Wipe the visible terminal. Pane console: clear scrollback + repaint. Pipes: + // plain CSI wipe. const clearScreen = (): void => { - const wasActive = statusBar.active; + if (panesLive()) { + paneScreen.clear(); + syncPaneChrome(); - if (wasActive) { - statusBar.teardown(); + return; } process.stdout.write("\x1b[2J\x1b[3J\x1b[H"); - - if (wasActive) { - statusBar.install(statusInfo()); - } }; - // The prompt. With the editable input row pinned it's always visible, so we - // just repaint the bar + row; with the bar (no input row) it shows the inline - // marker; otherwise it prints the inline status line above the marker. + // The prompt. Pane console owns the bottom strip — refresh metrics only. + // Pipes / non-TTY: inline status + `›`. const prompt = (): void => { - if (useInputRow) { + if (panesLive()) { + syncPaneChrome(); + if (rl !== null) { - statusBar.setInput(rl.line, rl.cursor); + paneScreen.setInput({ + lines: [rl.line], + cursorRow: 0, + cursorCol: rl.cursor, + }); + } else { + // Editor mode: status may be unchanged (no dirty paint) — still park the caret. + paneScreen.rehomeCursor(); } - statusBar.update(statusInfo()); - - return; - } - - if (statusBar.active) { - statusBar.update(statusInfo()); - process.stdout.write("\n› "); - return; } @@ -1776,7 +2144,7 @@ export async function repl(args: ICliArgs): Promise { // never echoed to scrollback — record it ourselves so the transcript reads // naturally above the (now-cleared) input row. if (useInputRow) { - echo(`\n${userBubble(line, true, process.stdout.columns)}\n`); + echo(`\n${userBubble(line, true, transcriptCols())}\n`); } if (busy) { @@ -1804,7 +2172,8 @@ export async function repl(args: ICliArgs): Promise { // Handle one idle line (slash command or a message), then any queued follow-up. const runLine = async (line: string): Promise => { busy = true; - beginAgentTurn(); // the agent's response opens a fresh "▌ " block + paneScreen.setBusy(true); + beginAgentTurn(); // the agent's response opens a fresh closed AGENT card try { if (line.startsWith("/")) { @@ -1828,6 +2197,7 @@ export async function repl(args: ICliArgs): Promise { } finally { closeAgentTurn(); // seal the agent card's bottom cap before re-prompting busy = false; + paneScreen.setBusy(false); } // A line typed in the gap after the last steer-drain becomes the next turn. @@ -1882,15 +2252,19 @@ export async function repl(args: ICliArgs): Promise { suspend, resume, out: (s) => process.stdout.write(s), + columns: transcriptCols(), + viewportRows: overlayBudget(), }) : openRecipePicker({ cwd: args.dir, render: (lines) => { - statusBar.setOverlay(lines, statusInfo()); + chrome.setOverlay(lines); }, close: () => { - statusBar.clearOverlay(statusInfo()); + chrome.clearOverlay(); }, + columns: transcriptCols(), + viewportRows: overlayBudget(), out: (s) => process.stdout.write(s), runRecipe: (recipe) => { if (recipe.gate !== undefined) { @@ -1908,11 +2282,13 @@ export async function repl(args: ICliArgs): Promise { }, }), render: (lines) => { - statusBar.setOverlay(lines, statusInfo()); + chrome.setOverlay(lines); }, close: () => { - statusBar.clearOverlay(statusInfo()); + chrome.clearOverlay(); }, + columns: transcriptCols(), + viewportRows: overlayBudget(), }; }; @@ -1936,9 +2312,7 @@ export async function repl(args: ICliArgs): Promise { editorControl?.getBuffer().setText(""); } - if (statusBar.active) { - statusBar.update(statusInfo()); - } + refreshStatus(); }; // Open the in-REPL scaffold wizard (create a new project here), reachable as a @@ -1960,11 +2334,23 @@ export async function repl(args: ICliArgs): Promise { editorControl?.resume(); editorControl?.getBuffer().setText(""); }, - out: (s) => process.stdout.write(s), + out: (s) => { + streamOut(s); + }, + view: { + render: (lines) => { + chrome.setOverlay(lines); + }, + close: () => { + chrome.clearOverlay(); + }, + }, + columns: transcriptCols(), + viewportRows: overlayBudget(), }); }; - // Helper: repaint the editor buffer to the status bar after palette insertion. + // Helper: repaint the editor buffer to the pane input after palette insertion. const repaintEditor = (handle: IEditorHandle): void => { const { line, col } = handle.getBuffer().getCursor(); const lines = handle.getBuffer().getText().split("\n"); @@ -1988,7 +2374,7 @@ export async function repl(args: ICliArgs): Promise { // writeStream — writeStream treats its argument as conversation content, so // it would strand the editor frame in scrollback (a leftover "/" per palette // open). This mirrors the editor's renderEditor→setEditor callback. - statusBar.setEditor( + chrome.setEditor( frame.frame.split("\n"), frame.cursorRow, frame.cursorCol @@ -2009,11 +2395,13 @@ export async function repl(args: ICliArgs): Promise { // query rides in the overlay title. const view: IPaletteView = { render: (lines) => { - statusBar.setOverlay(lines, statusInfo()); + chrome.setOverlay(lines); }, close: () => { - statusBar.clearOverlay(statusInfo()); + chrome.clearOverlay(); }, + columns: transcriptCols(), + viewportRows: overlayBudget(), }; try { @@ -2061,12 +2449,10 @@ export async function repl(args: ICliArgs): Promise { repaintEditor(editorHandle); } - if (useInputRow) { - statusBar.update(statusInfo()); + refreshStatus(); - if (rl !== null) { - syncInput(); - } + if (useInputRow && rl !== null) { + syncInput(); } } }; @@ -2096,15 +2482,15 @@ export async function repl(args: ICliArgs): Promise { const rows = formatCompletionRows( items, selected, - process.stdout.columns, + transcriptCols(), process.stdout.isTTY ); - statusBar.setInput(`${base}${query}`, base.length + query.length); - statusBar.setOverlay(rows, statusInfo()); + chrome.setInput(`${base}${query}`, base.length + query.length); + chrome.setOverlay(rows); }, close: (): void => { - statusBar.clearOverlay(statusInfo()); + chrome.clearOverlay(); }, }; @@ -2130,12 +2516,10 @@ export async function repl(args: ICliArgs): Promise { repaintEditor(editorHandle); } - if (useInputRow) { - statusBar.update(statusInfo()); + refreshStatus(); - if (rl !== null) { - syncInput(); - } + if (useInputRow && rl !== null) { + syncInput(); } } }; @@ -2211,26 +2595,91 @@ export async function repl(args: ICliArgs): Promise { items: (query: string): readonly string[] => filterFiles(completionFiles, query), render: (items: readonly string[], selected: number): void => { - statusBar.setEditorOverlay( + chrome.setEditorOverlay( formatCompletionRows( items, selected, - process.stdout.columns, + transcriptCols(), process.stdout.isTTY ) ); }, clear: (): void => { - statusBar.clearEditorOverlay(); + chrome.clearEditorOverlay(); }, }; + // When panes are up, strip leftover SGR mouse reports before the editor + // sees them (clicks otherwise insert `[<0;98;13M` into the buffer). + const stdinDataWrappers = new Map< + (data: string) => void, + (data: string) => void + >(); + // Hoisted — allocating a unicode RegExp per keystroke showed up in typing lag. + const mouseReportRe = new RegExp( + `${String.fromCharCode(27)}\\[<\\d+;\\d+;\\d+[Mm]`, + "gu" + ); + editorHandle = startEditor({ stdin: { on: (event: string, cb: (data: string) => void) => { - process.stdin.on(event, cb); + if (event !== "data") { + process.stdin.on(event, cb); + + return; + } + + const wrapped = (data: string): void => { + if (!panesLive()) { + cb(data); + + return; + } + + // Mouse reports must hit PaneScreen BEFORE strip — wheel scrolls + // main/panel viewports; never let the host terminal scroll. + mouseReportRe.lastIndex = 0; + const reports = data.match(mouseReportRe) ?? []; + + for (const report of reports) { + paneScreen.handleKey(report); + } + + const cleaned = stripMouseReports(data); + + if (cleaned.length === 0) { + return; + } + + // Pane chrome keys (Ctrl+G / Esc / panel nav) never reach the editor. + const paneKeys = + cleaned === "\x07" || + cleaned === "\x1b" || + paneScreen.focusState.panelFocused; + + if (paneKeys && paneScreen.handleKey(cleaned) === "handled") { + return; + } + + cb(cleaned); + }; + + stdinDataWrappers.set(cb, wrapped); + process.stdin.on(event, wrapped); }, removeListener: (event: string, cb: (data: string) => void) => { + if (event === "data") { + const wrapped = stdinDataWrappers.get(cb); + + if (wrapped !== undefined) { + process.stdin.removeListener(event, wrapped); + stdinDataWrappers.delete(cb); + + return; + } + } + process.stdin.removeListener(event, cb); }, setRawMode: (mode: boolean) => { @@ -2245,33 +2694,34 @@ export async function repl(args: ICliArgs): Promise { process.stdin.setEncoding("utf8"); }, }, - out: (s: string) => { - statusBar.writeStream(s); - }, + // Mode switches only — must NOT go through streamOut (that appends to + // the pane transcript and re-emits CSI on every paint). + out: writeTerm, // Multi-row editor rendering callback: paints to the pinned input area renderEditor: ( lines: string[], cursorRow: number, cursorCol: number ) => { - statusBar.setEditor(lines, cursorRow, cursorCol); + chrome.setEditor(lines, cursorRow, cursorCol); }, - // Reserve the `› ` prompt gutter the StatusBar paints in front of the - // editor block, so wrapping matches the visible width and the prompt row - // never exceeds `columns`. - columns: Math.max(1, process.stdout.columns - PROMPT_COLS), - rows: process.stdout.rows, + // Pane box = agent card width. + columns: editorColumns(process.stdout.columns), + // Editor reserves 3 rows internally; remainder = max draft visual lines + // the growing input box will show (then Enter clears → 1 line again). + rows: INPUT_INNER_ROWS_MAX + 3, openPalette, openFilePicker, completion: editorCompletion, pasteFromClipboard, }); - resizeEditor = (columns, rows): void => { - editorHandle?.resize(Math.max(1, columns - PROMPT_COLS), rows); + resizeEditor = (columns, _rows): void => { + editorHandle?.resize(editorColumns(columns), INPUT_INNER_ROWS_MAX + 3); }; editorControl = editorHandle; + editorForExit = editorHandle; editorHandle.onSubmit(submitLine); editorHandle.onInterrupt(() => { @@ -2296,6 +2746,15 @@ export async function repl(args: ICliArgs): Promise { // readline path at the keypress handler above). Consumed only while a tree // is active; otherwise the editor keeps the arrows for history/cursor. editorHandle.onNavigateTree((delta) => { + // Prefer pane scrollback when the pane console is up and the buffer + // is empty; otherwise the live agent tree keeps the arrows. + if (panesLive() && !treeActive) { + // Empty prompt: ↑/↓ scroll the main transcript only (never the window). + paneScreen.scrollMain(delta < 0 ? 1 : -1); + + return true; + } + if (!treeActive) { return false; } @@ -2311,13 +2770,66 @@ export async function repl(args: ICliArgs): Promise { rl?.on("close", () => { closed = true; editorHandle?.close(); - statusBar.teardown(); + paneScreen.leave(); observeEvents(null); // stop feeding the agent tree once the REPL is gone maybeFinish(); }); - // Pin the bar before the first turn so it's visible while that turn streams. - statusBar.install(statusInfo()); + // Enter alt-screen before any primary-buffer paint so scrollback is untouched. + const seedPaneLanding = (panes: PaneScreen): void => { + panes.setHeader({ cwd: args.dir, sessionId: id }); + syncPaneChrome(); + panes.setInput({ lines: [""], cursorRow: 0, cursorCol: 0 }); + + // Tasks rail first — it shrinks mainInnerCols. Replay MUST use that width + // (full stdout.columns paints cards that rewrap and shatter box rails). + const planId = session.getActivePlanId(); + const plan = planId !== null ? loadPlan(args.dir, planId) : null; + + syncWorklistPanel(plan); + + if (updateNotice !== null) { + panes.appendMain(`${updateNotice}\n`); + } + + if (resumed !== null) { + const speaker = modelInfo(provider.config).model; + const columns = panes.mainInnerCols(); + + for (const message of resumed.messages) { + if (isEphemeralUserInject(message)) { + continue; + } + + panes.appendMain( + renderMessage(message, { color: true, speaker, columns }) + ); + } + } + }; + + if (interactiveTty) { + if (!paneScreen.enter()) { + const reason = paneConsoleRejectReason({ + stdinTty: true, + stdoutTty: true, + rows: process.stdout.rows > 0 ? process.stdout.rows : 0, + }); + + process.stderr.write( + `${reason ?? `tsforge: could not enter pane console (need ≥ ${String(PANE_MIN_ROWS)} rows)`}\n` + ); + exitCode = 1; + closed = true; + editorHandle?.close(); + maybeFinish(); + + return; + } + + seedPaneLanding(paneScreen); + resizeEditor?.(process.stdout.columns, process.stdout.rows); + } if (args.task.length > 0) { void runLine(args.task); // sent as the first message; prompts when done @@ -2326,9 +2838,9 @@ export async function repl(args: ICliArgs): Promise { } }); - statusBar.teardown(); // belt-and-suspenders: restore the terminal on loop exit + paneScreen.leave(); process.stdout.off("resize", handleResize); // don't pin the REPL closure outputRouter.setParentSink(null); // later/headless writes go straight to stdout again - return 0; + return exitCode; } diff --git a/packages/core/src/loop/greenfield/greenfield.types.ts b/packages/core/src/loop/greenfield/greenfield.types.ts index c09ade95..3020f336 100644 --- a/packages/core/src/loop/greenfield/greenfield.types.ts +++ b/packages/core/src/loop/greenfield/greenfield.types.ts @@ -82,4 +82,6 @@ export interface IGreenfieldDeps { export interface IGreenfieldOptions { onEvent?: Reporter; + /** Subdirectory under `.tsforge/` for persistence. Defaults to `"greenfield"`. */ + stateName?: string; } diff --git a/packages/core/src/loop/greenfield/run.ts b/packages/core/src/loop/greenfield/run.ts index 6bff3de4..8b9e0487 100644 --- a/packages/core/src/loop/greenfield/run.ts +++ b/packages/core/src/loop/greenfield/run.ts @@ -19,9 +19,10 @@ import type { export async function prepareState( cwd: string, goal: string, - plan: (goal: string) => Promise + plan: (goal: string) => Promise, + stateName = "greenfield" ): Promise { - const existing = await loadState(cwd); + const existing = await loadState(cwd, stateName); if (existing !== null && existing.features.length > 0) { return existing; @@ -33,11 +34,11 @@ export async function prepareState( return null; } - await writeSpec(cwd, planned.spec); + await writeSpec(cwd, planned.spec, stateName); const state: IGreenfieldState = { goal, features: planned.features }; - await saveState(cwd, state); + await saveState(cwd, state, stateName); return state; } @@ -59,13 +60,14 @@ export async function runGreenfield( opts: IGreenfieldOptions = {} ): Promise { const report: Reporter = opts.onEvent ?? ((): void => undefined); + const stateName = opts.stateName ?? "greenfield"; const say = (message: string): void => { report({ kind: "fix", task: "greenfield", message }); }; - await saveState(cwd, state); - await writeProgress(cwd, state); + await saveState(cwd, state, stateName); + await writeProgress(cwd, state, stateName); // Main pass: drive all unpassed, unparked features. for (;;) { @@ -77,7 +79,14 @@ export async function runGreenfield( break; } - const infraError = await attemptFeature(cwd, state, feature, deps, say); + const infraError = await attemptFeature( + cwd, + state, + feature, + deps, + say, + stateName + ); if (infraError !== undefined) { return { @@ -111,6 +120,7 @@ export async function runGreenfield( feature, deps, say, + stateName, seed ); @@ -180,6 +190,7 @@ async function attemptFeature( feature: IFeature, deps: IGreenfieldDeps, say: (message: string) => void, + stateName: string, seed?: { triedLevers: EscalationRung[] } ): Promise { feature.attempts += 1; @@ -251,7 +262,7 @@ async function attemptFeature( return undefined; } finally { - await saveState(cwd, state); - await writeProgress(cwd, state); + await saveState(cwd, state, stateName); + await writeProgress(cwd, state, stateName); } } diff --git a/packages/core/src/loop/greenfield/state.ts b/packages/core/src/loop/greenfield/state.ts index 7fd9a7b4..bc0d5aeb 100644 --- a/packages/core/src/loop/greenfield/state.ts +++ b/packages/core/src/loop/greenfield/state.ts @@ -4,9 +4,12 @@ import { isRecord } from "../../lib/guards"; import type { IStep } from "../../browser"; import type { IFeature, IGreenfieldState } from "./greenfield.types"; -/** The greenfield state directory under the project's `.tsforge/`. */ -export function greenfieldDir(cwd: string): string { - return join(cwd, ".tsforge", "greenfield"); +/** + * State directory under the project's `.tsforge/`. + * Defaults to `"greenfield"`; worklist runs pass `"worklist"`. + */ +export function greenfieldDir(cwd: string, stateName = "greenfield"): string { + return join(cwd, ".tsforge", stateName); } /** @@ -100,16 +103,16 @@ function parseHandoff(handoff: unknown): IFeature["handoff"] | null { }; } -function featuresPath(cwd: string): string { - return join(greenfieldDir(cwd), "features.json"); +function featuresPath(cwd: string, stateName = "greenfield"): string { + return join(greenfieldDir(cwd, stateName), "features.json"); } -function specPath(cwd: string): string { - return join(greenfieldDir(cwd), "spec.md"); +function specPath(cwd: string, stateName = "greenfield"): string { + return join(greenfieldDir(cwd, stateName), "spec.md"); } -function progressPath(cwd: string): string { - return join(greenfieldDir(cwd), "progress.md"); +function progressPath(cwd: string, stateName = "greenfield"): string { + return join(greenfieldDir(cwd, stateName), "progress.md"); } /** Coerce one parsed JSON value into an IFeature, dropping it (→ null) when it @@ -157,11 +160,14 @@ function toFeature(value: unknown): IFeature | null { /** Read the persisted greenfield state, or null when none exists yet / it's * unreadable (a corrupt file degrades to "start fresh", never a crash). */ -export async function loadState(cwd: string): Promise { +export async function loadState( + cwd: string, + stateName = "greenfield" +): Promise { let parsed: unknown; try { - parsed = JSON.parse(await readFile(featuresPath(cwd), "utf8")); + parsed = JSON.parse(await readFile(featuresPath(cwd, stateName), "utf8")); } catch { return null; } @@ -183,23 +189,37 @@ export async function loadState(cwd: string): Promise { * start: a corrupt features.json on a tree that was already built into would look * "fresh" and let a caller re-capture a CONTAMINATED baseline (a false-green). Err * toward resume — presence ⇒ resume, only true absence ⇒ fresh. */ -export async function hasState(cwd: string): Promise { - return Bun.file(featuresPath(cwd)).exists(); +export async function hasState( + cwd: string, + stateName = "greenfield" +): Promise { + return Bun.file(featuresPath(cwd, stateName)).exists(); } /** Persist the feature checklist as pretty JSON (diff-friendly, model-resistant). */ export async function saveState( cwd: string, - state: IGreenfieldState + state: IGreenfieldState, + stateName = "greenfield" ): Promise { - await mkdir(greenfieldDir(cwd), { recursive: true }); - await writeFile(featuresPath(cwd), `${JSON.stringify(state, null, 2)}\n`); + await mkdir(greenfieldDir(cwd, stateName), { recursive: true }); + await writeFile( + featuresPath(cwd, stateName), + `${JSON.stringify(state, null, 2)}\n` + ); } /** Write the human-readable spec (the planner's high-level sprints). */ -export async function writeSpec(cwd: string, spec: string): Promise { - await mkdir(greenfieldDir(cwd), { recursive: true }); - await writeFile(specPath(cwd), spec.endsWith("\n") ? spec : `${spec}\n`); +export async function writeSpec( + cwd: string, + spec: string, + stateName = "greenfield" +): Promise { + await mkdir(greenfieldDir(cwd, stateName), { recursive: true }); + await writeFile( + specPath(cwd, stateName), + spec.endsWith("\n") ? spec : `${spec}\n` + ); } /** Render the checklist as a human-readable progress report. Pure (testable). */ @@ -235,8 +255,9 @@ export function renderProgress(state: IGreenfieldState): string { /** Write progress.md from the current state. */ export async function writeProgress( cwd: string, - state: IGreenfieldState + state: IGreenfieldState, + stateName = "greenfield" ): Promise { - await mkdir(greenfieldDir(cwd), { recursive: true }); - await writeFile(progressPath(cwd), renderProgress(state)); + await mkdir(greenfieldDir(cwd, stateName), { recursive: true }); + await writeFile(progressPath(cwd, stateName), renderProgress(state)); } diff --git a/packages/core/src/loop/index.ts b/packages/core/src/loop/index.ts index 0596e8fa..36227003 100644 --- a/packages/core/src/loop/index.ts +++ b/packages/core/src/loop/index.ts @@ -20,12 +20,41 @@ export { judgeFeature, parseFeatureVerdict, loadState, + hasState, saveState, writeSpec, writeProgress, renderProgress, greenfieldDir, } from "./greenfield"; +export { + parseWorklist, + resolveWorklistPath, + itemsToFeatures, + acceptMapOf, + WORKLIST_STATE, + prepareWorklistState, + runWorklist, + tickWorklistFile, + formatWorklistLines, + worklistBadge, + extractPlanJson, + seedWorklistFromPlan, + goalFromMessages, + loadPlan, + savePlan, + loadPlanIndex, + isChecklistComplete, + countOpen, + formatPlanTree, +} from "./worklist"; +export type { + IWorklistItem, + IPrepareWorklistOptions, + SeedWorklistResult, + IPlanDocument, + IChecklistItem, +} from "./worklist"; export type { IFeature, IGreenfieldState, @@ -49,6 +78,8 @@ export { export { Session, PLAN_APPROVED_NOTE, + checklistOpenNudge, + isEphemeralUserInject, filterGateStream, type ISessionConfig, type ISendResult, diff --git a/packages/core/src/loop/model-call.ts b/packages/core/src/loop/model-call.ts index 7120f53b..ae66b177 100644 --- a/packages/core/src/loop/model-call.ts +++ b/packages/core/src/loop/model-call.ts @@ -8,6 +8,17 @@ import type { ITokenUsage } from "../inference"; import { clampRatio } from "../lib/ratio"; import type { ILoopEvent } from "./loop.types"; +/** Checklist tools — withheld until a session binds activePlanId. */ +const TASK_TOOL_NAMES: ReadonlySet = new Set([ + TOOL_NAME.taskList, + TOOL_NAME.taskFocus, + TOOL_NAME.taskComplete, + TOOL_NAME.taskUncomplete, +]); + +/** Propose-plan tool — only useful in plan mode (approve binds the proposal). */ +const PRESENT_PLAN_NAME = TOOL_NAME.presentPlan; + /** The minimal shape shared by advertised tools and MCP tool schemas. */ interface INamedTool { readonly function: { readonly name: string; readonly description?: string }; @@ -58,15 +69,28 @@ export function offeredToolsFor( tools: readonly T[], planMode: boolean, mcpSchemas: readonly U[], - wiring: readonly IToolWiring[] = [] + wiring: readonly IToolWiring[] = [], + offerTaskTools = false ): (T | U)[] { + const scoped = tools.filter((t) => { + if (!offerTaskTools && TASK_TOOL_NAMES.has(t.function.name)) { + return false; + } + + // present_plan only while planning — after approve it's noise. + if (!planMode && t.function.name === PRESENT_PLAN_NAME) { + return false; + } + + return true; + }); const base = planMode - ? tools.filter( + ? scoped.filter( (t) => READ_ONLY_TOOL_NAMES.has(t.function.name) || t.function.name === TOOL_NAME.run ) - : [...tools]; + : [...scoped]; const offered = mcpSchemas.length > 0 ? [...base, ...mcpSchemas] : base; diff --git a/packages/core/src/loop/session.ts b/packages/core/src/loop/session.ts index 5db89ab9..87aa75fe 100644 --- a/packages/core/src/loop/session.ts +++ b/packages/core/src/loop/session.ts @@ -92,6 +92,14 @@ import { tryExpertRescue, } from "./turn"; import { parkOrRaiseHand } from "./raise-hand"; +import type { IPlanDocument } from "./worklist/checklist.types"; +import { + countOpen, + findItem, + formatPlanTree, + isChecklistComplete, + loadPlan, +} from "./worklist/checklist-store"; /** Signature of the memory-consolidation step, injectable for tests so they can * capture the per-build source id each send passes. */ @@ -211,6 +219,12 @@ export interface ISessionConfig { * yields (or churns) then validates the on-disk edit. Without this the rebuilt session * starts `edited=false` and a conversational send silently skips the gate (WS-C). */ pausedWithEdit?: boolean; + /** Session-bound plan id restored from the session store on `--continue`. */ + activePlanId?: string | null; + /** Fired when a task_* tool persists a plan change (REPL refreshes the Tasks rail). */ + onPlanChanged?: (plan: IPlanDocument) => void; + /** Fired when present_plan proposes a plan (REPL renders pending proposal). */ + onPlanPresented?: (plan: IPlanDocument) => void; /** Composed gate the session's loop checks each cycle. Defaults to a command * gate from `accept`. Use `setGate` to swap it per unit mid-build. */ gate?: IGate; @@ -373,19 +387,70 @@ const PLAN_MODE_NOTE = "reference examples, and explicit non-goals) yields a far better build than a vague " + "one. Then ask for the specific missing pieces that matter most. If the user would " + "rather not answer, proceed on clearly-stated assumptions — never block.\n" + - "4. PLAN — once you know enough, reply with a concise plan under a `## Plan` " + - "heading: each file to change and what to do in it, in order. For a small, " + - "unambiguous change the plan can be a single line. No code dumps, no tool calls " + - "in that reply.\n" + - "The user replies with feedback (revise the plan) or approves it; you implement " + - "ONLY after approval."; + "4. PLAN — once you know enough, call the `present_plan` tool with " + + "{ goal, items: [{ title, detail?, files?, verify?, children? }] }. " + + "Do NOT paste the JSON into chat — the harness renders it for the human. " + + "Items are concrete work units (e.g. create X, wire Y) — NEVER a checklist " + + "item for 'run tests / lint / the gate'; the harness gate validates each " + + "task_complete. Nested children allowed; `verify` is a hint only. " + + "The user replies with feedback (call present_plan again with revisions) or " + + "approves (approve/go/lgtm). On approve, the harness writes plans/.json " + + "and you implement ONLY after that."; /** Sent when the user approves a plan-mode plan — the plan itself is already the * latest assistant message, so anchor it instead of re-pasting it. */ export const PLAN_APPROVED_NOTE = - "Your plan is APPROVED — plan mode is off and all tools are available again. " + - "Implement the approved plan above now, in order, starting with the first " + - "step. Do not re-explore or restate the plan; emit the tool calls."; + "Your plan is APPROVED — saved as this session's checklist under " + + ".tsforge/worklist/plans/.json (bound via activePlanId). Plan mode is off; " + + "task_list / task_focus / task_complete / task_uncomplete are available. " + + "task_complete RUNS THE GATE and only marks done when green — never invent " + + "done, never mark an item complete while the gate is red. Finishing requires " + + "BOTH gate green AND every checklist item done. Implement now: task_focus " + + "the first open item, then emit the tool calls. Do not re-explore or restate " + + "the plan."; + +const CHECKLIST_CONTRACT_MARKER = "## Active plan checklist"; + +/** Post-green nudge when the gate is clean but the bound plan still has open nodes. */ +export function checklistOpenNudge(opts: { + openCount: number; + calledTaskComplete: boolean; +}): string { + const base = + `Gate is GREEN but the approved checklist still has ${String(opts.openCount)} ` + + "open item(s). Finished work requires BOTH gate green AND every checklist " + + "item done (via task_complete). Status is tools-only — do not invent done."; + + if (!opts.calledTaskComplete) { + return ( + `${base} You did not call task_complete this turn — mark finished items, ` + + "then task_focus the next open item and continue." + ); + } + + return `${base} Continue with the next open item (task_focus / task_complete).`; +} + +/** + * Per-turn checklist injects / Phase B nudges are real model context, but they + * must not paint as USER cards on `--continue` — the Tasks rail already owns + * that UI. Kept in session history for the model; filtered from transcript replay. + */ +export function isEphemeralUserInject(message: { + readonly role: string; + readonly content: string; +}): boolean { + if (message.role !== "user") { + return false; + } + + const content = message.content; + + return ( + content.startsWith("[checklist — session plan ") || + content.startsWith("Gate is GREEN but the approved checklist") + ); +} /** Default edits between incremental checks. */ const CHECK_EVERY = 3; @@ -830,6 +895,10 @@ export class Session { private baseMode: PolicyMode = "default"; /** Attach PLAN_MODE_NOTE to the NEXT send only (not every revision reply). */ private planIntroPending = false; + /** Session-bound checklist plan id (`.tsforge/worklist/plans/.json`). */ + private activePlanId: string | null = null; + /** Last present_plan proposal — bound on approve; not on disk until then. */ + private pendingPlan: IPlanDocument | null = null; /** Mid-session turn-cap override (setMaxTurns) — a web scaffold raises it. */ private maxTurnsOverride?: number; /** TTSR manager (built-in + project + memory-learned rules). Null when TTSR is @@ -886,27 +955,47 @@ export class Session { ? cfg.conventions.topics() : []; + // Task tools are advertised in the session list for interactive co-pilot + // sessions; offeredToolsFor withholds them until activePlanId is set. this.tools = toolsFor( false, {}, offerConventions, offerCheck, cfg.interactive === true, - conventionTopics + conventionTopics, + cfg.interactive === true ); this.ctx = ctx; - // Wire the `check` tool's runCheck seam to `runCheckGate` — the SAME full - // evaluation `settleGate` runs (autofix → gate command → META_RULES combined), - // so `check` can never report green while the end-of-turn settle is red. Reads - // `this.ctx` LAZILY so a mid-build `setGate` swap is honored, and never - // `validate(accept)` (empty for an injected gate — the vacuous-recheck trap). - // Absent ⇒ the tool isn't offered and reports it isn't available. + // `check` tool seam — only when offerCheck (do not enable via hasGate alone). + // Reads `this.ctx` LAZILY so a mid-build `setGate` swap is honored. if (offerCheck) { this.ctx.tool.runCheck = () => runCheckGate(this.ctx); } + // Checklist complete seam — separate from `check` so a gate for task_complete + // does not advertise/enable the model's on-demand check tool. + if (this.hasGate) { + this.ctx.tool.runTaskGate = () => runCheckGate(this.ctx); + } + + if (typeof cfg.activePlanId === "string" && cfg.activePlanId.length > 0) { + this.activePlanId = cfg.activePlanId; + this.ctx.tool.activePlanId = cfg.activePlanId; + this.refreshChecklistContract(); + } + + if (cfg.onPlanChanged !== undefined) { + this.ctx.tool.onPlanChanged = cfg.onPlanChanged; + } + + this.ctx.tool.onPlanPresented = (plan) => { + this.pendingPlan = plan; + cfg.onPlanPresented?.(plan); + }; + // create() already resolved the base mode (CLI > config > default) onto ctx. this.baseMode = ctx.tool.policyMode ?? "default"; this.ctx.tool.policyMode = this.planMode ? "plan" : this.baseMode; @@ -1212,6 +1301,11 @@ export class Session { this.hasGate = true; } + // task_complete needs runTaskGate; wire it when a gate appears mid-session. + if (this.hasGate && this.ctx.tool.runTaskGate === undefined) { + this.ctx.tool.runTaskGate = () => runCheckGate(this.ctx); + } + this.refreshTaskContract(); } @@ -1266,6 +1360,186 @@ export class Session { this.planIntroPending = on; } + /** Bind this session to a plan file id (after approve or `--continue`). Offers + * task_* tools and refreshes the system checklist block. */ + setActivePlanId(planId: string | null): void { + this.activePlanId = planId; + this.ctx.tool.activePlanId = planId; + this.refreshChecklistContract(); + } + + /** Session-bound plan id, or null when none approved yet. */ + getActivePlanId(): string | null { + return this.activePlanId; + } + + /** Wire the Tasks-rail refresh callback (REPL). */ + setOnPlanChanged(fn: ((plan: IPlanDocument) => void) | undefined): void { + this.ctx.tool.onPlanChanged = fn; + } + + /** Wire the present_plan UI callback (REPL). Keeps pendingPlan in sync. */ + setOnPlanPresented(fn: ((plan: IPlanDocument) => void) | undefined): void { + this.ctx.tool.onPlanPresented = (plan) => { + this.pendingPlan = plan; + fn?.(plan); + }; + } + + /** Last present_plan proposal, if any (not yet approved). */ + getPendingPlan(): IPlanDocument | null { + return this.pendingPlan; + } + + /** Take and clear the pending proposal (approve path). */ + takePendingPlan(): IPlanDocument | null { + const plan = this.pendingPlan; + + this.pendingPlan = null; + + return plan; + } + + /** System block: goal, active item, open counts, tools-only status reminder. */ + private refreshChecklistContract(): void { + const system = this.ctx.messages[0]; + + if (system?.role !== "system") { + return; + } + + const block = this.checklistContractText(); + const idx = system.content.indexOf(CHECKLIST_CONTRACT_MARKER); + + if (block.length === 0) { + if (idx !== -1) { + system.content = system.content.slice(0, idx).trimEnd(); + } + + return; + } + + system.content = + idx === -1 + ? `${system.content}\n\n${block}` + : `${system.content.slice(0, idx).trimEnd()}\n\n${block}`; + } + + private checklistContractText(): string { + if (this.activePlanId === null) { + return ""; + } + + const plan = loadPlan(this.cfg.cwd, this.activePlanId); + + if (plan === null) { + return ""; + } + + const open = countOpen(plan.items); + const focused = + plan.activeItemId === null + ? null + : findItem(plan.items, plan.activeItemId); + const active = + focused === null ? "(none)" : `${focused.title} (${focused.id})`; + + return [ + CHECKLIST_CONTRACT_MARKER, + `goal: ${plan.goal}`, + `active: ${active}`, + `open: ${String(open)}`, + "Checklist status changes ONLY via task_list / task_focus / task_complete / task_uncomplete.", + "task_complete runs the gate — an item can be done only when the gate is green.", + "Finished requires gate green AND every checklist item done.", + ].join("\n"); + } + + /** Compact tree injected once per model turn for the bound plan. */ + private injectPlanTree(): void { + if (this.activePlanId === null) { + return; + } + + const plan = loadPlan(this.cfg.cwd, this.activePlanId); + + if (plan === null) { + return; + } + + this.refreshChecklistContract(); + this.ctx.messages.push({ + role: "user", + content: `[checklist — session plan ${plan.id}]\n${formatPlanTree(plan)}`, + }); + } + + /** True when a bound plan still has open nodes (blocks claiming finished). */ + private checklistBlocksFinish(): { + block: true; + openCount: number; + calledTaskComplete: boolean; + } | null { + if (this.activePlanId === null) { + return null; + } + + const plan = loadPlan(this.cfg.cwd, this.activePlanId); + + if (plan === null || isChecklistComplete(plan)) { + return null; + } + + return { + block: true, + openCount: countOpen(plan.items), + calledTaskComplete: this.calledTaskCompleteThisSend(), + }; + } + + /** Whether task_complete ran during this send (scan buffered tool events). */ + private calledTaskCompleteThisSend(): boolean { + return this.sendEvents.some( + (e) => + e.kind === "tool" && + typeof e.message === "string" && + e.message.startsWith("task_complete:") + ); + } + + /** + * After a green settle: if the bound checklist is still open, inject a nudge + * and keep driving (Phase B — finished = gate green AND plan done). + */ + private continueIfChecklistOpen( + settled: ISendResult + ): ISendResult | "continue" { + if (settled.status !== "done") { + return settled; + } + + const block = this.checklistBlocksFinish(); + + if (block === null) { + return settled; + } + + this.ctx.messages.push({ + role: "user", + content: checklistOpenNudge({ + openCount: block.openCount, + calledTaskComplete: block.calledTaskComplete, + }), + }); + this.report({ + kind: "tool", + task: SESSION_ID, + message: `⊙ gate green — checklist still open (${String(block.openCount)} item(s)); continuing`, + }); + + return "continue"; + } + /** Set (or clear, with "") the auto-fix command run before each gate — e.g. a * scaffold's `eslint --fix`, so mechanical lint violations are squashed * deterministically instead of costing the model turns. */ @@ -1652,7 +1926,8 @@ export class Session { this.tools, this.planMode, this.ctx.tool.mcpRegistry?.toolSchemas() ?? [], - activeOverlay()?.toolOverrides ?? [] + activeOverlay()?.toolOverrides ?? [], + this.activePlanId !== null && this.activePlanId.length > 0 ); const callStart = performance.now(); let firstTokenAt = 0; @@ -2221,10 +2496,17 @@ export class Session { } // Gate confirms. Green/stuck ⇒ terminal; null ⇒ red, feedback pushed. + // Phase B: green + open checklist ⇒ nudge and continue (not finished yet). const settled = await this.settleTurn(turn, turnStart, sendStart); if (settled !== null) { - return { action: settled, buildNudges, forceTool: false }; + const next = this.continueIfChecklistOpen(settled); + + if (next === "continue") { + return { action: "continue", buildNudges, forceTool: false }; + } + + return { action: next, buildNudges, forceTool: false }; } // Gate came back RED → enter repair mode (think to converge on the fix), nudge @@ -2255,9 +2537,14 @@ export class Session { if (forced === null) { this.repairing = true; + + return null; } - return forced; + const next = this.continueIfChecklistOpen(forced); + + // Checklist-open continue is NOT a red gate — keep repairing off. + return next === "continue" ? null : next; } /** Run the gate once the model has stopped after editing: a terminal result @@ -2622,6 +2909,8 @@ export class Session { // Inject any messages the user typed while the run was in flight, so they // steer the next model turn instead of waiting for the run to finish. this.injectSteer(opts.steer); + // Compact bound-plan tree so every turn sees current checklist status. + this.injectPlanTree(); report({ kind: "cycle", diff --git a/packages/core/src/loop/tools/execute-tool.ts b/packages/core/src/loop/tools/execute-tool.ts index e6eb2c3d..f239ab08 100644 --- a/packages/core/src/loop/tools/execute-tool.ts +++ b/packages/core/src/loop/tools/execute-tool.ts @@ -15,6 +15,13 @@ import { doSpawnAgent } from "./spawn-agent"; import { doReadImage, doGenerateImage } from "./image-tools"; import { doCheck } from "./check-tool"; import { doAskUser } from "./ask-user-tool"; +import { + doTaskComplete, + doTaskFocus, + doTaskList, + doTaskUncomplete, +} from "./task-tools"; +import { doPresentPlan } from "./present-plan-tool"; import { reject, type IToolContext } from "./tool-context"; import { classifyAction, @@ -62,6 +69,11 @@ const HANDLERS: Record = { [TOOL_NAME.generateImage]: doGenerateImage, [TOOL_NAME.check]: doCheck, [TOOL_NAME.askUser]: doAskUser, + [TOOL_NAME.taskList]: doTaskList, + [TOOL_NAME.taskFocus]: doTaskFocus, + [TOOL_NAME.taskComplete]: doTaskComplete, + [TOOL_NAME.taskUncomplete]: doTaskUncomplete, + [TOOL_NAME.presentPlan]: doPresentPlan, }; function isToolName(name: string): name is ToolName { diff --git a/packages/core/src/loop/tools/present-plan-tool.ts b/packages/core/src/loop/tools/present-plan-tool.ts new file mode 100644 index 00000000..85b3d86d --- /dev/null +++ b/packages/core/src/loop/tools/present-plan-tool.ts @@ -0,0 +1,77 @@ +import { countOpen } from "../worklist/checklist-store"; +import { planDocumentFromUnknown } from "../worklist/seed"; +import type { IPlanDocument } from "../worklist/checklist.types"; +import { reject, type IToolContext } from "./tool-context"; + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +/** + * Build the raw draft object from tool args. Accepts either top-level + * `{ goal, items }` or a nested `{ plan: { goal, items } }`. + */ +export function presentPlanArgsToRaw( + args: Record +): Record | null { + if (isRecord(args.plan)) { + return args.plan; + } + + if (Array.isArray(args.items)) { + return { + ...(typeof args.goal === "string" ? { goal: args.goal } : {}), + items: args.items, + }; + } + + return null; +} + +/** + * `present_plan` — propose the session checklist for human approve. + * Validates + normalizes; does NOT write disk until the user approves. + * The REPL renders the proposal in the TUI via `onPlanPresented`. + */ +export function doPresentPlan( + args: Record, + ctx: IToolContext +): string { + const raw = presentPlanArgsToRaw(args); + + if (raw === null) { + return reject( + ctx, + "present_plan", + "present_plan needs `items` (array) and optional `goal`, or a `plan` object with those fields" + ); + } + + const fallback = + typeof ctx.task === "string" && ctx.task.length > 0 ? ctx.task : "goal"; + const normalized = planDocumentFromUnknown(raw, fallback); + + if (!normalized.ok) { + return reject(ctx, "present_plan", normalized.error); + } + + const plan: IPlanDocument = normalized.plan; + + ctx.onPlanPresented?.(plan); + + const open = countOpen(plan.items); + const tops = plan.items.length; + + ctx.report({ + kind: "tool", + task: ctx.task, + message: `present_plan: ${tops} top-level · ${String(open)} open — awaiting approve`, + }); + + return ( + `Plan presented to the human (${String(tops)} top-level item(s), ` + + `${String(open)} open). Do NOT paste the JSON into chat again. ` + + "Wait for them to approve (approve/go/lgtm) or reply with refinements — " + + "then call present_plan again with the revised plan." + ); +} diff --git a/packages/core/src/loop/tools/task-tools.ts b/packages/core/src/loop/tools/task-tools.ts new file mode 100644 index 00000000..47b79efe --- /dev/null +++ b/packages/core/src/loop/tools/task-tools.ts @@ -0,0 +1,237 @@ +import type { IPlanDocument } from "../worklist/checklist.types"; +import { + completeItemInPlan, + findItem, + focusItemInPlan, + formatPlanTree, + loadPlan, + savePlan, + uncompleteItemInPlan, +} from "../worklist/checklist-store"; +import { reject, str, type IToolContext } from "./tool-context"; + +function requirePlan( + ctx: IToolContext +): { ok: true; planId: string; cwd: string } | { ok: false; error: string } { + const planId = ctx.activePlanId; + + if (typeof planId !== "string" || planId.length === 0) { + return { + ok: false, + error: + "no active plan bound to this session — approve a plan in plan mode first", + }; + } + + return { ok: true, planId, cwd: ctx.cwd }; +} + +function persistAndNotify( + ctx: IToolContext, + planId: string, + plan: IPlanDocument, + tool: "task_focus" | "task_complete" | "task_uncomplete" +): string { + savePlan(ctx.cwd, plan); + ctx.onPlanChanged?.(plan); + ctx.report({ + kind: "tool", + task: ctx.task, + message: `${tool}: plan ${planId} updated`, + }); + + return formatPlanTree(plan); +} + +/** List the session-bound plan tree (status is tools-only; this is the mirror). */ +export function doTaskList( + _args: Record, + ctx: IToolContext +): string { + const bound = requirePlan(ctx); + + if (!bound.ok) { + return reject(ctx, "task_list", bound.error); + } + + const plan = loadPlan(bound.cwd, bound.planId); + + if (plan === null) { + return reject( + ctx, + "task_list", + `active plan file missing: ${bound.planId}` + ); + } + + return formatPlanTree(plan); +} + +/** Focus one open item (sets activeItemId + status active). */ +export function doTaskFocus( + args: Record, + ctx: IToolContext +): string { + const bound = requirePlan(ctx); + + if (!bound.ok) { + return reject(ctx, "task_focus", bound.error); + } + + const id = str(args, "id").trim(); + + if (id.length === 0) { + return reject( + ctx, + "task_focus", + "task_focus needs `id` (item UUID from task_list)" + ); + } + + const plan = loadPlan(bound.cwd, bound.planId); + + if (plan === null) { + return reject( + ctx, + "task_focus", + `active plan file missing: ${bound.planId}` + ); + } + + const result = focusItemInPlan(plan, id); + + if (!result.ok) { + return reject(ctx, "task_focus", result.error); + } + + const tree = persistAndNotify(ctx, bound.planId, result.plan, "task_focus"); + const item = findItem(result.plan.items, id); + + return [ + `focused: ${item?.title ?? id}`, + item?.verify !== undefined ? `verify hint: ${item.verify}` : "", + item?.detail !== undefined ? `detail: ${item.detail}` : "", + "", + tree, + ] + .filter((line) => line.length > 0) + .join("\n"); +} + +/** + * Mark an item done ONLY when the acceptance gate is green. + * Runs the same full evaluation as `check` / end-of-turn settle — refuses (and + * leaves the item open) when the gate is red. The gate is the authority; the + * checklist must not claim done ahead of it. + */ +export async function doTaskComplete( + args: Record, + ctx: IToolContext +): Promise { + const bound = requirePlan(ctx); + + if (!bound.ok) { + return reject(ctx, "task_complete", bound.error); + } + + const id = str(args, "id").trim(); + + if (id.length === 0) { + return reject( + ctx, + "task_complete", + "task_complete needs `id` (item UUID from task_list)" + ); + } + + const plan = loadPlan(bound.cwd, bound.planId); + + if (plan === null) { + return reject( + ctx, + "task_complete", + `active plan file missing: ${bound.planId}` + ); + } + + if (ctx.runTaskGate === undefined) { + return reject( + ctx, + "task_complete", + "no gate wired — cannot mark an item done without validation" + ); + } + + ctx.report({ + kind: "tool", + task: ctx.task, + message: "task_complete: running gate before marking done", + }); + + const gate = await ctx.runTaskGate(); + + if (!gate.passed) { + const sample = gate.errors + .slice(0, 5) + .map((e) => e.message) + .join("; "); + const more = + gate.errors.length > 5 + ? ` (+${String(gate.errors.length - 5)} more)` + : ""; + + return reject( + ctx, + "task_complete", + `gate RED (${String(gate.errors.length)} error(s)) — item stays open. Fix, then task_complete again.${sample.length > 0 ? ` First: ${sample}${more}` : ""}` + ); + } + + const result = completeItemInPlan(plan, id); + + if (!result.ok) { + return reject(ctx, "task_complete", result.error); + } + + return persistAndNotify(ctx, bound.planId, result.plan, "task_complete"); +} + +/** Re-open a done item. */ +export function doTaskUncomplete( + args: Record, + ctx: IToolContext +): string { + const bound = requirePlan(ctx); + + if (!bound.ok) { + return reject(ctx, "task_uncomplete", bound.error); + } + + const id = str(args, "id").trim(); + + if (id.length === 0) { + return reject( + ctx, + "task_uncomplete", + "task_uncomplete needs `id` (item UUID from task_list)" + ); + } + + const plan = loadPlan(bound.cwd, bound.planId); + + if (plan === null) { + return reject( + ctx, + "task_uncomplete", + `active plan file missing: ${bound.planId}` + ); + } + + const result = uncompleteItemInPlan(plan, id); + + if (!result.ok) { + return reject(ctx, "task_uncomplete", result.error); + } + + return persistAndNotify(ctx, bound.planId, result.plan, "task_uncomplete"); +} diff --git a/packages/core/src/loop/tools/tool-context.ts b/packages/core/src/loop/tools/tool-context.ts index 0f32faa0..33cfb615 100644 --- a/packages/core/src/loop/tools/tool-context.ts +++ b/packages/core/src/loop/tools/tool-context.ts @@ -7,6 +7,7 @@ import type { McpRegistry } from "../../mcp"; import type { PolicyMode, IPolicyRules } from "../../policy"; import type { IValidateResult } from "../../validate/validate.types"; import type { IConventionProvider } from "../conventions-provider"; +import type { IPlanDocument } from "../worklist/checklist.types"; /** What one on-demand gate run produced for the `check` tool: the standard * validate result PLUS the files the gate's autofix reformatted/rewrote on disk @@ -161,6 +162,19 @@ export interface IToolContext { /** Run the fast acceptance gate on demand for the `check` tool (see {@link RunCheck}). * Wired by the build overlay; absent ⇒ `check` says it isn't available here. */ runCheck?: RunCheck; + /** + * Gate runner for `task_complete` — same shape as {@link RunCheck}, but a + * separate seam so wiring a gate for checklist completion does not silently + * enable the model's `check` tool (`offerCheck` alone owns `runCheck`). + */ + runTaskGate?: RunCheck; + /** Session-bound plan id under `.tsforge/worklist/plans/.json`. Absent/null + * ⇒ task_* tools refuse (no plan approved for this session yet). */ + activePlanId?: string | null; + /** Fired after a task_* tool persists a plan change — REPL refreshes the Tasks rail. */ + onPlanChanged?: (plan: IPlanDocument) => void; + /** Fired when present_plan validates a proposal (pending until human approve). */ + onPlanPresented?: (plan: IPlanDocument) => void; } /** A required string arg, or "" if missing/wrong-type. */ diff --git a/packages/core/src/loop/turn.ts b/packages/core/src/loop/turn.ts index 0ba0b7de..1d8571a1 100644 --- a/packages/core/src/loop/turn.ts +++ b/packages/core/src/loop/turn.ts @@ -90,6 +90,11 @@ import { GENERATE_IMAGE_TOOL, CHECK_TOOL, ASK_USER_TOOL, + TASK_LIST_TOOL, + TASK_FOCUS_TOOL, + TASK_COMPLETE_TOOL, + TASK_UNCOMPLETE_TOOL, + PRESENT_PLAN_TOOL, } from "../agent"; import { TsService } from "../lsp"; import type { McpRegistry } from "../mcp"; @@ -147,7 +152,12 @@ type AdvertisedTool = | typeof READ_IMAGE_TOOL | typeof GENERATE_IMAGE_TOOL | typeof CHECK_TOOL - | typeof ASK_USER_TOOL; + | typeof ASK_USER_TOOL + | typeof TASK_LIST_TOOL + | typeof TASK_FOCUS_TOOL + | typeof TASK_COMPLETE_TOOL + | typeof TASK_UNCOMPLETE_TOOL + | typeof PRESENT_PLAN_TOOL; /** Which extra capability backends are configured this run — decides whether the * image tools are advertised. Resolved once by the driver (run.ts) so @@ -202,7 +212,8 @@ export function toolsFor( offerConventions = false, offerCheck = false, offerAskUser = false, - conventionTopics: readonly string[] = [] + conventionTopics: readonly string[] = [], + offerTaskTools = false ): AdvertisedTool[] { const web = webTools(); const git = gitTools(hasExistingCode); @@ -219,7 +230,21 @@ export function toolsFor( // (an interactive co-pilot session); off for autonomous eval/CI so the model isn't // tempted to ask a question no one will answer. The handler ALSO guards on // ctx.humanPresent, so a stray call in an unattended run returns "proceed" not a hang. - const askUser: AdvertisedTool[] = offerAskUser ? [ASK_USER_TOOL] : []; + // present_plan rides the same opt-in; offeredToolsFor withholds it outside plan mode. + const askUser: AdvertisedTool[] = offerAskUser + ? [ASK_USER_TOOL, PRESENT_PLAN_TOOL] + : []; + + // Session-bound checklist tools — only when a plan was approved for this session + // (`activePlanId`). Off until then so plan-mode exploration isn't cluttered. + const taskTools: AdvertisedTool[] = offerTaskTools + ? [ + TASK_LIST_TOOL, + TASK_FOCUS_TOOL, + TASK_COMPLETE_TOOL, + TASK_UNCOMPLETE_TOOL, + ] + : []; // pull_conventions — a read-only knowledge tool the model calls to fetch the // BoringStack how-to BEFORE writing that kind of code (the PULL complement to the @@ -239,6 +264,7 @@ export function toolsFor( ...conventions, ...check, ...askUser, + ...taskTools, ...web, ...git, ...script, @@ -253,6 +279,7 @@ export function toolsFor( ...conventions, ...check, ...askUser, + ...taskTools, ...LSP_TOOLS, ...web, ...git, @@ -329,6 +356,15 @@ export interface ILoopCtxTool { * demand for the `check` tool. Threaded into the tool context; declared here so * the seam is typed, not accidental. Absent ⇒ `check` isn't offered. */ runCheck?: IToolContext["runCheck"]; + /** Gate runner for `task_complete` — separate from `runCheck` so checklist + * completion does not enable the model's on-demand `check` tool. */ + runTaskGate?: IToolContext["runTaskGate"]; + /** Session-bound plan id — task_* tools read/write this plan. */ + activePlanId?: string | null; + /** Fired after a task_* tool persists a plan change (Tasks rail refresh). */ + onPlanChanged?: IToolContext["onPlanChanged"]; + /** present_plan proposal callback (REPL renders pending plan). */ + onPlanPresented?: IToolContext["onPlanPresented"]; } /** Gate/VALIDATION options — what `settleGate` and the write-guard consume. */ @@ -2568,7 +2604,7 @@ async function scopeRevision( const parts: string[] = []; for (const path of [...snap.existed].sort()) { - parts.push(`${path}${snap.contents.get(path) ?? ""}`); + parts.push(`${path}\0${snap.contents.get(path) ?? ""}`); } return String(Bun.hash(parts.join(""))); diff --git a/packages/core/src/loop/worklist/checklist-store.ts b/packages/core/src/loop/worklist/checklist-store.ts new file mode 100644 index 00000000..9933f4c1 --- /dev/null +++ b/packages/core/src/loop/worklist/checklist-store.ts @@ -0,0 +1,537 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { + ChecklistStatus, + IChecklistItem, + IPlanDocument, + IPlanIndex, + IPlanIndexEntry, +} from "./checklist.types"; + +const WORKLIST_DIR = ".tsforge/worklist"; +const INDEX_FILE = "index.json"; +const PLANS_DIR = "plans"; + +export function worklistRoot(cwd: string): string { + return join(cwd, WORKLIST_DIR); +} + +export function plansDir(cwd: string): string { + return join(worklistRoot(cwd), PLANS_DIR); +} + +export function planPath(cwd: string, planId: string): string { + return join(plansDir(cwd), `${planId}.json`); +} + +function indexPath(cwd: string): string { + return join(worklistRoot(cwd), INDEX_FILE); +} + +function ensureDirs(cwd: string): void { + mkdirSync(plansDir(cwd), { recursive: true }); +} + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function isStatus(v: unknown): v is ChecklistStatus { + return v === "pending" || v === "active" || v === "done" || v === "blocked"; +} + +function parseItem(raw: unknown): IChecklistItem | null { + if (!isRecord(raw)) { + return null; + } + + if (typeof raw.id !== "string" || raw.id.length === 0) { + return null; + } + + if (typeof raw.title !== "string" || raw.title.trim().length === 0) { + return null; + } + + if (!isStatus(raw.status)) { + return null; + } + + const childrenRaw = raw.children; + let children: IChecklistItem[] | undefined; + + if (Array.isArray(childrenRaw)) { + children = []; + + for (const c of childrenRaw) { + const parsed = parseItem(c); + + if (parsed === null) { + return null; + } + + children.push(parsed); + } + } + + const item: IChecklistItem = { + id: raw.id, + title: raw.title.trim(), + status: raw.status, + ...(typeof raw.detail === "string" ? { detail: raw.detail } : {}), + ...(Array.isArray(raw.files) && + raw.files.every((f): f is string => typeof f === "string") + ? { files: raw.files } + : {}), + ...(typeof raw.verify === "string" ? { verify: raw.verify } : {}), + ...(typeof raw.blockedReason === "string" + ? { blockedReason: raw.blockedReason } + : {}), + ...(typeof raw.updatedAt === "string" ? { updatedAt: raw.updatedAt } : {}), + ...(typeof raw.completedAt === "string" + ? { completedAt: raw.completedAt } + : {}), + ...(children !== undefined && children.length > 0 ? { children } : {}), + }; + + return item; +} + +function parsePlanDocument(raw: unknown): IPlanDocument | null { + if (!isRecord(raw)) { + return null; + } + + if (raw.schemaVersion !== 2) { + return null; + } + + if (typeof raw.id !== "string" || raw.id.length === 0) { + return null; + } + + if (typeof raw.goal !== "string") { + return null; + } + + if (raw.activeItemId !== null && typeof raw.activeItemId !== "string") { + return null; + } + + if (typeof raw.updatedAt !== "string") { + return null; + } + + if (!Array.isArray(raw.items) || raw.items.length === 0) { + return null; + } + + const items: IChecklistItem[] = []; + + for (const item of raw.items) { + const parsed = parseItem(item); + + if (parsed === null) { + return null; + } + + items.push(parsed); + } + + return { + schemaVersion: 2, + id: raw.id, + goal: raw.goal, + activeItemId: raw.activeItemId, + updatedAt: raw.updatedAt, + items, + }; +} + +function parseIndex(raw: unknown): IPlanIndex { + if (!isRecord(raw) || !Array.isArray(raw.plans)) { + return { plans: [] }; + } + + const plans: IPlanIndexEntry[] = []; + + for (const entry of raw.plans) { + if (!isRecord(entry)) { + continue; + } + + if ( + typeof entry.id !== "string" || + typeof entry.goal !== "string" || + typeof entry.updatedAt !== "string" + ) { + continue; + } + + plans.push({ + id: entry.id, + goal: entry.goal, + updatedAt: entry.updatedAt, + }); + } + + return { plans }; +} + +export function loadPlanIndex(cwd: string): IPlanIndex { + const path = indexPath(cwd); + + if (!existsSync(path)) { + return { plans: [] }; + } + + try { + return parseIndex(JSON.parse(readFileSync(path, "utf8"))); + } catch { + return { plans: [] }; + } +} + +export function savePlanIndex(cwd: string, index: IPlanIndex): void { + ensureDirs(cwd); + writeFileSync(indexPath(cwd), `${JSON.stringify(index, null, 2)}\n`, "utf8"); +} + +export function loadPlan(cwd: string, planId: string): IPlanDocument | null { + const path = planPath(cwd, planId); + + if (!existsSync(path)) { + return null; + } + + try { + return parsePlanDocument(JSON.parse(readFileSync(path, "utf8"))); + } catch { + return null; + } +} + +export function savePlan(cwd: string, plan: IPlanDocument): void { + ensureDirs(cwd); + writeFileSync( + planPath(cwd, plan.id), + `${JSON.stringify(plan, null, 2)}\n`, + "utf8" + ); + + const index = loadPlanIndex(cwd); + const entry: IPlanIndexEntry = { + id: plan.id, + goal: plan.goal, + updatedAt: plan.updatedAt, + }; + const rest = index.plans.filter((p) => p.id !== plan.id); + + savePlanIndex(cwd, { plans: [entry, ...rest] }); +} + +export function findItem( + items: readonly IChecklistItem[], + id: string +): IChecklistItem | null { + for (const item of items) { + if (item.id === id) { + return item; + } + + if (item.children) { + const found = findItem(item.children, id); + + if (found !== null) { + return found; + } + } + } + + return null; +} + +export function mapItems( + items: readonly IChecklistItem[], + fn: (item: IChecklistItem) => IChecklistItem +): IChecklistItem[] { + return items.map((item) => { + const next = fn(item); + const children = next.children + ? mapItems(next.children, fn) + : next.children; + + if (children === next.children) { + return next; + } + + return { ...next, children }; + }); +} + +/** Update one item by id; returns new tree or null if id missing. */ +export function updateItemById( + items: readonly IChecklistItem[], + id: string, + updater: (item: IChecklistItem) => IChecklistItem +): IChecklistItem[] | null { + if (findItem(items, id) === null) { + return null; + } + + const walk = (nodes: readonly IChecklistItem[]): IChecklistItem[] => + nodes.map((item) => { + if (item.id === id) { + return updater(item); + } + + if (item.children === undefined) { + return item; + } + + return { ...item, children: walk(item.children) }; + }); + + return walk(items); +} + +export function countOpen(items: readonly IChecklistItem[]): number { + let n = 0; + + for (const item of items) { + if (item.status !== "done") { + n += 1; + } + + if (item.children) { + n += countOpen(item.children); + } + } + + return n; +} + +export function countDone(items: readonly IChecklistItem[]): number { + let n = 0; + + for (const item of items) { + if (item.status === "done") { + n += 1; + } + + if (item.children) { + n += countDone(item.children); + } + } + + return n; +} + +export function isChecklistComplete(plan: IPlanDocument): boolean { + return countOpen(plan.items) === 0; +} + +function allChildrenDone(item: IChecklistItem): boolean { + if (!item.children || item.children.length === 0) { + return true; + } + + return item.children.every((c) => c.status === "done"); +} + +/** + * Mark an item done. Refuses if children exist and any child is not done. + * After marking, walks up and auto-completes parents whose children are all done. + */ +export function completeItemInPlan( + plan: IPlanDocument, + itemId: string, + now: string = new Date().toISOString() +): { ok: true; plan: IPlanDocument } | { ok: false; error: string } { + const target = findItem(plan.items, itemId); + + if (target === null) { + return { ok: false, error: `unknown item id: ${itemId}` }; + } + + if (!allChildrenDone(target)) { + return { + ok: false, + error: "cannot complete parent while children remain open", + }; + } + + let items = updateItemById(plan.items, itemId, (item) => ({ + ...item, + status: "done" as const, + updatedAt: now, + completedAt: now, + })); + + if (items === null) { + return { ok: false, error: `unknown item id: ${itemId}` }; + } + + // Auto-complete parents when all children are done. + let changed = true; + + while (changed) { + changed = false; + items = mapItems(items, (item) => { + if ( + item.status !== "done" && + item.children && + item.children.length > 0 && + item.children.every((c) => c.status === "done") + ) { + changed = true; + + return { + ...item, + status: "done", + updatedAt: now, + completedAt: now, + }; + } + + return item; + }); + } + + const activeItemId = plan.activeItemId === itemId ? null : plan.activeItemId; + + return { + ok: true, + plan: { + ...plan, + items, + activeItemId, + updatedAt: now, + }, + }; +} + +export function uncompleteItemInPlan( + plan: IPlanDocument, + itemId: string, + now: string = new Date().toISOString() +): { ok: true; plan: IPlanDocument } | { ok: false; error: string } { + const items = updateItemById(plan.items, itemId, (item) => ({ + ...item, + status: "pending" as const, + updatedAt: now, + completedAt: undefined, + })); + + if (items === null) { + return { ok: false, error: `unknown item id: ${itemId}` }; + } + + return { + ok: true, + plan: { + ...plan, + items, + updatedAt: now, + }, + }; +} + +export function focusItemInPlan( + plan: IPlanDocument, + itemId: string, + now: string = new Date().toISOString() +): { ok: true; plan: IPlanDocument } | { ok: false; error: string } { + const target = findItem(plan.items, itemId); + + if (target === null) { + return { ok: false, error: `unknown item id: ${itemId}` }; + } + + if (target.status === "done") { + return { ok: false, error: "cannot focus a done item — uncomplete first" }; + } + + // Clear previous active → pending (unless already the target). + let items = mapItems(plan.items, (item) => { + if (item.status === "active" && item.id !== itemId) { + return { ...item, status: "pending", updatedAt: now }; + } + + return item; + }); + + const focused = updateItemById(items, itemId, (item) => ({ + ...item, + status: "active" as const, + updatedAt: now, + })); + + if (focused === null) { + return { ok: false, error: `unknown item id: ${itemId}` }; + } + + items = focused; + + return { + ok: true, + plan: { + ...plan, + items, + activeItemId: itemId, + updatedAt: now, + }, + }; +} + +/** Compact tree for turn inject / tool list. */ +export function formatPlanTree( + plan: IPlanDocument, + opts: { maxDepth?: number; indent?: string } = {} +): string { + const indent = opts.indent ?? " "; + const lines: string[] = [`goal: ${plan.goal}`]; + + if (plan.activeItemId !== null) { + const active = findItem(plan.items, plan.activeItemId); + + lines.push( + `active: ${active !== null ? `${active.title} (${active.id})` : plan.activeItemId}` + ); + } else { + lines.push("active: (none)"); + } + + lines.push( + `open: ${String(countOpen(plan.items))} done: ${String(countDone(plan.items))}` + ); + lines.push("items:"); + + const walk = (nodes: readonly IChecklistItem[], depth: number): void => { + if (opts.maxDepth !== undefined && depth > opts.maxDepth) { + return; + } + + for (const item of nodes) { + const mark = + item.status === "done" + ? "[x]" + : item.status === "active" + ? "[>]" + : item.status === "blocked" + ? "[!]" + : "[ ]"; + const pad = indent.repeat(depth); + + lines.push(`${pad}${mark} ${item.title} (${item.id})`); + + if (item.children) { + walk(item.children, depth + 1); + } + } + }; + + walk(plan.items, 1); + + return lines.join("\n"); +} diff --git a/packages/core/src/loop/worklist/checklist.types.ts b/packages/core/src/loop/worklist/checklist.types.ts new file mode 100644 index 00000000..9eca06ba --- /dev/null +++ b/packages/core/src/loop/worklist/checklist.types.ts @@ -0,0 +1,54 @@ +/** One node in a session-bound project plan checklist. */ +export type ChecklistStatus = "pending" | "active" | "done" | "blocked"; + +export interface IChecklistItem { + readonly id: string; + readonly title: string; + readonly status: ChecklistStatus; + readonly detail?: string; + readonly files?: readonly string[]; + /** Hint only — not executed as a harness gate. */ + readonly verify?: string; + readonly blockedReason?: string; + readonly updatedAt?: string; + readonly completedAt?: string; + readonly children?: readonly IChecklistItem[]; +} + +/** Full plan document under `.tsforge/worklist/plans/.json`. */ +export interface IPlanDocument { + readonly schemaVersion: 2; + readonly id: string; + readonly goal: string; + readonly activeItemId: string | null; + readonly updatedAt: string; + readonly items: readonly IChecklistItem[]; +} + +export interface IPlanIndexEntry { + readonly id: string; + readonly goal: string; + readonly updatedAt: string; +} + +export interface IPlanIndex { + readonly plans: readonly IPlanIndexEntry[]; +} + +/** Draft item shape the model may emit (ids/status optional). */ +export interface IChecklistItemDraft { + readonly id?: string; + readonly title: string; + readonly status?: ChecklistStatus; + readonly detail?: string; + readonly files?: readonly string[]; + readonly verify?: string; + readonly blockedReason?: string; + readonly children?: readonly IChecklistItemDraft[]; +} + +/** Draft plan JSON from the assistant (before normalize). */ +export interface IPlanDraft { + readonly goal?: string; + readonly items: readonly IChecklistItemDraft[]; +} diff --git a/packages/core/src/loop/worklist/index.ts b/packages/core/src/loop/worklist/index.ts new file mode 100644 index 00000000..5ae4704c --- /dev/null +++ b/packages/core/src/loop/worklist/index.ts @@ -0,0 +1,56 @@ +export { + parseWorklist, + resolveWorklistPath, + slugifyItem, + itemsToFeatures, + acceptMapOf, +} from "./parse"; +export { + WORKLIST_STATE, + prepareWorklistState, + runWorklist, + tickWorklistFile, +} from "./run"; +export type { IPrepareWorklistOptions } from "./run"; +export { + extractPlanJson, + parsePlanDraft, + normalizePlanDraft, + planDocumentFromUnknown, + persistPlanDocument, + seedWorklistFromPlan, + goalFromMessages, +} from "./seed"; +export type { SeedWorklistResult, NormalizePlanResult } from "./seed"; +export { + formatWorklistLines, + formatPlanProposal, + worklistBadge, + pendingPlanBadge, +} from "./panel"; +export type { IFormatWorklistLinesOptions } from "./panel"; +export type { IWorklistItem, IParseWorklistOptions } from "./worklist.types"; +export type { + ChecklistStatus, + IChecklistItem, + IPlanDocument, + IPlanIndex, + IPlanIndexEntry, + IPlanDraft, + IChecklistItemDraft, +} from "./checklist.types"; +export { + loadPlan, + savePlan, + loadPlanIndex, + findItem, + countOpen, + countDone, + isChecklistComplete, + completeItemInPlan, + uncompleteItemInPlan, + focusItemInPlan, + formatPlanTree, + planPath, + worklistRoot, +} from "./checklist-store"; diff --git a/packages/core/src/loop/worklist/panel.ts b/packages/core/src/loop/worklist/panel.ts new file mode 100644 index 00000000..386d4dd6 --- /dev/null +++ b/packages/core/src/loop/worklist/panel.ts @@ -0,0 +1,416 @@ +import type { IChecklistItem, IPlanDocument } from "./checklist.types"; +import { countDone, countOpen } from "./checklist-store"; +import { stripSgr } from "../../render/frame/ansi-plain"; +import { CONSOLE } from "../../render/frame/chrome"; +import { + filledRoleBadge, + roleBadgeCols, + roleCardCols, + roleHairline, +} from "../../render/ansi"; +import { STYLE, paint } from "../../render/style"; +import { displayWidth, sliceToWidth } from "../../render/width"; + +export interface IFormatWorklistLinesOptions { + /** + * How many pending (not-yet-current) items to preview. + * Default 12 — fill a tall rail; callers may pass panel body rows. + */ + maxPending?: number; + /** Highlight this line index when the panel is focused (0 = first body line). */ + selectedIndex?: number; + /** When true, prefix the selected row with `▸ ` (skipped if the row is already current). */ + showSelection?: boolean; + /** Wrap width for descriptions (panel inner cols). Default 36. */ + columns?: number; + /** When false, emit plain glyphs with no SGR. Default true. */ + color?: boolean; +} + +type ItemKind = "done" | "current" | "pending" | "blocked"; + +const GLYPH: Record = { + done: "✓", + current: "▸", + pending: "○", + blocked: "!", +}; + +const CONT_INDENT = " "; + +/** Compact badge for the top status strip, e.g. `3/7`. */ +export function worklistBadge(plan: IPlanDocument | null): string { + if (plan === null || plan.items.length === 0) { + return ""; + } + + const open = countOpen(plan.items); + const done = countDone(plan.items); + const total = open + done; + + if (total === 0) { + return ""; + } + + return `${done}/${total}`; +} + +/** Badge while a present_plan proposal awaits approve (not yet bound). */ +export function pendingPlanBadge(plan: IPlanDocument | null): string { + if (plan === null || plan.items.length === 0) { + return ""; + } + + const open = countOpen(plan.items); + + return `·${String(open)}`; +} + +/** + * Closed PLAN card for the main transcript when present_plan fires — + * goal + nested tree, no raw JSON. Soft-wraps (never mid-word clip). + */ +export function formatPlanProposal( + plan: IPlanDocument, + columns?: number, + color = true +): string { + const cols = roleCardCols(columns); + const badge = filledRoleBadge("PLAN", color); + const top = + badge + roleHairline(cols, STYLE.plan, color, "┐", roleBadgeCols(badge)); + const gutter = paint("│", STYLE.plan, color); + const right = paint("│", STYLE.plan, color); + // Box inner between the two `│` rails; text sits in `│ … │` (2-col pad each side). + const boxInner = Math.max(14, cols - 2); + const textBudget = Math.max(12, boxInner - 4); + const lines: string[] = [top]; + + const pushBlank = (): void => { + lines.push(`${gutter}${" ".repeat(boxInner)}${right}`); + }; + + const push = (text: string, bold = false): void => { + const parts = wrapWords(text, textBudget); + + for (const part of parts.length > 0 ? parts : [""]) { + const body = paint( + part, + bold ? STYLE.plan + STYLE.bold : STYLE.plan, + color + ); + const pad = Math.max(0, textBudget - displayWidth(part)); + + lines.push(`${gutter} ${body}${" ".repeat(pad)} ${right}`); + } + }; + + pushBlank(); + push(plan.goal.trim().length > 0 ? plan.goal.trim() : "plan", true); + pushBlank(); + + const walk = (nodes: readonly IChecklistItem[], depth: number): void => { + for (const item of nodes) { + const pad = " ".repeat(depth); + + push(`${pad}○ ${item.title}`); + + if (item.detail !== undefined && item.detail.trim().length > 0) { + push(`${pad} ${item.detail.trim()}`); + } + + if (item.children) { + walk(item.children, depth + 1); + } + } + }; + + walk(plan.items, 0); + pushBlank(); + push(`type approve to build · ${String(countOpen(plan.items))} items`); + lines.push( + paint(`└${"─".repeat(Math.max(0, cols - 2))}┘`, STYLE.plan, color) + ); + + return lines.join("\n"); +} + +function clip(text: string, max: number): string { + return sliceToWidth(text, max).text; +} + +function pushHardBroken(word: string, budget: number, lines: string[]): string { + let rest = word; + + while (rest.length > 0 && displayWidth(rest) > budget) { + const cut = sliceToWidth(rest, budget); + + if (cut.text.length === 0) { + break; + } + + lines.push(cut.text); + rest = rest.slice(cut.text.length); + } + + return rest; +} + +/** Soft-wrap `text` to `budget` columns (word-aware, grapheme-safe). */ +function wrapWords(text: string, budget: number): string[] { + if (budget <= 0) { + return []; + } + + if (displayWidth(text) <= budget) { + return [text]; + } + + const words = text.split(/\s+/u).filter((w) => w.length > 0); + + if (words.length === 0) { + return [clip(text, budget)]; + } + + const lines: string[] = []; + let cur = ""; + + for (const word of words) { + const next = cur.length === 0 ? word : `${cur} ${word}`; + + if (displayWidth(next) <= budget) { + cur = next; + continue; + } + + if (cur.length > 0) { + lines.push(cur); + } + + cur = + displayWidth(word) <= budget ? word : pushHardBroken(word, budget, lines); + } + + if (cur.length > 0) { + lines.push(cur); + } + + return lines.length > 0 ? lines : [clip(text, budget)]; +} + +function paintGlyph(glyph: string, kind: ItemKind, color: boolean): string { + if (!color) { + return glyph; + } + + if (kind === "current") { + return paint(glyph, CONSOLE.bright, true); + } + + if (kind === "blocked") { + return paint(glyph, CONSOLE.warn, true); + } + + return paint(glyph, CONSOLE.muted, true); +} + +function paintBody(part: string, kind: ItemKind, color: boolean): string { + if (!color) { + return part; + } + + if (kind === "current") { + return paint(part, CONSOLE.bright, true); + } + + if (kind === "blocked") { + return part; + } + + return paint(part, CONSOLE.muted, true); +} + +function kindOf(item: IChecklistItem, activeItemId: string | null): ItemKind { + if (item.status === "done") { + return "done"; + } + + if (item.status === "blocked") { + return "blocked"; + } + + if (item.id === activeItemId || item.status === "active") { + return "current"; + } + + return "pending"; +} + +/** One item: glyph + wrapped title (continuations indented). */ +function formatItemLines( + item: IChecklistItem, + kind: ItemKind, + columns: number, + color: boolean, + depth: number, + extras: readonly string[] +): string[] { + const glyph = GLYPH[kind]; + const painted = paintGlyph(glyph, kind, color); + const nest = CONT_INDENT.repeat(depth); + const budget = Math.max(4, columns - displayWidth(`${nest}${glyph} `)); + const parts = wrapWords(item.title.trim(), budget); + const lines = parts.map((part, i) => { + const body = paintBody(part, kind, color); + + return i === 0 + ? `${nest}${painted} ${body}` + : `${nest}${CONT_INDENT}${body}`; + }); + + for (const extra of extras) { + const clipped = clip(extra, Math.max(4, columns - nest.length - 2)); + + lines.push(paint(`${nest}${CONT_INDENT}${clipped}`, CONSOLE.muted, color)); + } + + return lines; +} + +function applySelection( + lines: readonly string[], + selectedIndex: number, + color: boolean +): string[] { + return lines.map((line, i) => { + if (i !== selectedIndex) { + return ` ${line}`; + } + + const plain = stripSgr(line); + + if (plain.trimStart().startsWith(GLYPH.current)) { + return line; + } + + return paint(`▸ ${plain}`, CONSOLE.bright, color); + }); +} + +function focusExtras(item: IChecklistItem): string[] { + const extras: string[] = []; + + if (item.verify !== undefined && item.verify.trim().length > 0) { + extras.push(`verify: ${item.verify.trim()}`); + } + + if ( + item.blockedReason !== undefined && + item.blockedReason.trim().length > 0 + ) { + extras.push(`blocked: ${item.blockedReason.trim()}`); + } + + return extras; +} + +function walkChecklist( + plan: IPlanDocument, + nodes: readonly IChecklistItem[], + depth: number, + ctx: { + readonly maxPending: number; + readonly columns: number; + readonly color: boolean; + readonly pending: { shown: number; hidden: number }; + readonly out: string[]; + } +): void { + for (const item of nodes) { + const kind = kindOf(item, plan.activeItemId); + const isFocus = + kind === "current" || + (plan.activeItemId !== null && item.id === plan.activeItemId); + + if (kind === "pending" && !isFocus) { + if (ctx.pending.shown >= ctx.maxPending) { + ctx.pending.hidden += 1; + + if (item.children !== undefined) { + walkChecklist(plan, item.children, depth + 1, ctx); + } + + continue; + } + + ctx.pending.shown += 1; + } + + ctx.out.push( + ...formatItemLines( + item, + kind, + ctx.columns, + ctx.color, + depth, + isFocus ? focusExtras(item) : [] + ) + ); + + if (item.children !== undefined) { + walkChecklist(plan, item.children, depth + 1, ctx); + } + } +} + +/** + * Tasks-rail body lines — goal cue + nested checklist for the session-bound plan. + */ +export function formatWorklistLines( + plan: IPlanDocument | null, + opts: IFormatWorklistLinesOptions = {} +): string[] { + const maxPending = opts.maxPending ?? 12; + const columns = Math.max(12, opts.columns ?? 36); + const color = opts.color !== false; + + if (plan === null || plan.items.length === 0) { + return [ + paint("approve a plan", CONSOLE.muted, color), + paint("to fill this list", CONSOLE.muted, color), + ]; + } + + const lines: string[] = []; + const goal = plan.goal.trim(); + + if (goal.length > 0) { + lines.push(paint(clip(goal, columns), CONSOLE.muted, color)); + } + + const pending = { shown: 0, hidden: 0 }; + + walkChecklist(plan, plan.items, 0, { + maxPending, + columns, + color, + pending, + out: lines, + }); + + if (pending.hidden > 0) { + lines.push( + paint(`… +${String(pending.hidden)} more`, CONSOLE.muted, color) + ); + } + + if (countOpen(plan.items) === 0) { + lines.push(paint("All done.", CONSOLE.bright, color)); + } + + if (opts.showSelection === true && opts.selectedIndex !== undefined) { + return applySelection(lines, opts.selectedIndex, color); + } + + return lines; +} diff --git a/packages/core/src/loop/worklist/parse.ts b/packages/core/src/loop/worklist/parse.ts new file mode 100644 index 00000000..246e2998 --- /dev/null +++ b/packages/core/src/loop/worklist/parse.ts @@ -0,0 +1,291 @@ +import { access } from "node:fs/promises"; +import { join, isAbsolute } from "node:path"; +import { isFeatureId } from "../greenfield/state"; +import type { IFeature } from "../greenfield/greenfield.types"; +import type { IParseWorklistOptions, IWorklistItem } from "./worklist.types"; + +/** Human markdown lookup only — never `.specs/next.md` (product/solo-spec). */ +const DEFAULT_LOOKUP = ["PLAN.md", "TASKS.md"] as const; + +const CHECKBOX_RE = /^(\s*)[-*]\s+\[([ xX])\]\s+(.+)$/; +const NUMBERED_RE = /^(\d+)\.\s+(.+)$/; +/** Plain bullets (plan-mode paste) — open items, not checkboxes. */ +const BULLET_RE = /^(\s*)[-*]\s+(?!\[)(.+)$/; + +function splitList(value: string): string[] { + return value + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +/** + * Turn item prose into a kebab-case id that satisfies `isFeatureId`. + * Non-alphanumerics collapse to hyphens; empty residue becomes `"item"`. + */ +export function slugifyItem(text: string): string { + const slug = text + .toLowerCase() + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, "") + .slice(0, 64) + .replace(/-+$/u, ""); + + if (slug.length === 0 || !isFeatureId(slug)) { + return "item"; + } + + return slug; +} + +function uniqueId(base: string, used: Set): string { + if (!used.has(base)) { + used.add(base); + + return base; + } + + let n = 2; + + for (;;) { + const candidate = `${base}-${n}`; + + if (!used.has(candidate) && isFeatureId(candidate)) { + used.add(candidate); + + return candidate; + } + + n += 1; + } +} + +interface IWorklistDraft { + text: string; + done: boolean; + accept?: string; + files?: string[]; + context?: string[]; + fix?: string; +} + +function applyProperty(draft: IWorklistDraft, line: string): boolean { + const acceptMatch = /^\s+accept:\s*(.+)$/u.exec(line); + const acceptValue = acceptMatch?.[1]; + + if (acceptValue !== undefined) { + draft.accept = acceptValue.trim(); + + return true; + } + + const filesMatch = /^\s+files:\s*(.+)$/u.exec(line); + const filesValue = filesMatch?.[1]; + + if (filesValue !== undefined) { + draft.files = splitList(filesValue); + + return true; + } + + const contextMatch = /^\s+context:\s*(.+)$/u.exec(line); + const contextValue = contextMatch?.[1]; + + if (contextValue !== undefined) { + draft.context = splitList(contextValue); + + return true; + } + + const fixMatch = /^\s+fix:\s*(.+)$/u.exec(line); + const fixValue = fixMatch?.[1]; + + if (fixValue !== undefined) { + draft.fix = fixValue.trim(); + + return true; + } + + return false; +} + +function pushCurrent( + drafts: IWorklistDraft[], + current: IWorklistDraft | null +): void { + if (current !== null) { + drafts.push(current); + } +} + +/** Scan markdown into raw drafts (one pass; properties attach to the current item). */ +function collectDrafts(md: string): IWorklistDraft[] { + const drafts: IWorklistDraft[] = []; + let current: IWorklistDraft | null = null; + + for (const line of md.split("\n")) { + const checkbox = CHECKBOX_RE.exec(line); + + if (checkbox !== null) { + pushCurrent(drafts, current); + const mark = checkbox[2] ?? " "; + + current = { + text: (checkbox[3] ?? "").trim(), + done: mark === "x" || mark === "X", + }; + continue; + } + + const numbered = NUMBERED_RE.exec(line); + + if (numbered !== null) { + pushCurrent(drafts, current); + current = { + text: (numbered[2] ?? "").trim(), + done: false, + }; + continue; + } + + const bullet = BULLET_RE.exec(line); + + if (bullet !== null) { + pushCurrent(drafts, current); + current = { + text: (bullet[2] ?? "").trim(), + done: false, + }; + continue; + } + + if (current !== null) { + applyProperty(current, line); + } + } + + pushCurrent(drafts, current); + + return drafts; +} + +function draftToItem( + draft: IWorklistDraft, + used: Set +): IWorklistItem | null { + if (draft.text.length === 0) { + return null; + } + + const item: IWorklistItem = { + id: uniqueId(slugifyItem(draft.text), used), + text: draft.text, + done: draft.done, + }; + + if (draft.accept !== undefined) { + item.accept = draft.accept; + } + + if (draft.files !== undefined) { + item.files = draft.files; + } + + if (draft.context !== undefined) { + item.context = draft.context; + } + + if (draft.fix !== undefined) { + item.fix = draft.fix; + } + + return item; +} + +/** + * Parse a human-written worklist: markdown checkboxes and/or numbered items + * with optional indented `accept` / `files` / `context` / `fix` properties. + * Checked boxes are dropped unless `includeDone` is set. + */ +export function parseWorklist( + md: string, + opts: IParseWorklistOptions = {} +): IWorklistItem[] { + const includeDone = opts.includeDone === true; + const used = new Set(); + const items: IWorklistItem[] = []; + + for (const draft of collectDrafts(md)) { + if (draft.done && !includeDone) { + continue; + } + + const item = draftToItem(draft, used); + + if (item !== null) { + items.push(item); + } + } + + return items; +} + +/** Convert open worklist items into greenfield features. */ +export function itemsToFeatures(items: readonly IWorklistItem[]): IFeature[] { + return items.map((item) => ({ + id: item.id, + desc: item.text, + passes: false, + attempts: 0, + })); +} + +/** Per-item accept overrides keyed by feature id. */ +export function acceptMapOf( + items: readonly IWorklistItem[] +): Map { + const map = new Map(); + + for (const item of items) { + if (item.accept !== undefined && item.accept.length > 0) { + map.set(item.id, item.accept); + } + } + + return map; +} + +/** + * Resolve which worklist file to use. Explicit path wins when present; + * otherwise PLAN.md → TASKS.md under `cwd`. + */ +export async function resolveWorklistPath( + cwd: string, + explicit?: string +): Promise { + if (explicit !== undefined && explicit.length > 0) { + const path = isAbsolute(explicit) ? explicit : join(cwd, explicit); + + try { + await access(path); + + return path; + } catch { + return null; + } + } + + for (const name of DEFAULT_LOOKUP) { + const path = join(cwd, name); + + try { + await access(path); + + return path; + } catch { + // try next + } + } + + return null; +} diff --git a/packages/core/src/loop/worklist/run.ts b/packages/core/src/loop/worklist/run.ts new file mode 100644 index 00000000..bb403797 --- /dev/null +++ b/packages/core/src/loop/worklist/run.ts @@ -0,0 +1,133 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { hasState, loadState, runGreenfield, saveState } from "../greenfield"; +import type { + IFeature, + IGreenfieldDeps, + IGreenfieldOptions, + IGreenfieldResult, + IGreenfieldState, +} from "../greenfield"; +import { itemsToFeatures, parseWorklist, resolveWorklistPath } from "./parse"; +import type { IWorklistItem } from "./worklist.types"; + +/** Persistence subdirectory under `.tsforge/` for worklist runs. */ +export const WORKLIST_STATE = "worklist"; + +export interface IPrepareWorklistOptions { + /** Goal line stored in state / progress.md. */ + goal?: string; + /** Explicit worklist file (relative to cwd or absolute). */ + path?: string; + /** Pre-parsed items — skips file lookup when provided. */ + items?: readonly IWorklistItem[]; +} + +/** + * Resume `.tsforge/worklist/` when present; otherwise parse a list file (or + * supplied items) into a fresh checklist and persist it. + */ +export async function prepareWorklistState( + cwd: string, + opts: IPrepareWorklistOptions = {} +): Promise { + if (await hasState(cwd, WORKLIST_STATE)) { + const existing = await loadState(cwd, WORKLIST_STATE); + + if (existing !== null && existing.features.length > 0) { + return existing; + } + } + + let items: readonly IWorklistItem[]; + + if (opts.items !== undefined) { + items = opts.items; + } else { + const path = await resolveWorklistPath(cwd, opts.path); + + if (path === null) { + return null; + } + + const md = await readFile(path, "utf8"); + + items = parseWorklist(md); + } + + if (items.length === 0) { + return null; + } + + const state: IGreenfieldState = { + goal: opts.goal ?? "worklist", + features: itemsToFeatures(items), + }; + + await saveState(cwd, state, WORKLIST_STATE); + + return state; +} + +/** + * Drive a worklist through `runGreenfield`, persisting under `.tsforge/worklist/`. + */ +export async function runWorklist( + cwd: string, + state: IGreenfieldState, + deps: IGreenfieldDeps, + opts: IGreenfieldOptions = {} +): Promise { + return runGreenfield(cwd, state, deps, { + ...opts, + stateName: WORKLIST_STATE, + }); +} + +/** + * Opt-in rewrite of a human checklist file: flip `- [ ] ` to + * `- [x]` for features that already pass. Leaves numbered lists and unmatched + * lines untouched. + */ +export async function tickWorklistFile( + path: string, + features: readonly IFeature[] +): Promise { + const passed = new Set( + features.filter((f) => f.passes).map((f) => f.desc.trim()) + ); + + if (passed.size === 0) { + return; + } + + const md = await readFile(path, "utf8"); + const lines = md.split("\n"); + const next: string[] = []; + let changed = false; + + for (const line of lines) { + const match = /^(\s*[-*]\s+)\[ \](\s+)(.+)$/u.exec(line); + + if (match === null) { + next.push(line); + continue; + } + + const prefix = match[1] ?? ""; + const gap = match[2] ?? " "; + const body = match[3] ?? ""; + const text = body.trim(); + + if (!passed.has(text)) { + next.push(line); + continue; + } + + changed = true; + next.push(`${prefix}[x]${gap}${body}`); + } + + if (changed) { + await writeFile(path, next.join("\n")); + } +} diff --git a/packages/core/src/loop/worklist/seed.ts b/packages/core/src/loop/worklist/seed.ts new file mode 100644 index 00000000..413a034a --- /dev/null +++ b/packages/core/src/loop/worklist/seed.ts @@ -0,0 +1,322 @@ +/** + * Plan-mode approve → session-bound plan under `.tsforge/worklist/plans/`. + * The model emits fenced JSON; the harness only extracts, validates, and persists. + */ +import type { + IChecklistItem, + IChecklistItemDraft, + IPlanDocument, + IPlanDraft, +} from "./checklist.types"; +import { savePlan } from "./checklist-store"; + +export type SeedWorklistResult = + | { readonly ok: true; readonly plan: IPlanDocument } + | { readonly ok: false; readonly error: string }; + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +/** Prefer ```json … ```; else any fenced block that parses as a plan draft. */ +export function extractPlanJson(assistantText: string): unknown { + const fences = [ + ...assistantText.matchAll(/```(?:json)?\s*\n([\s\S]*?)```/giu), + ]; + + for (const match of fences) { + const body = match[1]?.trim() ?? ""; + + if (body.length === 0) { + continue; + } + + try { + return JSON.parse(body); + } catch { + // try next fence + } + } + + return null; +} + +function normalizeItem(draft: IChecklistItemDraft): IChecklistItem | null { + const title = draft.title.trim(); + + if (title.length === 0) { + return null; + } + + const children: IChecklistItem[] = []; + + if (draft.children) { + for (const child of draft.children) { + const normalized = normalizeItem(child); + + if (normalized === null) { + return null; + } + + children.push(normalized); + } + } + + const status = + draft.status === "pending" || + draft.status === "active" || + draft.status === "done" || + draft.status === "blocked" + ? draft.status + : "pending"; + + return { + id: + typeof draft.id === "string" && draft.id.length > 0 + ? draft.id + : crypto.randomUUID(), + title, + status, + ...(typeof draft.detail === "string" && draft.detail.trim().length > 0 + ? { detail: draft.detail } + : {}), + ...(draft.files !== undefined && + draft.files.length > 0 && + draft.files.every((f) => typeof f === "string") + ? { files: [...draft.files] } + : {}), + ...(typeof draft.verify === "string" && draft.verify.trim().length > 0 + ? { verify: draft.verify } + : {}), + ...(typeof draft.blockedReason === "string" && + draft.blockedReason.trim().length > 0 + ? { blockedReason: draft.blockedReason } + : {}), + ...(children.length > 0 ? { children } : {}), + }; +} + +function parseDraftItem(raw: unknown): IChecklistItemDraft | null { + if (!isRecord(raw)) { + return null; + } + + if (typeof raw.title !== "string") { + return null; + } + + let children: IChecklistItemDraft[] | undefined; + + if (Array.isArray(raw.children)) { + children = []; + + for (const c of raw.children) { + const parsed = parseDraftItem(c); + + if (parsed === null) { + return null; + } + + children.push(parsed); + } + } + + return { + ...(typeof raw.id === "string" ? { id: raw.id } : {}), + title: raw.title, + ...(raw.status === "pending" || + raw.status === "active" || + raw.status === "done" || + raw.status === "blocked" + ? { status: raw.status } + : {}), + ...(typeof raw.detail === "string" ? { detail: raw.detail } : {}), + ...(Array.isArray(raw.files) && + raw.files.every((f): f is string => typeof f === "string") + ? { files: raw.files } + : {}), + ...(typeof raw.verify === "string" ? { verify: raw.verify } : {}), + ...(typeof raw.blockedReason === "string" + ? { blockedReason: raw.blockedReason } + : {}), + ...(children !== undefined ? { children } : {}), + }; +} + +export function parsePlanDraft(raw: unknown): IPlanDraft | null { + if (!isRecord(raw)) { + return null; + } + + if (!Array.isArray(raw.items) || raw.items.length === 0) { + return null; + } + + const items: IChecklistItemDraft[] = []; + + for (const item of raw.items) { + const parsed = parseDraftItem(item); + + if (parsed === null) { + return null; + } + + items.push(parsed); + } + + return { + ...(typeof raw.goal === "string" ? { goal: raw.goal } : {}), + items, + }; +} + +export type NormalizePlanResult = + | { readonly ok: true; readonly plan: IPlanDocument } + | { readonly ok: false; readonly error: string }; + +/** + * Validate a plan draft and normalize UUIDs / pending status. Does not write disk. + */ +export function normalizePlanDraft( + draft: IPlanDraft, + fallbackGoal: string +): NormalizePlanResult { + const items: IChecklistItem[] = []; + + for (const item of draft.items) { + const normalized = normalizeItem(item); + + if (normalized === null) { + return { + ok: false, + error: "plan has an item with an empty title", + }; + } + + items.push(normalized); + } + + if (items.length === 0) { + return { ok: false, error: "plan needs a non-empty items tree" }; + } + + const goalFromDraft = draft.goal?.trim() ?? ""; + const goal = + goalFromDraft.length > 0 + ? goalFromDraft + : fallbackGoal.trim().length > 0 + ? fallbackGoal.trim() + : "goal"; + const now = new Date().toISOString(); + + return { + ok: true, + plan: { + schemaVersion: 2, + id: crypto.randomUUID(), + goal, + activeItemId: null, + updatedAt: now, + items, + }, + }; +} + +/** Normalize unknown JSON (tool args or extracted fence) into a plan document. */ +export function planDocumentFromUnknown( + raw: unknown, + fallbackGoal: string +): NormalizePlanResult { + const draft = parsePlanDraft(raw); + + if (draft === null) { + return { + ok: false, + error: + "plan invalid — need non-empty items[] with title on each node (optional detail/files/verify/children)", + }; + } + + return normalizePlanDraft(draft, fallbackGoal); +} + +/** Persist an already-normalized plan (approve path). */ +export function persistPlanDocument( + cwd: string, + plan: IPlanDocument +): IPlanDocument { + const stamped: IPlanDocument = { + ...plan, + updatedAt: new Date().toISOString(), + }; + + savePlan(cwd, stamped); + + return stamped; +} + +/** + * Extract fenced plan JSON from the last assistant message, validate, normalize + * UUIDs, write `plans/.json` + index, return the document. + * Prefer `present_plan` + pending proposal; this remains a fallback. + */ +export function seedWorklistFromPlan( + cwd: string, + assistantText: string, + fallbackGoal: string +): SeedWorklistResult { + const extracted = extractPlanJson(assistantText); + + if (extracted === null) { + return { + ok: false, + error: + "no plan yet — call present_plan with { goal, items }, or emit a fenced JSON plan, then approve", + }; + } + + const normalized = planDocumentFromUnknown(extracted, fallbackGoal); + + if (!normalized.ok) { + return normalized; + } + + return { ok: true, plan: persistPlanDocument(cwd, normalized.plan) }; +} + +/** First user message text in the session (plan intent), clipped. */ +export function goalFromMessages( + messages: readonly { role: string; content: string }[] +): string { + for (const message of messages) { + if (message.role !== "user") { + continue; + } + + let text = message.content.trim(); + + if (text.length === 0) { + continue; + } + + // PLAN_MODE_NOTE is appended to the first user send — keep the ask only. + const noteAt = text.indexOf("\n\n[PLAN MODE"); + + if (noteAt >= 0) { + text = text.slice(0, noteAt).trim(); + } + + if ( + text.length === 0 || + /^(approve|approved|go|lgtm|implement)[.!]?$/i.test(text) + ) { + continue; + } + + const line = text.split("\n")[0]?.trim() ?? text; + + return line.length > 120 ? `${line.slice(0, 117)}…` : line; + } + + return "goal"; +} diff --git a/packages/core/src/loop/worklist/worklist.types.ts b/packages/core/src/loop/worklist/worklist.types.ts new file mode 100644 index 00000000..b0c1a1a8 --- /dev/null +++ b/packages/core/src/loop/worklist/worklist.types.ts @@ -0,0 +1,25 @@ +/** + * One item from a human-written worklist (PLAN.md / TASKS.md / numbered list). + * Converted to `IFeature` before `runGreenfield` drives it. + */ +export interface IWorklistItem { + /** Stable kebab-case id (disambiguated on collision). */ + id: string; + /** The item text (checkbox body or numbered-line prose). */ + text: string; + /** True when the source checkbox was already `[x]`. */ + done: boolean; + /** Optional per-item gate command; inherits the session gate when absent. */ + accept?: string; + /** Optional editable scope for this item. */ + files?: string[]; + /** Optional extra context paths. */ + context?: string[]; + /** Optional fix hint carried into the implement prompt. */ + fix?: string; +} + +export interface IParseWorklistOptions { + /** When true, keep already-checked `[x]` items (default: drop them). */ + includeDone?: boolean; +} diff --git a/packages/core/src/policy/classify.ts b/packages/core/src/policy/classify.ts index b5beecbc..56ba05c7 100644 --- a/packages/core/src/policy/classify.ts +++ b/packages/core/src/policy/classify.ts @@ -35,6 +35,14 @@ const KIND_BY_TOOL: Readonly> = { // classified `read_file` so it's allowed in every mode (incl. plan). Absent here it // would classify `unknown` → deny before the handler runs (the check/script DOA class). [TOOL_NAME.askUser]: "read_file", + // Checklist tools mutate only `.tsforge/worklist/plans/*.json` (not source). List is a + // pure read; focus/complete/uncomplete classify like a low-risk edit so default mode + // allows them. Absent here → `unknown` → deny (same DOA class as ask_user/check). + [TOOL_NAME.taskList]: "read_file", + [TOOL_NAME.taskFocus]: "edit_file", + [TOOL_NAME.taskComplete]: "edit_file", + [TOOL_NAME.taskUncomplete]: "edit_file", + [TOOL_NAME.presentPlan]: "read_file", // `pull_conventions` is a pure read-only lookup of the injected convention library — it mutates // nothing, so it classifies `read_file` (allowed in every mode). Absent here it classified // `unknown` → deny before the handler ran, so a model's pull was silently denied in non-interactive diff --git a/packages/core/src/render/agent-rail.ts b/packages/core/src/render/agent-rail.ts index f5e73efc..1cd9f198 100644 --- a/packages/core/src/render/agent-rail.ts +++ b/packages/core/src/render/agent-rail.ts @@ -1,35 +1,119 @@ -import { displayWidth } from "./width"; +import { STYLE, paint } from "./style"; +import { displayWidth, graphemes } from "./width"; +import { stripSgr } from "./frame/ansi-plain"; /** A stateful, streaming rail-wrapper for the agent card body. `feed()` is called * per streamed chunk (tokens may split a line across calls, so the state persists - * between calls). It prefixes every visual line with the card rail and soft-wraps - * long lines at the card's inner width, so text can never spill past the rail — - * even on an auto-margin terminal or with wide chars (emoji / CJK count as 2). */ + * between calls). Soft-wraps at word boundaries when possible. Call `flush()` at + * turn end to emit a held word. When `rightRail` is set, each completed visual + * line is padded and closed so the card forms a solid box. */ export interface IAgentRail { - /** Rail-prefix + wrap one streamed chunk; returns the bytes to write now. */ feed(text: string): string; + flush(): string; +} + +/** Append an SGR/escape cluster to the in-progress hard-break head or rest. */ +function appendEscCluster( + cluster: string, + head: string, + rest: string +): { head: string; rest: string } { + if (rest.length > 0) { + return { head, rest: rest + cluster }; + } + + return { head: head + cluster, rest }; +} + +/** Hard-break `word` at `wrapAt` (grapheme-aware; SGR stays with its host). */ +function takeHardBreak( + word: string, + wordCol: number, + wrapAt: number +): { head: string; headCol: number; rest: string; restCol: number } { + let head = ""; + let headCol = 0; + let rest = ""; + let restCol = 0; + let esc = false; + + // Grapheme-aware so `🖥️` (base + VS16) is one cluster — code-point + // iteration used to mis-count width and under-pad the right │. + for (const cluster of graphemes(word)) { + if (esc) { + ({ head, rest } = appendEscCluster(cluster, head, rest)); + + if (cluster === "m") { + esc = false; + } + + continue; + } + + if (cluster === "\x1b") { + esc = true; + ({ head, rest } = appendEscCluster(cluster, head, rest)); + continue; + } + + const w = displayWidth(cluster); + + if (rest.length === 0 && headCol + w <= wrapAt) { + head += cluster; + headCol += w; + } else { + rest += cluster; + restCol += w; + } + } + + if (head.length === 0 && word.length > 0) { + const first = graphemes(word)[0] ?? ""; + const w = displayWidth(first); + + return { + head: first, + headCol: w, + rest: word.slice(first.length), + restCol: Math.max(0, wordCol - w), + }; + } + + return { head, headCol, rest, restCol }; +} + +/** Foreground used by a painted rail (`│` / `│ `). */ +function railFg(painted: string): string { + if (painted.includes(STYLE.cyan)) { + return STYLE.cyan; + } + + if (painted.includes(STYLE.plan)) { + return STYLE.plan; + } + + return STYLE.chrome; } /** - * @param rail The painted `│ ` prefix (2 visible columns). - * @param innerWidth Returns the content budget per line (columns minus the rail - * and a spare margin). A function so a mid-turn resize is picked - * up on the next chunk. + * @param rail The painted left gutter (e.g. `│ ` — 3 visible columns). + * @param innerWidth Content budget between left rail and optional right rail. + * @param rightRail Optional painted `│` closer — when set, lines are pad-closed. */ export function makeAgentRail( rail: string, - innerWidth: () => number + innerWidth: () => number, + rightRail = "" ): IAgentRail { - // `atStart`: at the beginning of a visual line (rail not yet emitted). - // `seen`: real content has arrived this turn (used to swallow the leading gap). - // `col`: visible columns used on the current line. `inEsc`: inside an ANSI SGR. let atStart = true; let seen = false; - let col = 0; + let lineCol = 0; + let word = ""; + let wordCol = 0; + let pendingSpace = false; let inEsc = false; + const emptyRowFg = railFg(rightRail.length > 0 ? rightRail : rail); - // Pass an ANSI escape byte through verbatim (escapes occupy no columns), or - // return null when `ch` is ordinary text. const passEsc = (ch: string): string | null => { if (inEsc) { if (ch === "m") { @@ -48,48 +132,181 @@ export function makeAgentRail( return null; }; + const ensureRail = (out: string): string => { + if (!atStart) { + return out; + } + + atStart = false; + seen = true; + lineCol = 0; + + return `${out}${rail}`; + }; + + const closeLine = (out: string, wrapAt: number): string => { + if (rightRail.length === 0) { + return `${out}\n`; + } + + // ensureRail + no visible glyphs → do not leave a mid-line RESET before + // the right │ (iTerm paints that rail dark/default on blank rows). + if (lineCol === 0) { + const inner = Math.max(0, displayWidth(stripSgr(rail)) - 1) + wrapAt; + let base = out; + + if (base.endsWith(rail)) { + base = base.slice(0, base.length - rail.length); + } + + return `${base}${paint(`│${" ".repeat(inner)}│`, emptyRowFg, true)}\n`; + } + + return `${out}${" ".repeat(Math.max(0, wrapAt - lineCol))}${rightRail}\n`; + }; + + const takeHard = ( + wrapAt: number + ): { head: string; headCol: number; rest: string; restCol: number } => + takeHardBreak(word, wordCol, wrapAt); + + const flushWord = (out: string, wrapAt: number): string => { + if (word.length === 0) { + return out; + } + + let result = out; + + while (wordCol > wrapAt) { + pendingSpace = false; + const { head, headCol, rest, restCol } = takeHard(wrapAt); + + result = ensureRail(result); + result += head; + lineCol = headCol; + result = closeLine(result, wrapAt); + atStart = true; + lineCol = 0; + word = rest; + wordCol = restCol; + } + + if (word.length === 0) { + return result; + } + + const spaceCols = pendingSpace && lineCol > 0 ? 1 : 0; + + if (lineCol > 0 && lineCol + spaceCols + wordCol > wrapAt) { + result = closeLine(result, wrapAt); + atStart = true; + lineCol = 0; + pendingSpace = false; + } else if (pendingSpace && lineCol > 0) { + result = ensureRail(result); + result += " "; + lineCol += 1; + pendingSpace = false; + } else { + pendingSpace = false; + } + + result = ensureRail(result); + result += word; + lineCol += wordCol; + word = ""; + wordCol = 0; + + return result; + }; + + /** Emit a closed blank card row, or fall back to open-rail close. */ + const blankClosedRow = (out: string, wrapAt: number): string => { + if (rightRail.length > 0) { + const inner = Math.max(0, displayWidth(stripSgr(rail)) - 1) + wrapAt; + + return `${out}${paint(`│${" ".repeat(inner)}│`, emptyRowFg, true)}\n`; + } + + return closeLine(ensureRail(out), wrapAt); + }; + + /** Finish the current visual line on `\n` (incl. blank closed rows). */ + const onNewline = (out: string, wrapAt: number): string => { + let result = flushWord(out, wrapAt); + + pendingSpace = false; + + if (atStart) { + if (seen) { + // Blank card row: one SGR span for `│…│`. Splitting left/right + // paints with a mid-line RESET made the right rail flash the + // default (bright) foreground on empty rows in iTerm. + result = blankClosedRow(result, wrapAt); + atStart = true; + } + } else { + result = closeLine(result, wrapAt); + atStart = true; + } + + lineCol = 0; + + return result; + }; + return { feed(text: string): string { const wrapAt = Math.max(20, innerWidth()); let out = ""; - for (const ch of text) { - const esc = passEsc(ch); + for (const cluster of graphemes(text)) { + // SGR is ASCII — walk code points so the escape state machine stays intact. + if (inEsc || cluster === "\x1b") { + for (const ch of cluster) { + const esc = passEsc(ch); - if (esc !== null) { - out += esc; + if (esc !== null) { + word += esc; + } + } continue; } - if (ch === "\n") { - if (atStart) { - if (seen) { - out += `${rail}\n`; // interior blank line keeps the rail - } - // else: swallow the leading blank (no gap under the card cap) - } else { - out += "\n"; - atStart = true; - } + if (cluster === "\n") { + out = onNewline(out, wrapAt); + continue; + } - col = 0; + if (cluster === " ") { + out = flushWord(out, wrapAt); + pendingSpace = true; continue; } - if (atStart) { - out += rail; - atStart = false; - seen = true; - col = 0; - } else if (col >= wrapAt) { - out += `\n${rail}`; // soft-wrap INSIDE the rail — text never spills out - col = 0; + word += cluster; + wordCol += displayWidth(cluster); + + if (wordCol > wrapAt) { + out = flushWord(out, wrapAt); } + } + + return out; + }, + + flush(): string { + const wrapAt = Math.max(20, innerWidth()); + let out = flushWord("", wrapAt); - out += ch; - col += displayWidth(ch); + // Only pad-close when a right rail is active; otherwise leave the open + // line as-is (no forced trailing newline — callers own line endings). + if (!atStart && rightRail.length > 0) { + out = closeLine(out, wrapAt); + atStart = true; + lineCol = 0; } return out; diff --git a/packages/core/src/render/ansi.ts b/packages/core/src/render/ansi.ts index 5974c4c8..1b3b3e91 100644 --- a/packages/core/src/render/ansi.ts +++ b/packages/core/src/render/ansi.ts @@ -1,13 +1,14 @@ import type { IRenderOptions, IStatusInfo } from "./render.types"; import type { ILoopEvent } from "../loop"; import type { IChatMessage } from "../inference"; -import { STYLE, paint } from "./style"; -import { displayWidth, padToWidth, sliceToWidth } from "./width"; +import { RESET, STYLE, paint } from "./style"; +import { displayWidth, sliceToWidth } from "./width"; import { box, GLYPH } from "./box"; import { renderMarkdown, highlightCode } from "./markdown"; import { StreamingMarkdown } from "./stream-markdown"; import { renderDiff } from "./diff"; import { makeAgentRail } from "./agent-rail"; +import { stripSgr } from "./frame/ansi-plain"; /** Split highlighted/plain text into the body-line array a box expects. */ function bodyLines(text: string): string[] { @@ -170,43 +171,195 @@ export function wrapToWidth(text: string, width: number): string[] { return out; } -/** A full rounded bubble for a USER message: `╭─ you ─╮ / │ … │ / ╰──╯`, sized to - * its content and capped at the terminal width, painted brand. */ +/** + * Shared role-card geometry — cards share the same right edge (and left rail + * column for body rows). Badge pills hug their label; hairlines fill the rest. + */ +/** Widest role pill (` AGENT `) — floors minimum card width. */ +const ROLE_BADGE_COLS = 7; +/** Spaces after the rail glyph before text (`▌ ` / `│ `) — breathing room. */ +const ROLE_INNER_PAD = 2; +/** Open-card left gutter width: glyph + inner pad. */ +const ROLE_GUTTER_COLS = 1 + ROLE_INNER_PAD; +/** Closed-card chrome: left glyph+pad + right glyph. */ +const ROLE_BOX_CHROME_COLS = ROLE_GUTTER_COLS + 1; +const ROLE_MIN_COLS = ROLE_BADGE_COLS + 2; + +/** Role pill body: one space each side — no fixed-width right pad. */ +function roleLabel(label: string): string { + return ` ${label.trim()} `; +} + +/** Resolve the shared card width for role chrome. */ +export function roleCardCols(columns?: number): number { + if (columns !== undefined && columns > 0) { + return Math.max(ROLE_MIN_COLS, columns); + } + + return Math.max( + ROLE_MIN_COLS, + process.stdout.columns > 0 ? process.stdout.columns : 80 + ); +} + +/** Filled role badge (USER cyan / AGENT chrome / PLAN amber). */ +export function filledRoleBadge( + kind: "USER" | "AGENT" | "PLAN", + color: boolean +): string { + const label = roleLabel(kind); + + if (!color) { + return label; + } + + if (kind === "USER") { + return `${STYLE.cyanBg}${STYLE.ink}${STYLE.bold}${label}${RESET}`; + } + + if (kind === "AGENT") { + return `${STYLE.chromeBg}${STYLE.chromeInk}${STYLE.bold}${label}${RESET}`; + } + + return `${STYLE.planBg}${STYLE.ink}${STYLE.bold}${label}${RESET}`; +} + +/** + * Hairline from the badge to the shared right edge. + * Optional `endCap` (e.g. `┐`) closes an AGENT top rule without changing width. + * `badgeCols` must match the visible width of the badge that precedes the line + * so shorter pills (` USER ` / ` PLAN `) still land on the same right edge. + */ +export function roleHairline( + columns: number, + code: string, + color: boolean, + endCap = "", + badgeCols: number = ROLE_BADGE_COLS +): string { + const capCols = endCap.length > 0 ? displayWidth(endCap) : 0; + const n = Math.max(1, columns - badgeCols - capCols); + const line = paint("─".repeat(n), code, color); + + return endCap.length > 0 ? `${line}${paint(endCap, code, color)}` : line; +} + +/** Visible columns of a filled/plain role badge (ANSI stripped). */ +export function roleBadgeCols(badge: string): number { + return displayWidth(stripSgr(badge)); +} + +/** Open-card left gutter (`▌ ` / `│ `). */ +function roleGutter(glyph: "▌" | "│", code: string, color: boolean): string { + return paint(glyph, code, color) + " ".repeat(ROLE_INNER_PAD); +} + +/** Empty closed USER row — cyan twin of {@link agentCardPadRow}. */ +function userCardPadRow(color: boolean, columns: number): string { + const cols = roleCardCols(columns); + const inner = Math.max(1, cols - 2); + + // One SGR span for the whole row — a mid-line RESET left the right │ on the + // default (bright) foreground in iTerm, so empty rows looked speckled. + return paint(`│${" ".repeat(inner)}│`, STYLE.cyan, color); +} + +/** A USER turn: closed cyan card — same geometry as AGENT (`┐` / `│…│` / `└┘`). */ export function userBubble( content: string, color: boolean, columns: number ): string { - const label = "you"; - const maxInner = Math.max(label.length + 4, columns - 2); - const body = wrapToWidth(content, Math.max(1, maxInner - 2)); - const widest = body.reduce((m, l) => Math.max(m, displayWidth(l)), 0); - const inner = Math.min(maxInner, Math.max(label.length + 4, widest + 2)); - const fill = "─".repeat(Math.max(0, inner - label.length - 3)); - const top = paint(`╭─ ${label} ${fill}╮`, STYLE.brand + STYLE.bold, color); - const bottom = paint(`╰${"─".repeat(inner)}╯`, STYLE.brand, color); - const side = paint("│", STYLE.brand, color); - const rows = body.map( - (line) => - `${side} ${paint(padToWidth(line, inner - 2), STYLE.brand + STYLE.bold, color)} ${side}` + const cols = roleCardCols(columns); + const badge = filledRoleBadge("USER", color); + const top = + badge + roleHairline(cols, STYLE.cyan, color, "┐", roleBadgeCols(badge)); + const rail = makeAgentRail( + roleGutter("│", STYLE.cyan, color), + () => Math.max(1, cols - ROLE_BOX_CHROME_COLS), + paint("│", STYLE.cyan, color) + ); + const painted = content + .split("\n") + .map((line) => paint(line, STYLE.cyan + STYLE.bold, color)) + .join("\n"); + const body = `${rail.feed(painted)}${rail.flush()}`.replace(/\n$/, ""); + const padRow = userCardPadRow(color, cols); + const bottom = paint( + `└${"─".repeat(Math.max(0, cols - 2))}┘`, + STYLE.cyan, + color ); - return [top, ...rows, bottom].join("\n"); + return [top, padRow, body, padRow, bottom].join("\n"); } -/** The rounded top cap + model label for an AGENT card (streams below it). */ -export function agentCardTop(model: string, color: boolean): string { - return paint(`╭ ${model}`, STYLE.brandLight + STYLE.bold, color); +/** One closed agent row: `│ content… │` padded to `cols`. */ +export function agentCardRow( + content: string, + color: boolean, + columns: number +): string { + const cols = roleCardCols(columns); + const inner = Math.max(1, cols - 2); + + if (stripSgr(content).length === 0) { + return paint(`│${" ".repeat(inner)}│`, STYLE.chrome, color); + } + + const left = paint("│", STYLE.chrome, color); + const right = paint("│", STYLE.chrome, color); + const maxText = Math.max(1, inner - ROLE_INNER_PAD * 2); + const plain = stripSgr(content); + const text = + displayWidth(plain) <= maxText + ? content + : sliceToWidth(plain, maxText).text; + const body = `${" ".repeat(ROLE_INNER_PAD)}${text}`; + const pad = Math.max(ROLE_INNER_PAD, inner - displayWidth(stripSgr(body))); + + return `${left}${body}${" ".repeat(pad)}${right}`; } -/** The rounded bottom cap that closes an AGENT card. */ -export function agentCardBottom(color: boolean): string { - return paint("╰", STYLE.brandLight, color); +/** Empty closed row — vertical breathing room under the top rule / above the bottom. */ +export function agentCardPadRow(color: boolean, columns?: number): string { + const cols = roleCardCols(columns); + const inner = Math.max(1, cols - 2); + + return paint(`│${" ".repeat(inner)}│`, STYLE.chrome, color); +} + +/** Closed AGENT card top (filled badge + hairline + `┐`). Model lives in the top bar. */ +export function agentCardTop(color: boolean, columns?: number): string { + const cols = roleCardCols(columns); + const badge = filledRoleBadge("AGENT", color); + + // No leading `┌` — badge starts on the same column as USER / PLAN. + return ( + badge + roleHairline(cols, STYLE.chrome, color, "┐", roleBadgeCols(badge)) + ); +} + +/** Closed AGENT card bottom rule. */ +export function agentCardBottom(color: boolean, columns?: number): string { + const cols = roleCardCols(columns); + + return paint(`└${"─".repeat(Math.max(0, cols - 2))}┘`, STYLE.chrome, color); } -/** The left-rail prefix (`│ `) painted for every row inside an AGENT card. */ +/** The left-rail prefix (`│ `) painted for every row inside an AGENT card. */ export function agentBar(color: boolean): string { - return `${paint("│", STYLE.brandLight, color)} `; + return roleGutter("│", STYLE.chrome, color); +} + +/** The right-rail closer (`│`) for a closed agent card. */ +export function agentRight(color: boolean): string { + return paint("│", STYLE.chrome, color); +} + +/** Content budget inside `│ … │` (left gutter + right rail). */ +export function agentRailInnerCols(columns: number): number { + return Math.max(20, roleCardCols(columns) - ROLE_BOX_CHROME_COLS); } /** Rail-prefix AND soft-wrap a settled agent body (the `--continue` replay @@ -217,10 +370,16 @@ export function agentCardBody( color: boolean, columns?: number ): string { - const cols = columns !== undefined && columns > 0 ? columns : 80; - const rail = makeAgentRail(agentBar(color), () => cols - 4); + const cols = roleCardCols(columns); + const rail = makeAgentRail( + agentBar(color), + () => agentRailInnerCols(cols), + agentRight(color) + ); - return rail.feed(text); + // Trim the rail's trailing newline so joiners (`body\n` + pad) cannot inject + // a railless blank row into scrollback. + return `${rail.feed(text)}${rail.flush()}`.replace(/\n$/, ""); } export function renderMessage( @@ -252,11 +411,15 @@ export function renderMessage( parts.push(paint(`· used ${names}`, STYLE.dim, color)); } - // A left-accent card (rounded caps + rail), streaming-friendly. + // Closed card: top + pad, railed body, pad + bottom (model lives in the top bar). + const columns = opts.columns ?? process.stdout.columns; + return parts.length > 0 - ? `\n${agentCardTop(opts.speaker ?? "assistant", color)}\n` + - `${agentCardBody(parts.join("\n"), color, opts.columns ?? process.stdout.columns)}\n` + - `${agentCardBottom(color)}\n` + ? `\n${agentCardTop(color, columns)}\n` + + `${agentCardPadRow(color, columns)}\n` + + `${agentCardBody(parts.join("\n"), color, columns)}\n` + + `${agentCardPadRow(color, columns)}\n` + + `${agentCardBottom(color, columns)}\n` : ""; } diff --git a/packages/core/src/render/command-menu.ts b/packages/core/src/render/command-menu.ts index b5e2349c..fe77d255 100644 --- a/packages/core/src/render/command-menu.ts +++ b/packages/core/src/render/command-menu.ts @@ -46,6 +46,10 @@ interface IKeyInfo { export interface IPaletteView { render(lines: readonly string[]): void; close(): void; + /** Overlay width. Prefer main-pane inner cols when the pane console is live. */ + readonly columns?: number; + /** Max overlay rows. Prefer pane chrome budget when panes are live. */ + readonly viewportRows?: number; } /** @@ -79,8 +83,18 @@ export function pickCommand(view: IPaletteView): Promise { selected = clampIndex(selected, items.length); - const columns = process.stdout.columns > 0 ? process.stdout.columns : 80; - const viewportRows = process.stdout.rows > 0 ? process.stdout.rows : 24; + const columns = + view.columns !== undefined && view.columns > 0 + ? view.columns + : process.stdout.columns > 0 + ? process.stdout.columns + : 80; + const viewportRows = + view.viewportRows !== undefined && view.viewportRows > 0 + ? view.viewportRows + : process.stdout.rows > 0 + ? process.stdout.rows + : 24; // The live query IS the title (e.g. "/co"), so it shows via the overlay even // while the editor is suspended (setInput wouldn't repaint in editor mode). const title = query.length > 0 ? `/${query}` : "commands"; diff --git a/packages/core/src/render/file-menu.ts b/packages/core/src/render/file-menu.ts index 4cf632aa..261b4dcb 100644 --- a/packages/core/src/render/file-menu.ts +++ b/packages/core/src/render/file-menu.ts @@ -1,6 +1,7 @@ import { emitKeypressEvents } from "node:readline"; import { STYLE, paint } from "./style"; import { clampIndex } from "./command-menu"; +import { formatMenuRow } from "./menu-chrome"; import { displayWidth, graphemes } from "./width"; /** Rows shown in the popup at once — a tight dropdown above the prompt, never a @@ -83,9 +84,10 @@ export function truncatePath(path: string, max: number): string { /** * The popup rows for the inline file dropdown — one painted line per visible file, - * each truncated to `columns` (no wrapping), the selected row gutter-highlighted. - * Pure/width-aware so it can be asserted without a terminal. Empty list ⇒ a single - * "no matching file" row so the dropdown never silently vanishes mid-type. + * each truncated to `columns` (no wrapping), the selected row gutter-highlighted + * with the shared menu dialect (`▸` + CONSOLE.bright). Pure/width-aware so it can + * be asserted without a terminal. Empty list ⇒ a single "no matching file" row so + * the dropdown never silently vanishes mid-type. */ export function formatCompletionRows( items: readonly string[], @@ -99,10 +101,19 @@ export function formatCompletionRows( return items.map((path, i) => { const active = i === selected; - const gutter = active ? paint("›", STYLE.brand, color) : " "; const text = truncatePath(path, Math.max(0, columns - 2)); - return `${gutter} ${paint(text, active ? STYLE.brand : STYLE.dim, color)}`; + if (active) { + return formatMenuRow({ + label: text, + active: true, + columns, + color, + }); + } + + // Inactive: shared gutter width, dim path (quiet under the input). + return ` ${paint(text, STYLE.dim, color)}`; }); } diff --git a/packages/core/src/render/frame/ansi-plain.ts b/packages/core/src/render/frame/ansi-plain.ts new file mode 100644 index 00000000..c535e054 --- /dev/null +++ b/packages/core/src/render/frame/ansi-plain.ts @@ -0,0 +1,60 @@ +/** Strip SGR color codes so cell-grid paint never treats escapes as glyphs. */ +export function stripSgr(text: string): string { + const esc = String.fromCharCode(27); + + return text.replace(new RegExp(`${esc}\\[[0-9;]*m`, "gu"), ""); +} + +/** + * Drop SGR mouse-report sequences (`CSI < btn ; x ; y M/m`). Used when mouse + * tracking is on (or leftover from a prior session) so clicks don't insert + * garbage into the editor buffer. + */ +export function stripMouseReports(text: string): string { + const esc = String.fromCharCode(27); + + return text.replace(new RegExp(`${esc}\\[<\\d+;\\d+;\\d+[Mm]`, "gu"), ""); +} + +/** One SGR mouse report (`CSI < btn ; col ; row M|m`), 1-based col/row. */ +export interface IMouseReport { + readonly button: number; + readonly col: number; + readonly row: number; + readonly release: boolean; +} + +/** Parse a single SGR mouse report; null if `seq` is not exactly one. */ +export function parseMouseReport(seq: string): IMouseReport | null { + const esc = String.fromCharCode(27); + const m = new RegExp(`^${esc}\\[<(\\d+);(\\d+);(\\d+)([Mm])$`, "u").exec(seq); + + if (m === null) { + return null; + } + + return { + button: Number(m[1]), + col: Number(m[2]), + row: Number(m[3]), + release: m[4] === "m", + }; +} + +/** Extract every SGR mouse report from a chunk (order preserved). */ +export function extractMouseReports(text: string): IMouseReport[] { + const esc = String.fromCharCode(27); + const re = new RegExp(`${esc}\\[<(\\d+);(\\d+);(\\d+)([Mm])`, "gu"); + const out: IMouseReport[] = []; + + for (const m of text.matchAll(re)) { + out.push({ + button: Number(m[1]), + col: Number(m[2]), + row: Number(m[3]), + release: m[4] === "m", + }); + } + + return out; +} diff --git a/packages/core/src/render/frame/chrome.ts b/packages/core/src/render/frame/chrome.ts new file mode 100644 index 00000000..c466833d --- /dev/null +++ b/packages/core/src/render/frame/chrome.ts @@ -0,0 +1,426 @@ +import type { IStatusInfo } from "../render.types"; +import { RESET, STYLE, paint, truecolor, truecolorBg } from "../style"; +import { displayWidth, sliceToWidth } from "../width"; +import { stripSgr } from "./ansi-plain"; +import type { ActiveSurface } from "./focus"; + +/** Console palette — Lovable agent-console greens / meta / warns. */ +export const CONSOLE = { + green: STYLE.green, + bright: truecolor(74, 222, 128), + muted: STYLE.dim, + /** Match STYLE.chrome — greenish rule made pane gutters disagree with AGENT/input. */ + rule: STYLE.chrome, + warn: STYLE.yellow, + fail: STYLE.red, + meta: truecolor(125, 211, 252), + accent: truecolor(251, 191, 36), + /** Opaque canvas (#141414) — one surface for the whole frame. */ + bg: truecolorBg(20, 20, 20), +} as const; + +/** + * Horizontal breathing room — enough inset to breathe, not a floating island. + * Hairlines stay full-bleed; text does not. + */ +export const CHROME_PAD_X = 3; +/** + * Vertical air inside the outer frame (above the title / under the hairline). + * Keep this small — matching {@link CHROME_PAD_X} in *rows* floated the chrome. + */ +export const CHROME_PAD_Y = 1; + +/** + * Fit a content line into `cols` with left/right inset. + * Prefer this over painting flush against the frame edge. + * Always returns exactly `cols` display columns (hard clamp) so pane gutters + * cannot be overwritten by wide/emoji/ANSI content. + */ +export function insetX( + line: string, + cols: number, + pad: number = CHROME_PAD_X +): string { + if (cols <= 0) { + return ""; + } + + const p = Math.max(0, Math.min(pad, Math.floor(Math.max(0, cols - 1) / 2))); + + if (p === 0) { + return exactCols(line, cols); + } + + const inner = cols - 2 * p; + const fitted = exactCols(line, inner); + + return exactCols(`${" ".repeat(p)}${fitted}${" ".repeat(p)}`, cols); +} + +/** Inner width available after left/right inset. */ +export function insetInnerCols( + cols: number, + pad: number = CHROME_PAD_X +): number { + const p = Math.max(0, Math.min(pad, Math.floor(Math.max(0, cols - 1) / 2))); + + return Math.max(0, cols - 2 * p); +} + +export interface ITopStatusOpts { + readonly info: IStatusInfo; + readonly worklistBadge?: string; + readonly cols: number; + readonly color?: boolean; +} + +/** Legacy one-liner — prefer {@link formatConsoleTitle}. */ +export function formatTopStatus(opts: ITopStatusOpts): string { + return formatConsoleTitle({ + info: opts.info, + cwd: "", + worklistBadge: opts.worklistBadge, + cols: opts.cols, + color: opts.color, + }); +} + +export interface IConsoleTitleOpts { + readonly info: IStatusInfo | null; + readonly cwd: string; + readonly sessionId?: string; + readonly worklistBadge?: string; + readonly cols: number; + readonly color?: boolean; +} + +/** + * Single dense status strip — one place for session chrome (no input cutout): + * left → brand · path · scope (where) + * right → model · % · [PLAN]/[NORMAL] · ✓ · Nt · #n/m (live) + */ +export function formatConsoleTitle(opts: IConsoleTitleOpts): string { + const color = opts.color ?? true; + const sep = paint(" · ", CONSOLE.muted, color); + const leftBits: string[] = [paint("▚ TSFORGE", CONSOLE.bright, color)]; + const rightBits: string[] = []; + + if (opts.cwd.length > 0) { + leftBits.push(paint(shortPath(opts.cwd), CONSOLE.green, color)); + } + + if (opts.info !== null && opts.info.scope.length > 0) { + leftBits.push(paint(opts.info.scope, CONSOLE.muted, color)); + } + + if (opts.info !== null) { + rightBits.push(paint(shortModel(opts.info.model), CONSOLE.meta, color)); + + if (opts.info.contextWindow > 0) { + rightBits.push( + paint(`${String(ctxPct(opts.info))}%`, CONSOLE.muted, color) + ); + } + + if (opts.info.mode !== undefined && opts.info.mode.length > 0) { + rightBits.push(modeChip(opts.info.mode, color)); + } + + if (opts.info.activity !== undefined && opts.info.activity.length > 0) { + rightBits.push(paint(opts.info.activity, CONSOLE.muted, color)); + } else { + rightBits.push(statusChip(opts.info.status, color)); + } + + if ( + opts.info.tokensPerSecond !== undefined && + opts.info.tokensPerSecond > 0 + ) { + rightBits.push( + paint(`${String(opts.info.tokensPerSecond)}t`, CONSOLE.muted, color) + ); + } + } + + const badge = opts.worklistBadge?.trim() ?? ""; + + if (badge.length > 0) { + const bare = badge.startsWith("#") ? badge : `#${badge}`; + + rightBits.push(paint(bare, CONSOLE.accent, color)); + } + + return insetX( + splitBar( + leftBits.join(sep), + rightBits.join(sep), + insetInnerCols(opts.cols) + ), + opts.cols + ); +} + +/** + * Pinned top chrome: air, title, air, hairline (┬ starts the panel gutter). + */ +export function formatConsoleTopbar(opts: { + readonly info: IStatusInfo | null; + readonly cwd: string; + readonly sessionId?: string; + readonly worklistBadge?: string; + readonly cols: number; + readonly color?: boolean; + readonly splitCol?: number; + /** When false, omit title air (short terminals). Default true. */ + readonly padTop?: boolean; +}): string[] { + const lines: string[] = []; + const padded = opts.padTop !== false; + + if (padded) { + for (let i = 0; i < CHROME_PAD_Y; i += 1) { + lines.push(""); + } + } + + lines.push(formatConsoleTitle(opts)); + + if (padded) { + for (let i = 0; i < CHROME_PAD_Y; i += 1) { + lines.push(""); + } + } + + lines.push( + hairline(opts.cols, "─", { + splitCol: opts.splitCol, + junction: "┬", + color: opts.color, + }) + ); + + return lines; +} + +export interface IMainHeaderOpts { + readonly info: IStatusInfo | null; + readonly cwd: string; + readonly cols: number; + readonly color?: boolean; + readonly streaming?: boolean; +} + +/** Folded into {@link formatConsoleTitle}; kept for older call sites. */ +export function formatMainHeader(opts: IMainHeaderOpts): string { + return formatConsoleTitle({ + info: opts.info, + cwd: opts.cwd, + cols: opts.cols, + color: opts.color, + }); +} + +export interface IRailHeaderOpts { + readonly done: number; + readonly total: number; + readonly cols: number; + readonly color?: boolean; + /** Left label — default `Tasks`. */ + readonly title?: string; +} + +/** Sticky rail title rows: label row + under-rule (borders the title cell). */ +export const RAIL_TITLE_ROWS = 2; + +/** + * Side-rail title: muted label left, cyan `done/total` right. + * Lives under the ┬ hairline; pair with {@link formatRailTitleRule} for the + * bottom border so the cell reads like the screenshot title bar. + */ +export function formatRailHeader(opts: IRailHeaderOpts): string { + const color = opts.color ?? true; + const trimmed = (opts.title ?? "Tasks").trim(); + const label = trimmed.length > 0 ? trimmed : "Tasks"; + const left = paint(label, CONSOLE.muted, color); + const countText = `${String(Math.max(0, opts.done))}/${String(Math.max(0, opts.total))}`; + const right = + opts.total > 0 + ? paint(countText, STYLE.cyan, color) + : paint(countText, CONSOLE.muted, color); + + return insetX(splitBar(left, right, insetInnerCols(opts.cols)), opts.cols); +} + +/** Full-width under-rule for the rail title cell (panel column only). */ +export function formatRailTitleRule(cols: number, color = true): string { + return paint("─".repeat(Math.max(0, cols)), CONSOLE.rule, color); +} + +/** Sticky title block: `[header, under-rule]`. */ +export function formatRailTitleBlock(opts: IRailHeaderOpts): string[] { + return [formatRailHeader(opts), formatRailTitleRule(opts.cols, opts.color)]; +} + +/** Full-width hairline, optionally with a junction glyph at `splitCol` (0-based). */ +export function hairline( + cols: number, + fill = "─", + opts?: { splitCol?: number; junction?: string; color?: boolean } +): string { + const color = opts?.color ?? true; + const n = Math.max(0, cols); + + if (n === 0) { + return ""; + } + + const splitCol = opts?.splitCol; + const junction = opts?.junction ?? "┼"; + + if (splitCol === undefined || splitCol < 0 || splitCol >= n) { + return paint(fill.repeat(n), CONSOLE.rule, color); + } + + const left = fill.repeat(splitCol); + const right = fill.repeat(Math.max(0, n - splitCol - 1)); + + return paint(`${left}${junction}${right}`, CONSOLE.rule, color); +} + +export type HintFocus = ActiveSurface; + +/** Dim hint strings — unused in the minimal chrome (kept for focus tests). */ +export function formatHints(focus: HintFocus, busy: boolean): string { + if (busy) { + return "Ctrl+C abort · Ctrl+O dump"; + } + + if (focus === "panel") { + return "↑↓ select · Esc prompt · Ctrl+G toggle"; + } + + if (focus === "scrollback") { + return "↑↓ scroll · Esc prompt · Ctrl+O dump"; + } + + return "Shift+Tab mode · @ files · /help · Ctrl+G panel"; +} + +function statusChip(status: string, color: boolean): string { + if (status === "ready" || status === "done" || status === "responded") { + return paint("✓", CONSOLE.bright, color); + } + + if (status === "stuck") { + return paint("✗", CONSOLE.fail, color); + } + + return paint("●", CONSOLE.warn, color); +} + +/** + * Compact filled mode pill — plan uses the amber plan accent; normal stays quiet + * chrome. Same language as the PLAN role badge, sized for the top strip. + */ +function modeChip(mode: string, color: boolean): string { + const id = mode.trim().toLowerCase(); + const label = ` ${id.length > 0 ? id.toUpperCase() : "MODE"} `; + + if (!color) { + return label.trim(); + } + + if (id === "plan") { + return `${STYLE.planBg}${STYLE.ink}${STYLE.bold}${label}${RESET}`; + } + + return `${STYLE.chromeBg}${STYLE.chromeInk}${label}${RESET}`; +} + +function ctxPct(info: IStatusInfo): number { + return Math.round((info.contextTokens / info.contextWindow) * 100); +} + +function shortModel(model: string): string { + if (model.length <= 22) { + return model; + } + + return `${model.slice(0, 20)}…`; +} + +function shortPath(cwd: string): string { + const home = process.env.HOME; + const raw = + home !== undefined && home.length > 0 && cwd.startsWith(home) + ? `~${cwd.slice(home.length)}` + : cwd; + + if (raw.length <= 28) { + return raw; + } + + const parts = raw.split("/").filter((p) => p.length > 0); + + if (parts.length <= 2) { + return `${raw.slice(0, 25)}…`; + } + + return `…/${parts[parts.length - 1] ?? ""}`; +} + +function splitBar(left: string, right: string, cols: number): string { + const gap = 2; + const rightPlain = stripSgr(right); + const rightW = displayWidth(rightPlain); + + if (rightW === 0) { + return padOrSlice(left, cols); + } + + if (rightW + gap >= cols) { + return padOrSlice(right, cols); + } + + const leftBudget = cols - rightW - gap; + const leftFitted = padOrSlice(left, leftBudget); + const leftW = displayWidth(stripSgr(leftFitted)); + const pad = Math.max(gap, cols - leftW - rightW); + + return `${leftFitted}${" ".repeat(pad)}${right}`; +} + +function padOrSlice(line: string, cols: number): string { + const plain = stripSgr(line); + const width = displayWidth(plain); + + if (width === cols) { + return line; + } + + if (width < cols) { + return `${line}${" ".repeat(cols - width)}`; + } + + return sliceToWidth(plain, cols).text; +} + +/** Pad or truncate so the visible width is exactly `cols`. */ +function exactCols(line: string, cols: number): string { + if (cols <= 0) { + return ""; + } + + const fitted = padOrSlice(line, cols); + const width = displayWidth(stripSgr(fitted)); + + if (width === cols) { + return fitted; + } + + if (width < cols) { + return `${fitted}${" ".repeat(cols - width)}`; + } + + return sliceToWidth(stripSgr(fitted), cols).text; +} diff --git a/packages/core/src/render/frame/codes.ts b/packages/core/src/render/frame/codes.ts new file mode 100644 index 00000000..d53ed163 --- /dev/null +++ b/packages/core/src/render/frame/codes.ts @@ -0,0 +1,48 @@ +const ESC = "\x1b"; + +/** Home + erase entire display (used on resize so shrunk geometry leaves no ghosts). */ +export const CLEAR_SCREEN = `${ESC}[H${ESC}[2J`; + +/** Enter the alternate screen buffer and clear the scroll region. */ +export const ENTER_ALT = `${ESC}[?1049h${CLEAR_SCREEN}`; + +/** Leave the alternate screen buffer. */ +export const EXIT_ALT = `${ESC}[?1049l`; + +/** + * Enable SGR mouse reporting (wheel + clicks as CSI sequences). + * Required so the host terminal does not scroll its own buffer — wheel events + * come to us and we scroll only the main/panel viewports. + */ +export const ENABLE_MOUSE = `${ESC}[?1000h${ESC}[?1006h`; + +/** Disable SGR mouse reporting. */ +export const DISABLE_MOUSE = `${ESC}[?1006l${ESC}[?1000l`; + +/** Hide / show the cursor. */ +export const HIDE_CURSOR = `${ESC}[?25l`; +export const SHOW_CURSOR = `${ESC}[?25h`; + +/** Blinking block cursor (DECSCUSR). */ +export const CURSOR_BLINK_BLOCK = `${ESC}[1 q`; + +/** Restore default cursor shape. */ +export const CURSOR_SHAPE_DEFAULT = `${ESC}[0 q`; + +/** Green caret — matches console bright green (#4ade80). */ +export const CURSOR_COLOR_GREEN = `${ESC}]12;#4ade80\x07`; + +/** Restore default cursor color. */ +export const CURSOR_COLOR_DEFAULT = `${ESC}]112\x07`; + +/** Move cursor to 1-based (row, col). */ +export function cup(row: number, col: number): string { + return `${ESC}[${row};${col}H`; +} + +/** Clear from cursor to end of line. */ +export const EL_EOL = `${ESC}[K`; + +/** Begin / end synchronized update (CSI ?2026) — tear-free frame commits. */ +export const BEGIN_SYNC = `${ESC}[?2026h`; +export const END_SYNC = `${ESC}[?2026l`; diff --git a/packages/core/src/render/frame/cursor-state.ts b/packages/core/src/render/frame/cursor-state.ts new file mode 100644 index 00000000..b254a4d8 --- /dev/null +++ b/packages/core/src/render/frame/cursor-state.ts @@ -0,0 +1,30 @@ +import { SHOW_CURSOR, cup } from "./codes"; + +/** + * Dedupes cursor Show/MoveTo so idle/status ticks don't thrash blink + * (Grok `CursorState` idea). + */ +export class CursorState { + private row = 0; + private col = 0; + private placed = false; + + /** Bytes to place the cursor, or `""` when already there. */ + move(row: number, col: number): string { + if (this.placed && this.row === row && this.col === col) { + return ""; + } + + this.row = row; + this.col = col; + this.placed = true; + + return SHOW_CURSOR + cup(row, col); + } + + reset(): void { + this.placed = false; + this.row = 0; + this.col = 0; + } +} diff --git a/packages/core/src/render/frame/fit-line.ts b/packages/core/src/render/frame/fit-line.ts new file mode 100644 index 00000000..43f42698 --- /dev/null +++ b/packages/core/src/render/frame/fit-line.ts @@ -0,0 +1,23 @@ +import { displayWidth, sliceToWidth } from "../width"; +import { RESET } from "../style"; +import { stripSgr } from "./ansi-plain"; + +/** + * Fit a (possibly ANSI-styled) line into `cols` terminal columns. + * Preserves SGR when the visible width already fits; truncates to plain text + * when it doesn't (cutting mid-SGR is worse than dropping color on overflow). + */ +export function fitAnsiLine(line: string, cols: number): string { + if (cols <= 0) { + return ""; + } + + const plain = stripSgr(line); + const width = displayWidth(plain); + + if (width <= cols) { + return `${line}${RESET}${" ".repeat(cols - width)}`; + } + + return `${sliceToWidth(plain, cols).text}${RESET}`; +} diff --git a/packages/core/src/render/frame/focus.ts b/packages/core/src/render/frame/focus.ts new file mode 100644 index 00000000..290e6fb5 --- /dev/null +++ b/packages/core/src/render/frame/focus.ts @@ -0,0 +1,156 @@ +/** + * Panel visibility/focus state machine (Grok OverlayState, no fullscreen). + * + * hidden ──toggle──► visibleFocused ──toggle──► visibleUnfocused + * ▲ Esc/Tab │ toggle + * └──────── prompt ◄───────────┘ + */ + +export type PanelVis = "hidden" | "visibleUnfocused" | "visibleFocused"; +export type ActiveSurface = "prompt" | "scrollback" | "panel"; + +export type FocusAction = "changed" | "ignored"; + +export class PaneFocus { + panel: PanelVis = "hidden"; + active: ActiveSurface = "prompt"; + /** Selected row index within the worklist body (0 = first item; title is sticky). */ + selection = 0; + + get promptFocused(): boolean { + return this.active === "prompt"; + } + + get panelFocused(): boolean { + return this.panel === "visibleFocused" && this.active === "panel"; + } + + /** Sync visibility when worklist content appears/disappears. */ + syncHasItems(hasItems: boolean): void { + if (hasItems && this.panel === "hidden") { + this.panel = "visibleUnfocused"; + } + + if (!hasItems && this.panel !== "hidden") { + this.panel = "hidden"; + + if (this.active === "panel") { + this.active = "prompt"; + } + } + } + + focusPrompt(): FocusAction { + if (this.active === "prompt" && this.panel !== "visibleFocused") { + return "ignored"; + } + + if (this.panel === "visibleFocused") { + this.panel = "visibleUnfocused"; + } + + this.active = "prompt"; + + return "changed"; + } + + focusScrollback(): FocusAction { + this.active = "scrollback"; + + if (this.panel === "visibleFocused") { + this.panel = "visibleUnfocused"; + } + + return "changed"; + } + + /** + * Ctrl+G: with items, cycle unfocused ↔ focused; without items, toggle hidden. + * Focusing the panel also sets active = panel. + */ + togglePanel(hasItems: boolean): FocusAction { + if (!hasItems) { + if (this.panel === "hidden") { + this.panel = "visibleUnfocused"; + this.active = "prompt"; + + return "changed"; + } + + this.panel = "hidden"; + this.active = "prompt"; + + return "changed"; + } + + if (this.panel === "hidden") { + this.panel = "visibleFocused"; + this.active = "panel"; + + return "changed"; + } + + if (this.panel === "visibleUnfocused") { + this.panel = "visibleFocused"; + this.active = "panel"; + + return "changed"; + } + + // visibleFocused → unfocused, return to prompt + this.panel = "visibleUnfocused"; + this.active = "prompt"; + + return "changed"; + } + + /** Esc: panel → prompt; scrollback → prompt. */ + escape(): FocusAction { + if (this.active === "panel" || this.panel === "visibleFocused") { + this.panel = this.panel === "hidden" ? "hidden" : "visibleUnfocused"; + this.active = "prompt"; + + return "changed"; + } + + if (this.active === "scrollback") { + this.active = "prompt"; + + return "changed"; + } + + return "ignored"; + } + + /** Tab: panel → prompt; prompt + visible panel → panel. */ + tab(hasItems: boolean): FocusAction { + if (this.active === "panel") { + return this.focusPrompt(); + } + + if (this.active === "prompt" && hasItems && this.panel !== "hidden") { + this.panel = "visibleFocused"; + this.active = "panel"; + + return "changed"; + } + + return "ignored"; + } + + moveSelection(delta: number, maxIndex: number): FocusAction { + if (!this.panelFocused || maxIndex < 0) { + return "ignored"; + } + + const next = Math.max(0, Math.min(maxIndex, this.selection + delta)); + + if (next === this.selection) { + return "ignored"; + } + + this.selection = next; + + return "changed"; + } +} diff --git a/packages/core/src/render/frame/frame.types.ts b/packages/core/src/render/frame/frame.types.ts new file mode 100644 index 00000000..116c8069 --- /dev/null +++ b/packages/core/src/render/frame/frame.types.ts @@ -0,0 +1,33 @@ +/** A single terminal cell (plain text for v1 — no per-cell SGR). */ +export interface ICell { + readonly ch: string; +} + +/** One painted frame: row-major cells sized to the terminal. */ +export interface IFrame { + readonly rows: number; + readonly cols: number; + readonly cells: readonly (readonly ICell[])[]; +} + +export interface ILayoutRects { + /** Pinned console topbar (2 rows when the terminal is tall enough). */ + readonly top: { row: number; col: number; rows: number; cols: number }; + readonly main: { row: number; col: number; rows: number; cols: number }; + readonly panel: { + row: number; + col: number; + rows: number; + cols: number; + } | null; + readonly input: { row: number; col: number; rows: number; cols: number }; + /** Metrics footer under the input box. */ + readonly footer: { row: number; col: number; rows: number; cols: number }; + readonly collapsedPanel: boolean; +} + +export interface IPaneInput { + readonly lines: readonly string[]; + readonly cursorRow: number; + readonly cursorCol: number; +} diff --git a/packages/core/src/render/frame/grid.ts b/packages/core/src/render/frame/grid.ts new file mode 100644 index 00000000..723b7890 --- /dev/null +++ b/packages/core/src/render/frame/grid.ts @@ -0,0 +1,115 @@ +import type { ICell, IFrame } from "./frame.types"; +import { cup, EL_EOL } from "./codes"; + +const SPACE: ICell = { ch: " " }; + +/** Allocate a blank frame filled with spaces. */ +export function blankFrame(rows: number, cols: number): IFrame { + const cells: ICell[][] = []; + + for (let r = 0; r < rows; r += 1) { + const row: ICell[] = []; + + for (let c = 0; c < cols; c += 1) { + row.push(SPACE); + } + + cells.push(row); + } + + return { rows, cols, cells }; +} + +/** Clone a frame into a mutable grid for painting. */ +export function cloneFrame(frame: IFrame): ICell[][] { + return frame.cells.map((row) => row.map((cell) => ({ ch: cell.ch }))); +} + +/** + * Write plain lines into a rectangular region of a mutable grid. Lines are + * clipped to the rect; shorter lines are space-padded by leaving prior cells. + */ +export function writeRect( + grid: ICell[][], + rect: { row: number; col: number; rows: number; cols: number }, + lines: readonly string[] +): void { + for (let r = 0; r < rect.rows; r += 1) { + const targetRow = rect.row + r; + const row = grid[targetRow]; + + if (row === undefined) { + continue; + } + + const text = lines[r] ?? ""; + + for (let c = 0; c < rect.cols; c += 1) { + const targetCol = rect.col + c; + + if (targetCol >= row.length) { + break; + } + + row[targetCol] = { ch: text[c] ?? " " }; + } + } +} + +/** Freeze a mutable grid into an IFrame. */ +export function freezeFrame( + grid: readonly (readonly ICell[])[], + rows: number, + cols: number +): IFrame { + return { + rows, + cols, + cells: grid.map((row) => row.map((cell) => ({ ch: cell.ch }))), + }; +} + +function sameSize(prev: IFrame | null, next: IFrame): prev is IFrame { + return prev !== null && prev.rows === next.rows && prev.cols === next.cols; +} + +/** + * Diff `next` against `prev` into minimal CUP + line writes. When sizes differ, + * redraw the whole screen from the home position. + */ +export function diffFrames(prev: IFrame | null, next: IFrame): string { + if (!sameSize(prev, next)) { + let out = cup(1, 1); + + for (let r = 0; r < next.rows; r += 1) { + const row = next.cells[r] ?? []; + + out += cup(r + 1, 1); + out += row.map((c) => c.ch).join("") + EL_EOL; + } + + return out; + } + + let out = ""; + + for (let r = 0; r < next.rows; r += 1) { + const prevRow = prev.cells[r] ?? []; + const nextRow = next.cells[r] ?? []; + let dirty = false; + + for (let c = 0; c < next.cols; c += 1) { + if ((prevRow[c]?.ch ?? " ") !== (nextRow[c]?.ch ?? " ")) { + dirty = true; + break; + } + } + + if (dirty) { + out += cup(r + 1, 1); + out += nextRow.map((c) => c.ch).join("") + EL_EOL; + } + } + + return out; +} diff --git a/packages/core/src/render/frame/index.ts b/packages/core/src/render/frame/index.ts new file mode 100644 index 00000000..2dbb0d37 --- /dev/null +++ b/packages/core/src/render/frame/index.ts @@ -0,0 +1,126 @@ +export { + ENTER_ALT, + EXIT_ALT, + CLEAR_SCREEN, + ENABLE_MOUSE, + DISABLE_MOUSE, + HIDE_CURSOR, + SHOW_CURSOR, + CURSOR_BLINK_BLOCK, + CURSOR_SHAPE_DEFAULT, + CURSOR_COLOR_GREEN, + CURSOR_COLOR_DEFAULT, + EL_EOL, + BEGIN_SYNC, + END_SYNC, + cup, +} from "./codes"; +export { CursorState } from "./cursor-state"; +export { + stripSgr, + stripMouseReports, + parseMouseReport, + extractMouseReports, +} from "./ansi-plain"; +export type { IMouseReport } from "./ansi-plain"; +export { fitAnsiLine } from "./fit-line"; +export { withOpaqueBg } from "./opaque-bg"; +export { + blankFrame, + cloneFrame, + writeRect, + freezeFrame, + diffFrames, +} from "./grid"; +export { Scrollback } from "./scrollback"; +export { + needsScrollbar, + thumbWindow, + formatScrollbarColumn, + overlayScrollbarCol, +} from "./scrollbar"; +export type { IScrollMetrics } from "./scrollbar"; +export { + OUTER_MARGIN, + OUTER_BORDER, + OUTER_CHROME, + outerInsets, + wrapOuterFrame, + frameContentRow, + isFullBleedRule, + isPanelRuleRow, +} from "./outer-frame"; +export type { + IOuterInsets, + IOuterFrameOpts, + IFrameContentRowOpts, +} from "./outer-frame"; +export { + computeLayout, + canUsePaneTui, + clampInputInnerRows, + inputBandRows, + PANE_MIN_ROWS, + PANE_SPLIT_MIN_COLS, + PANEL_WIDTH, + PANEL_MAIN_MIN_COLS, + panelWidthFor, + INPUT_BAND_ROWS, + INPUT_INNER_ROWS, + INPUT_INNER_ROWS_MAX, + INPUT_BOX_TOP_ROWS, + INPUT_BOX_BOTTOM_ROWS, + INPUT_RULE_ROWS, + INPUT_PAD_TOP_ROWS, + INPUT_PAD_BOTTOM_ROWS, + BODY_HEADER_ROWS, + BODY_GAP_ROWS, + FOOTER_ROWS, + BOTTOM_CHROME_ROWS, + BOTTOM_PAD_ROWS, + TOP_STATUS_ROWS, + TOP_PAD_ROWS, + TOP_PAD_BOTTOM_ROWS, + TOP_STATUS_MIN_ROWS, +} from "./layout"; +export { wrapAnsiLine, wrapAnsiLines } from "./wrap-line"; +export { + formatTopStatus, + formatConsoleTopbar, + formatConsoleTitle, + formatMainHeader, + formatRailHeader, + formatRailTitleRule, + formatRailTitleBlock, + RAIL_TITLE_ROWS, + hairline, + insetX, + insetInnerCols, + formatHints, + CONSOLE, + CHROME_PAD_X, + CHROME_PAD_Y, +} from "./chrome"; +export { + formatInputBox, + formatInputBoxTop, + formatInputBoxMid, + formatInputBoxBottom, + formatInputStatusLabel, + INPUT_PROMPT, + INPUT_PROMPT_COLS, + INPUT_EDITOR_GUTTER, + inputContentCols, + inputCursorCol, +} from "./input-box"; +export { PaneFocus } from "./focus"; +export type { PanelVis, ActiveSurface, FocusAction } from "./focus"; +export { + PaneScreen, + FORGE_PROMPT, + FORGE_PROMPT_COLS, + FORGE_EDITOR_GUTTER, +} from "./pane-screen"; +export type { IPaneScreenTerminal, PaneKeyResult } from "./pane-screen"; +export type { ICell, IFrame, ILayoutRects, IPaneInput } from "./frame.types"; +export type { IScrollAnchor } from "./scrollback"; diff --git a/packages/core/src/render/frame/input-box.ts b/packages/core/src/render/frame/input-box.ts new file mode 100644 index 00000000..74d8c33d --- /dev/null +++ b/packages/core/src/render/frame/input-box.ts @@ -0,0 +1,158 @@ +import type { IStatusInfo } from "../render.types"; +import { STYLE, paint } from "../style"; +import { displayWidth, sliceToWidth } from "../width"; +import { stripSgr } from "./ansi-plain"; + +/** Prompt shown inside the input box. */ +export const INPUT_PROMPT = "> "; +export const INPUT_PROMPT_COLS = 2; +/** Space between `│` and prompt (left) / trailing edge (right) — matched. */ +export const INPUT_BOX_SIDE_PAD = 3; + +/** + * Columns reserved outside the draft text (borders + pads + prompt). + * Editor wrap width = ttyCols - INPUT_EDITOR_GUTTER. + */ +export const INPUT_EDITOR_GUTTER = + 2 + INPUT_BOX_SIDE_PAD * 2 + INPUT_PROMPT_COLS; // 10 + +/** Draft columns available inside the box. */ +export function inputContentCols(cols: number): number { + return Math.max(1, cols - INPUT_EDITOR_GUTTER); +} + +/** Absolute cursor column (0-based) for a draft caret at `draftCol`. */ +export function inputCursorCol(draftCol: number): number { + return 1 + INPUT_BOX_SIDE_PAD + INPUT_PROMPT_COLS + Math.max(0, draftCol); +} + +/** + * Input-box bottom cutout label — always empty. + * Session chips live in the top strip only. + */ +export function formatInputStatusLabel(_info: IStatusInfo | null): string { + return ""; +} + +export interface IInputBoxOpts { + readonly cols: number; + /** Single-line draft (used when `draftLines` is omitted). */ + readonly draft?: string; + /** Visual draft rows — box grows with length (caller clamps). */ + readonly draftLines?: readonly string[]; + readonly placeholder?: string; + readonly label?: string; + readonly color?: boolean; + readonly showPlaceholder?: boolean; +} + +/** + * Closed input box (caller insets to match the agent card width): + * ╭──────────────╮ + * │ > draft… │ + * │ more… │ + * ╰──────────────╯ + */ +export function formatInputBox(opts: IInputBoxOpts): { + lines: string[]; + cursorCol: number; +} { + const cols = Math.max(8, opts.cols); + const color = opts.color ?? true; + const label = opts.label ?? ""; + const rawLines = + opts.draftLines !== undefined ? [...opts.draftLines] : [opts.draft ?? ""]; + const lines = rawLines.length > 0 ? rawLines : [""]; + const empty = lines.length === 1 && (lines[0] ?? "").length === 0; + const showPh = opts.showPlaceholder !== false && empty; + const placeholder = opts.placeholder ?? "describe a task, or /help"; + const midBodies = showPh + ? [paint(placeholder, STYLE.dim, color)] + : lines.map((line) => line); + + const mid = midBodies.map((body, i) => + formatInputBoxMid(cols, body, color, { showPrompt: i === 0 }) + ); + + return { + lines: [ + formatInputBoxTop(cols, color), + ...mid, + formatInputBoxBottom(cols, label, color), + ], + cursorCol: inputCursorCol( + empty ? 0 : displayWidth(stripSgr(lines[0] ?? "")) + ), + }; +} + +/** `╭────╮` */ +export function formatInputBoxTop(cols: number, color: boolean): string { + const n = Math.max(0, cols - 2); + + return paint(`╭${"─".repeat(n)}╮`, STYLE.chrome, color); +} + +export interface IInputBoxMidOpts { + /** First draft row shows `> `; continuations indent to the same column. */ + readonly showPrompt?: boolean; +} + +/** `│ > content… │` or continuation `│ content… │`. */ +export function formatInputBoxMid( + cols: number, + body: string, + color: boolean, + midOpts: IInputBoxMidOpts = {} +): string { + const showPrompt = midOpts.showPrompt !== false; + const inner = Math.max(1, cols - 2); + const pad = " ".repeat(INPUT_BOX_SIDE_PAD); + const prompt = showPrompt + ? paint(INPUT_PROMPT, STYLE.chrome, color) + : " ".repeat(INPUT_PROMPT_COLS); + const budget = Math.max( + 0, + inner - INPUT_BOX_SIDE_PAD * 2 - INPUT_PROMPT_COLS + ); + const plain = stripSgr(body); + const fitted = + displayWidth(plain) <= budget ? body : sliceToWidth(plain, budget).text; + const used = + INPUT_BOX_SIDE_PAD + INPUT_PROMPT_COLS + displayWidth(stripSgr(fitted)); + const trail = Math.max(INPUT_BOX_SIDE_PAD, inner - used); + const left = paint("│", STYLE.chrome, color); + const right = paint("│", STYLE.chrome, color); + + return `${left}${pad}${prompt}${fitted}${" ".repeat(trail)}${right}`; +} + +/** `╰────╯` (optional right-biased label cutout when label is non-empty). */ +export function formatInputBoxBottom( + cols: number, + label: string, + color: boolean +): string { + const left = "╰"; + const right = "╯"; + const inner = Math.max(0, cols - 2); + + if (label.trim().length === 0 || inner < 8) { + return paint(`${left}${"─".repeat(inner)}${right}`, STYLE.chrome, color); + } + + const minDash = 2; + const maxLabel = Math.max(1, inner - minDash * 2 - 2); + const clipped = sliceToWidth(label.trim(), maxLabel).text; + const block = ` ${clipped} `; + const blockW = displayWidth(block); + const dashBudget = Math.max(0, inner - blockW); + const rightDash = Math.max(minDash, Math.min(4, Math.floor(dashBudget / 4))); + const leftDash = Math.max(minDash, dashBudget - rightDash); + + return ( + paint(`${left}${"─".repeat(leftDash)}`, STYLE.chrome, color) + + paint(block, STYLE.dim, color) + + paint(`${"─".repeat(rightDash)}${right}`, STYLE.chrome, color) + ); +} diff --git a/packages/core/src/render/frame/layout.ts b/packages/core/src/render/frame/layout.ts new file mode 100644 index 00000000..9968f9bf --- /dev/null +++ b/packages/core/src/render/frame/layout.ts @@ -0,0 +1,247 @@ +import type { ILayoutRects } from "./frame.types"; +import { CHROME_PAD_Y } from "./chrome"; + +/** + * Minimum terminal rows before the pane TUI yields to the classic renderer. + * Includes outer floating-window chrome (margin + border on top and bottom). + */ +export const PANE_MIN_ROWS = 16; + +/** Minimum total columns to keep a side panel. */ +export const PANE_SPLIT_MIN_COLS = 72; + +/** Minimum main-column width when the Tasks rail is open. */ +export const PANEL_MAIN_MIN_COLS = 24; + +/** + * Adaptive Tasks rail width: readable checklists without starving the main pane. + * `cols` is content width inside the outer frame (terminal − 4 chrome). + * Maps to ≈32 / 36 / 40 for terminals under 100 / 100–139 / 140+. + */ +export function panelWidthFor(cols: number): number { + if (cols >= 136) { + return 40; + } + + if (cols >= 96) { + return 36; + } + + return 32; +} + +/** Default mid-size rail width — prefer {@link panelWidthFor} for live layout. */ +export const PANEL_WIDTH = 36; + +/** + * Console chrome (inside the floating outer window): + * + * ╭──────────────────────────────────────╮ + * │ (air) TSFORGE ~/path · … │ TOP + * │ ──────────────────┬────────── │ gutter starts + * │ …scroll… │ panel │ + * │ ╭──────────────────────────╮ │ + * │ │ > describe a task… │ │ INPUT = agent width + * │ ╰──────────────────────────╯ │ + * ╰──────────────────────────────────────╯ + */ +/** Air above the title — one row. */ +export const TOP_PAD_ROWS = CHROME_PAD_Y; +export const TOP_TITLE_ROWS = 1; +/** Air between title and hairline — one row. */ +export const TOP_PAD_BOTTOM_ROWS = CHROME_PAD_Y; +export const TOP_RULE_ROWS = 1; +export const TOP_STATUS_ROWS = + TOP_PAD_ROWS + TOP_TITLE_ROWS + TOP_PAD_BOTTOM_ROWS + TOP_RULE_ROWS; // 4 + +/** Body headers folded into the top strip — always 0. */ +export const BODY_HEADER_ROWS = 0; +/** Blank row under the top rule when the body has room. */ +export const BODY_GAP_ROWS = 0; + +/** Top border of the input box (`╭─╮`). */ +export const INPUT_BOX_TOP_ROWS = 1; +/** Default draft / caret rows inside the box (idle / single-line). */ +export const INPUT_INNER_ROWS = 1; +/** + * Cap on draft rows as the user types / wraps. Keeps the transcript readable. + * Enter clears the buffer → band collapses back to INPUT_INNER_ROWS. + */ +export const INPUT_INNER_ROWS_MAX = 6; +/** Bottom border (`╰─╯`). */ +export const INPUT_BOX_BOTTOM_ROWS = 1; +/** Box has no air pad above (kept for older call sites). */ +export const INPUT_PAD_TOP_ROWS = 0; +/** Prefer BOTTOM_PAD_ROWS below the whole input band (alias kept for call sites). */ +export const INPUT_PAD_BOTTOM_ROWS = 0; + +/** Total band height for a given number of draft rows (borders + mids). */ +export function inputBandRows(innerRows: number): number { + const inner = Math.max( + INPUT_INNER_ROWS, + Math.min(INPUT_INNER_ROWS_MAX, innerRows) + ); + + return INPUT_BOX_TOP_ROWS + inner + INPUT_BOX_BOTTOM_ROWS; +} + +/** Idle / minimum closed box height. */ +export const INPUT_BAND_ROWS = inputBandRows(INPUT_INNER_ROWS); + +/** Clamp draft visual-line count into the growable band. */ +export function clampInputInnerRows(innerRows: number): number { + if (!Number.isFinite(innerRows)) { + return INPUT_INNER_ROWS; + } + + return Math.max( + INPUT_INNER_ROWS, + Math.min(INPUT_INNER_ROWS_MAX, Math.floor(innerRows)) + ); +} + +/** + * Air below the input band. Keep at 0 so the prompt sits on the outer floor + * (outer margin already provides edge breathing room). + */ +export const BOTTOM_PAD_ROWS = 0; + +/** Alias of INPUT_BOX_TOP_ROWS (kept for older call sites). */ +export const INPUT_RULE_ROWS = INPUT_BOX_TOP_ROWS; + +/** Footer metrics removed — bottom pad lives in `footer`. */ +export const FOOTER_RULE_ROWS = 0; +export const FOOTER_METRICS_ROWS = 0; +export const FOOTER_ROWS = BOTTOM_PAD_ROWS; +/** Idle bottom chrome (single draft row). Grows via `inputInnerRows`. */ +export const BOTTOM_CHROME_ROWS = INPUT_BAND_ROWS + BOTTOM_PAD_ROWS; + +/** + * Drop the top title/rule before shrinking bottom chrome on short terminals. + * Measured in *content* rows (inside the outer floating window). At + * PANE_MIN_ROWS with OUTER_CHROME=2 that is 12. + */ +export const TOP_STATUS_MIN_ROWS = 12; + +/** @deprecated Prefer INPUT_BAND_ROWS — kept for older call sites. */ +export const INPUT_ROWS_DEFAULT = INPUT_BAND_ROWS; + +export interface IComputeLayoutOpts { + readonly rows: number; + readonly cols: number; + /** + * Draft visual rows inside the input box (not including ╭/╰). + * Defaults to 1; grows with typing up to INPUT_INNER_ROWS_MAX. + */ + readonly inputInnerRows?: number; + /** + * Prefer `inputInnerRows`. When set without `inputInnerRows`, + * treated as total band height (borders included) for older call sites. + */ + readonly inputRows?: number; + /** + * When false, keep a single full-width main column. + * Default true (empty worklist still shows the rail column when wide enough). + */ + readonly showPanel?: boolean; +} + +/** Untagged shape so legacy `inputRows` can be read without no-deprecated noise. */ +interface IBandRowOpts { + readonly inputInnerRows?: number; + readonly inputRows?: number; +} + +function resolveInputBandRows(opts: IComputeLayoutOpts): number { + return resolveBandRowsFrom(opts); +} + +function resolveBandRowsFrom(opts: IBandRowOpts): number { + if (opts.inputInnerRows !== undefined) { + return inputBandRows(clampInputInnerRows(opts.inputInnerRows)); + } + + if (opts.inputRows !== undefined) { + // Legacy: callers passed total band height. + return Math.max( + INPUT_BAND_ROWS, + Math.min(inputBandRows(INPUT_INNER_ROWS_MAX), Math.floor(opts.inputRows)) + ); + } + + return INPUT_BAND_ROWS; +} + +/** + * Compute top / main / panel / input / footer rectangles. + * Status lives in the top strip; `footer` is bottom air under the input. + */ +export function computeLayout(opts: IComputeLayoutOpts): ILayoutRects { + const topRows = opts.rows >= TOP_STATUS_MIN_ROWS ? TOP_STATUS_ROWS : 0; + const wantedInput = resolveInputBandRows(opts); + const bottom = Math.min( + wantedInput + BOTTOM_PAD_ROWS, + Math.max(1, opts.rows - topRows - 1) + ); + const inputRows = Math.min(wantedInput, bottom); + const footerRows = Math.min(BOTTOM_PAD_ROWS, Math.max(0, bottom - inputRows)); + const bodyRows = Math.max(1, opts.rows - topRows - inputRows - footerRows); + const wantPanel = opts.showPanel !== false; + const panelCols = panelWidthFor(opts.cols); + const split = + wantPanel && + opts.cols >= PANE_SPLIT_MIN_COLS && + opts.cols - panelCols >= PANEL_MAIN_MIN_COLS; + // Gutter spine runs through main + input + bottom pad. + const spineRows = bodyRows + inputRows + footerRows; + + const top = + topRows > 0 + ? { row: 0, col: 0, rows: topRows, cols: opts.cols } + : { row: 0, col: 0, rows: 0, cols: opts.cols }; + + const input = { + row: topRows + bodyRows, + col: 0, + rows: inputRows, + cols: opts.cols, + }; + const footer = { + row: topRows + bodyRows + inputRows, + col: 0, + rows: footerRows, + cols: opts.cols, + }; + + if (!split) { + return { + top, + main: { row: topRows, col: 0, rows: bodyRows, cols: opts.cols }, + panel: null, + input, + footer, + collapsedPanel: true, + }; + } + + const mainCols = opts.cols - panelCols - 1; // 1-col gutter + + return { + top, + main: { row: topRows, col: 0, rows: bodyRows, cols: mainCols }, + panel: { + row: topRows, + col: mainCols + 1, + rows: spineRows, + cols: panelCols, + }, + input, + footer, + collapsedPanel: false, + }; +} + +/** True when the terminal is tall enough for pane mode. */ +export function canUsePaneTui(rows: number): boolean { + return rows >= PANE_MIN_ROWS; +} diff --git a/packages/core/src/render/frame/opaque-bg.ts b/packages/core/src/render/frame/opaque-bg.ts new file mode 100644 index 00000000..d6e8f2dd --- /dev/null +++ b/packages/core/src/render/frame/opaque-bg.ts @@ -0,0 +1,17 @@ +import { RESET } from "../style"; + +/** + * Stamp an opaque background onto a (possibly SGR-styled) line so transparent + * terminals cannot show wallpaper through blank cells. + * + * `paint()` / `fitAnsiLine()` emit full SGR resets, which also clear background. + * Re-apply `bg` after every reset, and leave `bg` active at the end so a + * following EL (erase-to-EOL) can fill with BCE when the terminal supports it. + */ +export function withOpaqueBg(line: string, bg: string): string { + if (bg.length === 0) { + return line; + } + + return `${bg}${line.split(RESET).join(`${RESET}${bg}`)}`; +} diff --git a/packages/core/src/render/frame/outer-frame.ts b/packages/core/src/render/frame/outer-frame.ts new file mode 100644 index 00000000..46daae76 --- /dev/null +++ b/packages/core/src/render/frame/outer-frame.ts @@ -0,0 +1,189 @@ +import { STYLE, paint } from "../style"; +import { stripSgr } from "./ansi-plain"; +import { fitAnsiLine } from "./fit-line"; + +/** Blank cells between the terminal edge and the outer box. */ +export const OUTER_MARGIN = 1; +/** Thickness of the floating-window border (one cell). */ +export const OUTER_BORDER = 1; +/** Margin + border on each side. */ +export const OUTER_CHROME = OUTER_MARGIN + OUTER_BORDER; + +export interface IOuterInsets { + /** 0-based row of the first content cell (inside the border). */ + readonly originRow: number; + /** 0-based col of the first content cell. */ + readonly originCol: number; + readonly contentRows: number; + readonly contentCols: number; +} + +/** Content rect inside the floating window for a terminal of `rows`×`cols`. */ +export function outerInsets(rows: number, cols: number): IOuterInsets { + const originRow = OUTER_CHROME; + const originCol = OUTER_CHROME; + const contentRows = Math.max(1, rows - 2 * OUTER_CHROME); + const contentCols = Math.max(8, cols - 2 * OUTER_CHROME); + + return { originRow, originCol, contentRows, contentCols }; +} + +export interface IOuterFrameOpts { + readonly color?: boolean; + /** + * 0-based content column of the panel gutter. When set, the bottom edge + * uses `┴` so the vertical spine closes into the outer frame. + */ + readonly splitCol?: number; +} + +/** + * Wrap content lines (exactly `contentRows` × `contentCols`) in margin + + * chrome border so the result is `termRows` × `termCols`. + */ +export function wrapOuterFrame( + content: readonly string[], + termRows: number, + termCols: number, + opts: boolean | IOuterFrameOpts = true +): string[] { + const options = typeof opts === "boolean" ? { color: opts } : opts; + const color = options.color !== false; + const { originRow, contentCols } = outerInsets(termRows, termCols); + const screen: string[] = []; + const blank = fitAnsiLine("", termCols); + const top = frameHorizEdge("╭", "╮", contentCols, termCols, color); + const bottom = frameHorizEdge("╰", "╯", contentCols, termCols, color, { + splitCol: options.splitCol, + junction: "┴", + }); + + for (let r = 0; r < termRows; r += 1) { + if (r < OUTER_MARGIN || r >= termRows - OUTER_MARGIN) { + screen.push(blank); + continue; + } + + if (r === OUTER_MARGIN) { + screen.push(top); + continue; + } + + if (r === termRows - OUTER_MARGIN - 1) { + screen.push(bottom); + continue; + } + + screen.push( + frameContentRow(content[r - originRow] ?? "", termCols, { + color, + splitCol: options.splitCol, + }) + ); + } + + return screen; +} + +/** + * True when `contentLine` is a full-bleed horizontal rule (─ / ┬ / ┴ / ┼). + * Those rows need `├`/`┤` side glyphs so the rule joins the outer rails — + * bare `│────│` reads as a floating segment in the terminal font. + */ +export function isFullBleedRule(contentLine: string): boolean { + const plain = stripSgr(contentLine); + + if (plain.length === 0) { + return false; + } + + return /^[─┬┴┼]+$/u.test(plain); +} + +/** + * Panel-only under-rule: gutter at `splitCol` is `├`/`│`/`┼` and the panel + * cells are all `─`. Needs a right `┤` so the rule does not float. + */ +export function isPanelRuleRow( + contentLine: string, + splitCol: number | undefined +): boolean { + if (splitCol === undefined || splitCol < 0) { + return false; + } + + const plain = stripSgr(contentLine); + + if (splitCol >= plain.length) { + return false; + } + + const gutter = plain[splitCol]; + + if (gutter !== "├" && gutter !== "│" && gutter !== "┼") { + return false; + } + + const panel = plain.slice(splitCol + 1); + + return panel.length > 0 && /^─+$/u.test(panel); +} + +export interface IFrameContentRowOpts { + readonly color?: boolean; + /** 0-based content column of the panel gutter (for panel-only rules). */ + readonly splitCol?: number; +} + +/** Stamp one content row into a full-width framed terminal line. */ +export function frameContentRow( + contentLine: string, + termCols: number, + colorOrOpts: boolean | IFrameContentRowOpts = true +): string { + const opts = + typeof colorOrOpts === "boolean" ? { color: colorOrOpts } : colorOrOpts; + const color = opts.color !== false; + const { originCol, contentCols } = outerInsets( + OUTER_CHROME * 2 + 1, + termCols + ); + const full = isFullBleedRule(contentLine); + const panelRule = !full && isPanelRuleRow(contentLine, opts.splitCol); + const left = paint(full ? "├" : "│", STYLE.chrome, color); + const right = paint(full || panelRule ? "┤" : "│", STYLE.chrome, color); + const inner = fitAnsiLine(contentLine, contentCols); + const row = + " ".repeat(originCol - OUTER_BORDER) + + left + + inner + + right + + " ".repeat(Math.max(0, termCols - originCol - contentCols - OUTER_BORDER)); + + return fitAnsiLine(row, termCols); +} + +function frameHorizEdge( + left: string, + right: string, + contentCols: number, + termCols: number, + color: boolean, + junction?: { splitCol?: number; junction?: string } +): string { + const splitCol = junction?.splitCol; + const glyph = junction?.junction ?? "┴"; + const mid = + splitCol !== undefined && + splitCol >= 0 && + splitCol < contentCols && + contentCols > 0 + ? "─".repeat(splitCol) + + glyph + + "─".repeat(Math.max(0, contentCols - splitCol - 1)) + : "─".repeat(Math.max(0, contentCols)); + + const bar = paint(`${left}${mid}${right}`, STYLE.chrome, color); + + return fitAnsiLine(`${" ".repeat(OUTER_MARGIN)}${bar}`, termCols); +} diff --git a/packages/core/src/render/frame/pane-keys.ts b/packages/core/src/render/frame/pane-keys.ts new file mode 100644 index 00000000..48b44ed2 --- /dev/null +++ b/packages/core/src/render/frame/pane-keys.ts @@ -0,0 +1,160 @@ +import type { PaneFocus } from "./focus"; +import type { Scrollback } from "./scrollback"; +import { parseMouseReport } from "./ansi-plain"; + +export type PaneKeyResult = "handled" | "passthrough" | "dump"; + +export interface IPaneKeyDeps { + readonly focus: PaneFocus; + readonly scrollback: Scrollback; + readonly panelLen: number; + /** Wheel: positive = older / up; negative = newer / down. `col` is 1-based. */ + onWheel?(delta: number, col: number, row: number): void; + paint(): void; + invalidate(): void; +} + +/** Focus / panel navigation keys (Ctrl+G, Esc, Tab, j/k when panel-focused). */ +export function handleFocusKey( + seq: string, + deps: IPaneKeyDeps +): PaneKeyResult | null { + if (seq === "\x07") { + if (deps.focus.togglePanel(deps.panelLen > 0) === "changed") { + deps.paint(); + } + + return "handled"; + } + + if (seq === "\x1b") { + if (deps.focus.escape() === "changed") { + deps.paint(); + + return "handled"; + } + + return "passthrough"; + } + + if (seq === "\t") { + if (deps.focus.tab(deps.panelLen > 0) === "changed") { + deps.paint(); + + return "handled"; + } + + return "passthrough"; + } + + if (!deps.focus.panelFocused) { + return null; + } + + const max = Math.max(0, deps.panelLen - 1); + const up = seq === "\x1b[A" || seq === "\x1bOA" || seq === "k" || seq === "K"; + const down = + seq === "\x1b[B" || seq === "\x1bOB" || seq === "j" || seq === "J"; + + if (up || down) { + if (deps.focus.moveSelection(up ? -1 : 1, max) === "changed") { + deps.paint(); + } + + return "handled"; + } + + return null; +} + +/** + * Scrollback / paging keys — only when the user has focused scrollback + * (or panel arrows are handled above). Prompt-focused arrows stay with the editor. + */ +export function handleScrollKey( + seq: string, + deps: IPaneKeyDeps +): PaneKeyResult | null { + // Don't steal ↑/↓ from the editor while typing. + if ( + deps.focus.promptFocused && + !deps.focus.panelFocused && + (seq === "\x1b[A" || + seq === "\x1bOA" || + seq === "\x1b[B" || + seq === "\x1bOB") + ) { + return null; + } + + if (seq === "\x1b[A" || seq === "\x1bOA") { + if (deps.focus.active === "prompt") { + deps.focus.focusScrollback(); + } + + deps.scrollback.scroll(1); + deps.invalidate(); + deps.paint(); + + return "handled"; + } + + if (seq === "\x1b[B" || seq === "\x1bOB") { + deps.scrollback.scroll(-1); + + if (deps.scrollback.following) { + deps.focus.focusPrompt(); + } + + deps.invalidate(); + deps.paint(); + + return "handled"; + } + + if (seq === "\x1b[5~") { + deps.scrollback.scroll(10); + deps.invalidate(); + deps.paint(); + + return "handled"; + } + + if (seq === "\x1b[6~") { + deps.scrollback.scroll(-10); + deps.invalidate(); + deps.paint(); + + return "handled"; + } + + return null; +} + +/** Swallow SGR mouse reports; wheel scrolls main or panel via onWheel. */ +export function handleMouseKey( + seq: string, + deps: IPaneKeyDeps +): PaneKeyResult | null { + const report = parseMouseReport(seq); + + if (report === null) { + // Chunk with mouse noise but not a lone report — still swallow if present. + if (seq.includes(`${String.fromCharCode(27)}[<`)) { + return "handled"; + } + + return null; + } + + // 64 = wheel up, 65 = wheel down (SGR). + if (report.button === 64 || report.button === 65) { + const delta = report.button === 64 ? 3 : -3; + + deps.onWheel?.(delta, report.col, report.row); + deps.invalidate(); + deps.paint(); + } + + return "handled"; +} diff --git a/packages/core/src/render/frame/pane-screen.ts b/packages/core/src/render/frame/pane-screen.ts new file mode 100644 index 00000000..dcf7d754 --- /dev/null +++ b/packages/core/src/render/frame/pane-screen.ts @@ -0,0 +1,1392 @@ +import type { IPaneInput } from "./frame.types"; +import type { IStatusInfo } from "../render.types"; +import { + BEGIN_SYNC, + CLEAR_SCREEN, + CURSOR_BLINK_BLOCK, + CURSOR_COLOR_DEFAULT, + CURSOR_COLOR_GREEN, + CURSOR_SHAPE_DEFAULT, + DISABLE_MOUSE, + ENABLE_MOUSE, + END_SYNC, + ENTER_ALT, + EXIT_ALT, + EL_EOL, + SHOW_CURSOR, + cup, +} from "./codes"; +import { + CHROME_PAD_X, + CONSOLE, + formatConsoleTopbar, + formatRailTitleBlock, + insetInnerCols, + insetX, + RAIL_TITLE_ROWS, +} from "./chrome"; +import { CursorState } from "./cursor-state"; +import { fitAnsiLine } from "./fit-line"; +import { + formatInputBox, + formatInputStatusLabel, + INPUT_EDITOR_GUTTER, + INPUT_PROMPT, + INPUT_PROMPT_COLS, + inputCursorCol, +} from "./input-box"; +import { withOpaqueBg } from "./opaque-bg"; +import { PaneFocus } from "./focus"; +import { + BODY_GAP_ROWS, + canUsePaneTui, + clampInputInnerRows, + computeLayout, + inputBandRows, + TOP_STATUS_ROWS, +} from "./layout"; +import { Scrollback } from "./scrollback"; +import { stripSgr } from "./ansi-plain"; +import { handleFocusKey, handleMouseKey, handleScrollKey } from "./pane-keys"; +import type { PaneKeyResult } from "./pane-keys"; +import { paint } from "../style"; +import { displayWidth } from "../width"; +import { formatScrollbarColumn, overlayScrollbarCol } from "./scrollbar"; +import { frameContentRow, outerInsets, wrapOuterFrame } from "./outer-frame"; + +export interface IPaneScreenTerminal { + readonly isTTY?: boolean; + rows?: number; + columns?: number; + write(data: string): boolean; +} + +export type { PaneKeyResult }; + +/** + * Prompt prefix inside the input box (`> `). Hardware cursor sits after it; + * placeholder ghost-text fills the draft when idle. + */ +export const FORGE_PROMPT = INPUT_PROMPT; +export const FORGE_PROMPT_COLS = INPUT_PROMPT_COLS; +/** Borders + side pads + prompt — columns the editor must reserve. */ +export const FORGE_EDITOR_GUTTER = INPUT_EDITOR_GUTTER; +const FORGE_PLACEHOLDER = "describe a task, or /help"; + +const GUTTER = "│"; +/** Fallback when no worklist lines are set — mirrors formatWorklistLines empty. */ +const EMPTY_PANEL_LINES = ["approve a plan", "to fill this list"] as const; + +/** + * Interactive console TUI: dense top strip, hairlines, scroll + rail, caret input. + */ +export class PaneScreen { + private readonly scrollback = new Scrollback(); + private readonly focus = new PaneFocus(); + private readonly cursor = new CursorState(); + private panelLines: readonly string[] = []; + private overlayLines: readonly string[] = []; + private agentTreeLines: readonly string[] = []; + private status: IStatusInfo | null = null; + private worklistBadge = ""; + private lastTopLine = ""; + /** Mode/badge/cwd/status-class — full invalidate when this changes, not on ticks. */ + private lastTopShape = ""; + private flashHeaderPaints = 0; + private lastBadge = ""; + private cwd = process.cwd(); + private sessionId = ""; + private input: IPaneInput = { lines: [""], cursorRow: 0, cursorCol: 0 }; + private prevLines: string[] | null = null; + private lastWrapCols = 0; + /** When true, next flush homes + erases the alt screen (resize / geom change). */ + private geometryDirty = false; + /** Panel list scroll offset (lines from top). Independent of main scrollback. */ + private panelOffset = 0; + private bodyViewportRows = 1; + private entered = false; + /** True after a successful enter — resize may re-enter after a shrink-leave. */ + private everEntered = false; + private rows: number; + private cols: number; + + constructor( + private readonly out: IPaneScreenTerminal, + rows?: number, + cols?: number + ) { + this.rows = rows ?? out.rows ?? 24; + this.cols = cols ?? out.columns ?? 80; + } + + get active(): boolean { + return this.entered; + } + + get focusState(): PaneFocus { + return this.focus; + } + + enter(): boolean { + // Require an explicit TTY — `undefined` on pipes must not enter alt-screen. + if (this.out.isTTY !== true || !canUsePaneTui(this.rows)) { + return false; + } + + if (this.entered) { + return true; + } + + // Alt screen + mouse capture: host terminal must NOT scroll its own buffer. + // Wheel events become CSI reports; we scroll main/panel viewports only. + // Green blinking block caret replaces any prompt text. + this.out.write( + ENTER_ALT + + ENABLE_MOUSE + + CURSOR_COLOR_GREEN + + CURSOR_BLINK_BLOCK + + SHOW_CURSOR + ); + this.entered = true; + this.everEntered = true; + this.prevLines = null; + this.cursor.reset(); + this.panelOffset = 0; + this.paint(); + + return true; + } + + leave(): void { + if (!this.entered) { + return; + } + + this.out.write( + DISABLE_MOUSE + + CURSOR_COLOR_DEFAULT + + CURSOR_SHAPE_DEFAULT + + SHOW_CURSOR + + EXIT_ALT + ); + this.entered = false; + this.prevLines = null; + this.cursor.reset(); + this.panelOffset = 0; + } + + /** Full clear + redraw (e.g. `/clear`) — invalidates differential cache. */ + clear(): void { + if (!this.entered) { + return; + } + + this.scrollback.clear(); + this.geometryDirty = true; + this.prevLines = null; + this.cursor.reset(); + this.panelOffset = 0; + this.lastWrapCols = 0; + this.paint(); + } + + /** + * Apply a new terminal size. When `paint` is false, only updates geometry — + * caller must paint once (e.g. after syncing status) so resize settle is a + * single frame, not resize-paint + setStatus-paint. + */ + resize( + rows: number, + cols: number, + opts?: { readonly paint?: boolean } + ): void { + const nextRows = Math.max(1, rows); + const nextCols = Math.max(1, cols); + const geomChanged = nextRows !== this.rows || nextCols !== this.cols; + const shouldPaint = opts?.paint !== false; + + this.rows = nextRows; + this.cols = nextCols; + + if (!canUsePaneTui(nextRows)) { + if (this.entered) { + this.leave(); + } + + return; + } + + // Re-enter after a shrink-leave when the terminal is tall enough again. + // Never auto-enter a screen that was never started (pipes / plain path). + if (!this.entered) { + if (this.everEntered) { + this.enter(); + } + + return; + } + + // Full clear on any geometry change — shrink/zoom otherwise leaves ghost cells. + if (geomChanged) { + this.geometryDirty = true; + this.prevLines = null; + this.cursor.reset(); + // Force wrap reflow on next paint (main cols may change with panel split). + this.lastWrapCols = 0; + } + + if (shouldPaint) { + this.paint(); + } + } + + appendMain(text: string): void { + this.scrollback.append(text); + + if (!this.entered) { + return; + } + + // Streaming hot path: following + no overlay — patch main viewport only. + if ( + this.prevLines !== null && + !this.geometryDirty && + this.scrollback.following && + this.overlayLines.length === 0 && + this.agentTreeLines.length === 0 && + this.lastWrapCols > 0 + ) { + this.paintMainFollowOnly(); + + return; + } + + this.paint(); + } + + setPanel(lines: readonly string[]): void { + this.panelLines = lines; + this.focus.syncHasItems(this.hasPanelContent()); + this.clampPanelOffset(); + + if (this.entered) { + this.paint(); + } + } + + /** Scroll the main transcript viewport (positive = older). */ + scrollMain(delta: number): void { + this.scrollback.scroll(delta); + + if (this.entered) { + this.prevLines = null; + this.paint(); + } + } + + /** Scroll the side panel list (positive = later lines). */ + scrollPanel(delta: number): void { + this.panelOffset = Math.max(0, this.panelOffset + delta); + this.clampPanelOffset(); + + if (this.entered) { + this.prevLines = null; + this.paint(); + } + } + + /** Real worklist items — empty placeholder stays visible but is not focusable. */ + private hasPanelContent(): boolean { + if (this.panelLines.length === 0) { + return false; + } + + const head = stripSgr(this.panelLines[0] ?? ""); + + // Empty landing copy in the rail. + if ( + head === "worklist" || + head === "(empty)" || + head === "—" || + head.startsWith("No worklist") || + head === "approve a plan" || + head === "/work to start" || + head === "/work" + ) { + return false; + } + + // Live header is `worklist N/M` (Tasks title reads counts via badge). + return /^worklist\s+\d+\/\d+/.test(head) || /^\d+\/\d+$/.test(head); + } + + private draftInnerRows(): number { + const n = this.input.lines.length > 0 ? this.input.lines.length : 1; + + return clampInputInnerRows(n); + } + + private layoutOpts(): { + rows: number; + cols: number; + showPanel: boolean; + inputInnerRows: number; + } { + // Layout runs inside the floating window (margin + border reserved). + const insets = outerInsets(this.rows, this.cols); + + return { + rows: insets.contentRows, + cols: insets.contentCols, + showPanel: true, + inputInnerRows: this.draftInnerRows(), + }; + } + + clearPanel(): void { + this.setPanel([]); + } + + setWorklistBadge(badge: string): void { + if (badge !== this.lastBadge && badge.length > 0) { + this.flashHeaderPaints = 2; + } + + const shapeChanged = badge !== this.worklistBadge; + + this.lastBadge = badge; + this.worklistBadge = badge; + + if (shapeChanged) { + this.lastTopShape = ""; + this.prevLines = null; + } + + if (this.entered) { + this.paint(); + } + } + + setBusy(_busy: boolean): void { + // Reserved for turn-busy chrome; still repaint so callers can rely on a flush. + if (this.entered) { + this.paint(); + } + } + + /** Identity chips for the pinned topbar (cwd + short session id). */ + setHeader(opts: { cwd: string; sessionId?: string }): void { + this.cwd = opts.cwd; + this.sessionId = opts.sessionId ?? ""; + this.lastTopShape = ""; + + if (this.entered) { + this.prevLines = null; + this.paint(); + } + } + + /** + * Update the top-strip status. Routine ticks (activity / elapsed) differential- + * paint dirty top rows only. Shape changes (mode, badge, cwd, status class) + * full-invalidate so chrome width shifts cannot leave ghosts. + */ + setStatus(info: IStatusInfo, opts?: { readonly paint?: boolean }): void { + this.status = info; + + if (!this.entered) { + return; + } + + const layout = computeLayout(this.layoutOpts()); + const nextTop = + layout.top.rows > 0 + ? this.topbarLines(layout.top.cols, layout).join("\n") + : ""; + const nextShape = this.topStripShape(info); + + if ( + nextTop === this.lastTopLine && + nextShape === this.lastTopShape && + this.prevLines !== null && + this.flashHeaderPaints === 0 + ) { + return; + } + + if (nextShape !== this.lastTopShape) { + this.lastTopShape = nextShape; + this.prevLines = null; + } + + if (opts?.paint === false) { + return; + } + + this.paint(); + } + + setOverlay(lines: readonly string[]): void { + this.overlayLines = lines; + + if (this.entered) { + this.paint(); + } + } + + clearOverlay(): void { + if (this.overlayLines.length === 0) { + return; + } + + this.overlayLines = []; + + if (this.entered) { + this.paint(); + } + } + + setAgentTree(lines: readonly string[]): void { + this.agentTreeLines = lines; + + if (this.entered) { + this.paint(); + } + } + + clearAgentTree(): void { + if (this.agentTreeLines.length === 0) { + return; + } + + this.agentTreeLines = []; + + if (this.entered) { + this.paint(); + } + } + + setInput(input: IPaneInput): void { + const prevInner = this.draftInnerRows(); + + this.input = { + lines: [...input.lines], + cursorRow: input.cursorRow, + cursorCol: input.cursorCol, + }; + + if (!this.entered) { + return; + } + + // Growing/shrinking the box moves the body/input split — full paint. + if (this.draftInnerRows() !== prevInner) { + this.geometryDirty = true; + this.prevLines = null; + this.paint(); + + return; + } + + // Keystroke hot path: only the input band changes. A full paint re-walks + // scrollback/wrap and was the lag source behind every space/character. + if (this.prevLines !== null && !this.geometryDirty) { + this.paintInputOnly(); + + return; + } + + this.paint(); + } + + /** + * Stable top-strip shape — mode/badge/cwd/status class, not ticking activity + * text. Shape changes full-invalidate; ticks differential-paint. + */ + private topStripShape(info: IStatusInfo): string { + const hasActivity = + info.activity !== undefined && info.activity.length > 0 ? "a" : "s"; + + return [ + info.mode ?? "", + info.status, + info.model, + info.scope, + this.worklistBadge, + this.cwd, + hasActivity, + ].join("\0"); + } + + /** Patch just the input band + caret — skips scrollback wrap/compose. */ + private paintInputOnly(): void { + const insets = outerInsets(this.rows, this.cols); + const layout = computeLayout(this.layoutOpts()); + const band = this.paintInputBand(layout); + const screen = this.prevLines; + + if (screen === null) { + this.paint(); + + return; + } + + const gutter = paint( + GUTTER, + this.focus.panelFocused ? CONSOLE.bright : CONSOLE.rule, + true + ); + const panelCols = layout.panel?.cols ?? 0; + const panelSource = + layout.panel !== null ? this.panelPaintLines(panelCols) : []; + const panelStart = layout.main.rows; + const splitCol = layout.panel !== null ? layout.main.cols : undefined; + let dirty = ""; + + for (let i = 0; i < layout.input.rows; i += 1) { + const row = insets.originRow + layout.input.row + i; + const mainCols = + layout.panel !== null ? layout.main.cols : insets.contentCols; + const mainLine = fitAnsiLine(band.lines[i] ?? "", mainCols); + const content = + layout.panel !== null + ? paintSplitRow( + mainLine, + gutter, + fitPanelCell(panelSource[panelStart + i] ?? "", panelCols) + ) + : mainLine; + const line = frameContentRow(content, this.cols, { splitCol }); + const stamped = withOpaqueBg(line, CONSOLE.bg); + + if (screen[row] !== stamped) { + screen[row] = stamped; + dirty += + cup(row + 1, 1) + + lastRowSafe(stamped, row, this.rows, this.cols) + + EL_EOL; + } + } + + const cursorRow = insets.originRow + layout.input.row + band.cursor.row + 1; + const cursorCol = insets.originCol + layout.input.col + band.cursor.col + 1; + + this.cursor.reset(); + const cursorBytes = this.cursor.move(cursorRow, cursorCol); + + if (dirty.length > 0) { + this.out.write(BEGIN_SYNC + dirty + cursorBytes + END_SYNC); + } else { + this.out.write(BEGIN_SYNC + cursorBytes + END_SYNC); + } + } + + /** + * Streaming hot path: refresh main viewport (+ scrollbar) while following. + * Reuses top/input/panel from prevLines — no full compose/outer-frame rebuild. + */ + private paintMainFollowOnly(): void { + const screen = this.prevLines; + + if (screen === null) { + this.paint(); + + return; + } + + const insets = outerInsets(this.rows, this.cols); + const layout = computeLayout(this.layoutOpts()); + const bodyGap = layout.main.rows >= BODY_GAP_ROWS + 2 ? BODY_GAP_ROWS : 0; + const mainRows = Math.max(0, layout.main.rows - bodyGap); + const wrapCols = insetInnerCols(layout.main.cols); + + if (wrapCols !== this.lastWrapCols || mainRows !== this.bodyViewportRows) { + this.paint(); + + return; + } + + this.scrollback.setViewportRows(mainRows); + const band = this.paintInputBand(layout); + const dirty = this.patchMainViewportRows(screen, { + layout, + insets, + mainRows, + bodyStart: layout.main.row + bodyGap, + mainVisible: this.scrollback.visible(), + scrollbar: formatScrollbarColumn( + this.scrollback.metrics(), + mainRows, + true + ), + }); + + if (dirty.length === 0) { + return; + } + + const cursorRow = insets.originRow + layout.input.row + band.cursor.row + 1; + const cursorCol = insets.originCol + layout.input.col + band.cursor.col + 1; + + this.cursor.reset(); + this.out.write( + BEGIN_SYNC + dirty + this.cursor.move(cursorRow, cursorCol) + END_SYNC + ); + } + + /** Patch main-body terminal rows in `screen`; returns CSI dirty bytes. */ + private patchMainViewportRows( + screen: string[], + opts: { + readonly layout: ReturnType; + readonly insets: ReturnType; + readonly mainRows: number; + readonly bodyStart: number; + readonly mainVisible: readonly string[]; + readonly scrollbar: readonly string[] | null; + } + ): string { + const { layout, insets, mainRows, bodyStart, mainVisible, scrollbar } = + opts; + const mainCols = layout.panel !== null ? layout.main.cols : layout.top.cols; + const gutter = paint( + GUTTER, + this.focus.panelFocused ? CONSOLE.bright : CONSOLE.rule, + true + ); + const panelCols = layout.panel?.cols ?? 0; + const panelSource = + layout.panel !== null ? this.panelPaintLines(panelCols) : []; + const splitCol = layout.panel !== null ? layout.main.cols : undefined; + const ruleGutter = paint( + "├", + this.focus.panelFocused ? CONSOLE.bright : CONSOLE.rule, + true + ); + let dirty = ""; + + for (let r = 0; r < mainRows; r += 1) { + const content = buildMainSplitContent({ + mainText: mainVisible[r] ?? "", + mainCols, + thumb: scrollbar?.[r], + gutter, + ruleGutter, + panelCell: + layout.panel !== null + ? fitPanelCell(panelSource[r] ?? "", panelCols) + : null, + }); + const stamped = withOpaqueBg( + frameContentRow(content, this.cols, { splitCol }), + CONSOLE.bg + ); + const termRow = insets.originRow + bodyStart + r; + + if (screen[termRow] !== stamped) { + screen[termRow] = stamped; + dirty += + cup(termRow + 1, 1) + + lastRowSafe(stamped, termRow, this.rows, this.cols) + + EL_EOL; + } + } + + return dirty; + } + + dumpTranscript(): string { + return this.scrollback.dump(); + } + + handleKey(seq: string): PaneKeyResult { + if (seq === "\x0f") { + return "dump"; + } + + const deps = { + focus: this.focus, + scrollback: this.scrollback, + panelLen: this.panelSourceLen(), + onWheel: (delta: number, col: number, _row: number): void => { + this.wheelAt(delta, col); + }, + paint: () => { + this.paint(); + }, + invalidate: () => { + this.prevLines = null; + }, + }; + + return ( + handleFocusKey(seq, deps) ?? + handleScrollKey(seq, deps) ?? + handleMouseKey(seq, deps) ?? + "passthrough" + ); + } + + /** Wheel over panel columns scrolls the rail; otherwise the main transcript. */ + private wheelAt(delta: number, col1Based: number): void { + const insets = outerInsets(this.rows, this.cols); + const layout = computeLayout(this.layoutOpts()); + // Mouse cols are terminal-absolute; layout cols are content-relative. + const contentCol = col1Based - insets.originCol; + const overPanel = + layout.panel !== null && contentCol > layout.main.cols + 1; + + if (overPanel) { + // Wheel up (positive delta from handler) → earlier lines → decrease offset. + this.panelOffset = Math.max(0, this.panelOffset - delta); + this.clampPanelOffset(); + + return; + } + + this.scrollback.scroll(delta); + } + + private panelSourceLen(): number { + return this.panelBodyLines().length; + } + + /** Body rows available under the sticky Tasks title + under-rule. */ + private panelBodyViewRows(): number { + return Math.max(0, this.bodyViewportRows - RAIL_TITLE_ROWS); + } + + private clampPanelOffset(): void { + const max = Math.max(0, this.panelSourceLen() - this.panelBodyViewRows()); + + if (this.panelOffset > max) { + this.panelOffset = max; + } + } + + /** + * Columns available for transcript / bubble content inside the main pane + * (after horizontal inset). Callers must size user/agent chrome to this — + * full `stdout.columns` is wider than the pane and causes mid-word rewrap. + */ + mainInnerCols(): number { + const layout = computeLayout(this.layoutOpts()); + + return insetInnerCols(layout.main.cols); + } + + /** + * Wrap budget for Tasks-rail body lines (0 when collapsed). + * Matches {@link fitPanelCell}'s insetX — full panel.cols over-wraps and + * mid-word clips (and strips SGR) when painted. + */ + panelInnerCols(): number { + const layout = computeLayout(this.layoutOpts()); + + if (layout.panel === null) { + return 0; + } + + return insetInnerCols(layout.panel.cols); + } + + /** Rows available for checklist body under the sticky Tasks title. */ + panelListBudgetRows(): number { + return this.panelBodyViewRows(); + } + + /** + * Max rows an overlay may occupy in the main pane body (leaves one transcript + * row). Menu formatters should fit this budget so pinOverlayChrome is only a + * safety net. + */ + overlayBudgetRows(): number { + const layout = computeLayout(this.layoutOpts()); + const bodyGap = layout.main.rows >= BODY_GAP_ROWS + 2 ? BODY_GAP_ROWS : 0; + const scrollBudget = Math.max(0, layout.main.rows - bodyGap); + + return Math.max(1, scrollBudget - 1); + } + + paint(): void { + if (!this.entered) { + return; + } + + const layout = computeLayout(this.layoutOpts()); + const inputBand = this.paintInputBand(layout); + const topLines = this.composeTop(layout); + + this.lastTopLine = topLines.join("\n"); + + if (this.flashHeaderPaints > 0) { + this.flashHeaderPaints -= 1; + } + + const bodyGap = layout.main.rows >= BODY_GAP_ROWS + 2 ? BODY_GAP_ROWS : 0; + const chromeAll = [...this.agentTreeLines, ...this.overlayLines]; + const scrollBudget = Math.max(0, layout.main.rows - bodyGap); + const chromeRows = Math.min( + chromeAll.length, + Math.max(0, scrollBudget - 1) + ); + const mainRows = scrollBudget - chromeRows; + const wrapCols = insetInnerCols(layout.main.cols); + + this.bodyViewportRows = mainRows; + this.clampPanelOffset(); + this.applyScrollbackViewport(wrapCols, mainRows); + + const content = this.composeScreen({ + layout, + topLines, + inputBand, + bodyGap, + mainRows, + chromeRows, + // Pin the first overlay line (menu title) when the menu is taller than + // the chrome budget — otherwise /help's title was scrolled off forever. + chrome: pinOverlayChrome(chromeAll, chromeRows), + }); + const insets = outerInsets(this.rows, this.cols); + const screen = wrapOuterFrame(content, this.rows, this.cols, { + splitCol: layout.panel !== null ? layout.main.cols : undefined, + }).map((line) => withOpaqueBg(line, CONSOLE.bg)); + + this.flushScreen(screen, { + inputStart: insets.originRow + layout.input.row, + cursor: inputBand.cursor, + inputCol: insets.originCol + layout.input.col, + }); + } + + private railCounts(): { done: number; total: number } { + const badge = this.worklistBadge.trim(); + const fromBadge = /^(\d+)\/(\d+)$/.exec(badge); + + if (fromBadge !== null) { + return { + done: Number(fromBadge[1]), + total: Number(fromBadge[2]), + }; + } + + const head = stripSgr(this.panelLines[0] ?? ""); + const fromHead = /^worklist\s+(\d+)\/(\d+)/.exec(head); + + if (fromHead !== null) { + return { + done: Number(fromHead[1]), + total: Number(fromHead[2]), + }; + } + + return { done: 0, total: 0 }; + } + + private topbarLines( + cols: number, + layout: ReturnType + ): string[] { + const counts = this.railCounts(); + const rawBadge = + this.worklistBadge.length > 0 + ? this.worklistBadge + : `${String(counts.done)}/${String(counts.total)}`; + const badge = + this.flashHeaderPaints > 0 + ? paint(rawBadge, CONSOLE.bright, true) + : rawBadge; + + return formatConsoleTopbar({ + info: this.status, + cwd: this.cwd, + sessionId: this.sessionId, + worklistBadge: badge, + cols, + splitCol: layout.panel !== null ? layout.main.cols : undefined, + padTop: layout.top.rows >= TOP_STATUS_ROWS, + }); + } + + private composeTop(layout: ReturnType): string[] { + if (layout.top.rows <= 0) { + return []; + } + + const lines = this.topbarLines(layout.top.cols, layout); + + while (lines.length < layout.top.rows) { + lines.push(""); + } + + return lines.slice(0, layout.top.rows); + } + + private applyScrollbackViewport(cols: number, mainRows: number): void { + if (cols !== this.lastWrapCols) { + this.scrollback.reflow(cols); + this.lastWrapCols = cols; + } else { + this.scrollback.setWrapCols(cols); + } + + this.scrollback.setViewportRows(mainRows); + } + + private composeScreen(opts: { + layout: ReturnType; + topLines: string[]; + inputBand: { + lines: string[]; + cursor: { row: number; col: number }; + }; + bodyGap: number; + mainRows: number; + chromeRows: number; + chrome: string[]; + }): string[] { + const { layout } = opts; + const contentCols = layout.top.cols; + const contentRows = Math.max(1, layout.footer.row + layout.footer.rows); + const screen: string[] = new Array(contentRows); + const mainVisible = this.scrollback.visible(); + const panelCols = layout.panel?.cols ?? 0; + const panelSource = + layout.panel !== null ? this.panelPaintLines(panelCols) : []; + const gutter = paint( + GUTTER, + this.focus.panelFocused ? CONSOLE.bright : CONSOLE.rule, + true + ); + const ruleGutter = paint( + "├", + this.focus.panelFocused ? CONSOLE.bright : CONSOLE.rule, + true + ); + + for (let i = 0; i < layout.top.rows; i += 1) { + screen[layout.top.row + i] = fitAnsiLine( + opts.topLines[i] ?? "", + contentCols + ); + } + + const gapStart = layout.main.row; + + for (let g = 0; g < opts.bodyGap; g += 1) { + screen[gapStart + g] = paintSplitRow( + fitAnsiLine("", layout.panel !== null ? layout.main.cols : contentCols), + gutter, + layout.panel !== null ? fitAnsiLine("", panelCols) : null + ); + } + + const bodyStart = gapStart + opts.bodyGap; + const mainCols = layout.panel !== null ? layout.main.cols : contentCols; + const scrollbar = formatScrollbarColumn( + this.scrollback.metrics(), + opts.mainRows, + true + ); + + for (let r = 0; r < opts.mainRows; r += 1) { + screen[bodyStart + r] = buildMainSplitContent({ + mainText: mainVisible[r] ?? "", + mainCols, + thumb: scrollbar?.[r], + gutter, + ruleGutter, + panelCell: + layout.panel !== null + ? fitPanelCell(panelSource[r] ?? "", panelCols) + : null, + }); + } + + this.fillSplitChrome(screen, { + layout, + gutter, + panelSource, + panelCols, + contentCols, + bodyStart, + mainRows: opts.mainRows, + chromeRows: opts.chromeRows, + chrome: opts.chrome, + inputBand: opts.inputBand, + }); + + for (let r = 0; r < contentRows; r += 1) { + screen[r] ??= fitAnsiLine("", contentCols); + } + + return screen; + } + + /** Overlay/input/footer rows that share the panel gutter spine. */ + private fillSplitChrome( + screen: string[], + opts: { + readonly layout: ReturnType; + readonly gutter: string; + readonly panelSource: readonly string[]; + readonly panelCols: number; + readonly contentCols: number; + readonly bodyStart: number; + readonly mainRows: number; + readonly chromeRows: number; + readonly chrome: readonly string[]; + readonly inputBand: { readonly lines: readonly string[] }; + } + ): void { + const { layout } = opts; + const hasPanel = layout.panel !== null; + + for (let i = 0; i < opts.chromeRows; i += 1) { + const r = opts.mainRows + i; + const main = insetX( + opts.chrome[i] ?? "", + hasPanel ? layout.main.cols : opts.contentCols + ); + + screen[opts.bodyStart + r] = hasPanel + ? paintSplitRow( + main, + opts.gutter, + fitPanelCell(opts.panelSource[r] ?? "", opts.panelCols) + ) + : main; + } + + const inputMainCols = hasPanel ? layout.main.cols : opts.contentCols; + const spineBase = opts.mainRows + opts.chromeRows; + + for (let i = 0; i < layout.input.rows; i += 1) { + const mainLine = fitAnsiLine( + opts.inputBand.lines[i] ?? "", + inputMainCols + ); + + screen[layout.input.row + i] = hasPanel + ? paintSplitRow( + mainLine, + opts.gutter, + fitPanelCell(opts.panelSource[spineBase + i] ?? "", opts.panelCols) + ) + : mainLine; + } + + for (let i = 0; i < layout.footer.rows; i += 1) { + const spineIdx = spineBase + layout.input.rows + i; + + screen[layout.footer.row + i] = hasPanel + ? paintSplitRow( + fitAnsiLine("", layout.main.cols), + opts.gutter, + fitPanelCell(opts.panelSource[spineIdx] ?? "", opts.panelCols) + ) + : fitAnsiLine("", opts.contentCols); + } + } + + private flushScreen( + screen: string[], + opts: { + inputStart: number; + cursor: { row: number; col: number }; + inputCol: number; + } + ): void { + let dirty = ""; + + if (this.geometryDirty) { + dirty += CLEAR_SCREEN; + this.geometryDirty = false; + this.prevLines = null; + } + + for (let r = 0; r < this.rows; r += 1) { + const line = screen[r] ?? fitAnsiLine("", this.cols); + + if (this.prevLines?.[r] !== line) { + dirty += + cup(r + 1, 1) + lastRowSafe(line, r, this.rows, this.cols) + EL_EOL; + } + } + + const cursorRow = opts.inputStart + opts.cursor.row + 1; + const cursorCol = opts.inputCol + opts.cursor.col + 1; + + // Row paints leave the hardware cursor at the end of the last dirty line. + // Force a CUP after any dirty write so CursorState dedupe cannot strand it + // on the footer. Pure no-op paints (no dirty) write nothing. + if (dirty.length > 0) { + this.cursor.reset(); + const cursorBytes = this.cursor.move(cursorRow, cursorCol); + + this.out.write(BEGIN_SYNC + dirty + cursorBytes + END_SYNC); + } + + this.prevLines = screen; + } + + /** Re-home the caret onto the input row (e.g. after prompt() with no dirty rows). */ + rehomeCursor(): void { + if (!this.entered) { + return; + } + + const insets = outerInsets(this.rows, this.cols); + const layout = computeLayout(this.layoutOpts()); + const band = this.paintInputBand(layout); + const cursorRow = insets.originRow + layout.input.row + band.cursor.row + 1; + const cursorCol = insets.originCol + layout.input.col + band.cursor.col + 1; + + this.cursor.reset(); + this.out.write( + BEGIN_SYNC + this.cursor.move(cursorRow, cursorCol) + END_SYNC + ); + } + + /** + * Panel column lines for the body viewport: sticky Tasks title + under-rule, + * then the scrolled item list. Title stays put while the body scrolls. + */ + private panelPaintLines(panelCols: number): string[] { + const counts = this.railCounts(); + const title = formatRailTitleBlock({ + done: counts.done, + total: counts.total, + cols: panelCols, + color: true, + }); + const raw = this.panelBodyLines(); + const view = this.panelBodyViewRows(); + const slice = raw.slice(this.panelOffset, this.panelOffset + view); + + // Checklist lines already carry CONSOLE hierarchy (current bright, done muted). + // Do not blanket-dim — that erased the current-item accent. + if (!this.focus.panelFocused) { + return [...title, ...slice]; + } + + const body = slice.map((line, i) => { + const abs = this.panelOffset + i; + const plain = stripSgr(line); + + if (abs !== this.focus.selection) { + return line; + } + + // Current work item already leads with ▸ — recolor, don't double the gutter. + if (plain.startsWith("▸")) { + return paint(plain, CONSOLE.bright, true); + } + + return paint(`▸ ${plain}`, CONSOLE.bright, true); + }); + + return [...title, ...body]; + } + + /** Rail body lines — skip the legacy `worklist N/M` header (Tasks title owns it). */ + private panelBodyLines(): string[] { + if (this.panelLines.length === 0) { + return [...EMPTY_PANEL_LINES]; + } + + const head = stripSgr(this.panelLines[0] ?? ""); + + if (head === "worklist" || /^worklist\s+\d+\/\d+/.test(head)) { + const rest = this.panelLines.slice(1); + + return rest.length > 0 ? [...rest] : [...EMPTY_PANEL_LINES]; + } + + return [...this.panelLines]; + } + + /** + * Closed input box aligned to the agent/user card: + * same left inset and width as `mainInnerCols()` (not full-bleed). + * Grows with draft visual lines (capped); Enter clears → 1 mid row again. + * ╭────╮ + * │ > │ + * ╰────╯ + */ + private paintInputBand(layout: ReturnType): { + lines: string[]; + cursor: { row: number; col: number }; + } { + const inner = this.draftInnerRows(); + const wantedBand = inputBandRows(inner); + const bandRows = Math.max(1, Math.min(layout.input.rows, wantedBand)); + const lines = this.input.lines.length > 0 ? this.input.lines : [""]; + const cursorRow = Math.max( + 0, + Math.min(this.input.cursorRow, lines.length - 1) + ); + // Editor already windows to INPUT_INNER_ROWS_MAX — take what it gave us. + const draftLines = lines.slice(0, inner); + const emptyDraft = + draftLines.length === 1 && + (draftLines[0] ?? "").length === 0 && + this.focus.promptFocused; + const label = formatInputStatusLabel(this.status); + // Match AGENT/USER card width inside the main pane. + const pad = CHROME_PAD_X; + const boxCols = Math.max(8, insetInnerCols(layout.main.cols, pad)); + const box = formatInputBox({ + cols: boxCols, + draftLines: emptyDraft ? [""] : draftLines, + placeholder: FORGE_PLACEHOLDER, + label, + color: true, + showPlaceholder: emptyDraft, + }); + let painted = [...box.lines]; + + // Short terminals may clip the closed box — keep the mid+caret visible. + if (painted.length > bandRows) { + if (bandRows >= 2) { + painted = painted.slice(0, bandRows - 1); + painted.push(box.lines[box.lines.length - 1] ?? ""); + } else { + painted = painted.slice(1, 1 + bandRows); + } + } + + while (painted.length < bandRows) { + painted.push(fitAnsiLine("", boxCols)); + } + + const midRow = + bandRows >= 3 ? 1 + cursorRow : Math.min(cursorRow, bandRows - 1); + const left = " ".repeat(pad); + // Main-column width only — compose stamps the gutter + panel beside us. + const band = painted.map((row) => + fitAnsiLine(`${left}${row}`, layout.main.cols) + ); + + return { + lines: band.slice(0, bandRows), + cursor: { + row: Math.min(midRow, bandRows - 1), + col: pad + inputCursorCol(this.input.cursorCol), + }, + }; + } +} + +/** main │ panel — gutter column is the continuous frame spine. */ +function paintSplitRow( + main: string, + gutter: string, + panel: string | null +): string { + if (panel === null) { + return main; + } + + return `${main}${gutter}${panel}`; +} + +/** + * Panel cells: sticky title/rule are already sized to `cols` — don't double + * inset (that clipped the right-hand count). Body lines still get insetX. + */ +function fitPanelCell(line: string, cols: number): string { + if (cols <= 0) { + return ""; + } + + if (displayWidth(stripSgr(line)) === cols) { + return fitAnsiLine(line, cols); + } + + return insetX(line, cols); +} + +/** Sticky Tasks under-rule — full panel width of `─` (joins via `├`/`┤`). */ +function isRailUnderRule(panelCell: string): boolean { + const plain = stripSgr(panelCell); + + return plain.length > 0 && /^─+$/u.test(plain); +} + +/** + * When an overlay/menu exceeds the chrome budget, keep the first line (title) + * and the tail (selection + footer) so the header is never scrolled away. + */ +function pinOverlayChrome(lines: readonly string[], budget: number): string[] { + if (budget <= 0) { + return []; + } + + if (lines.length <= budget) { + return [...lines]; + } + + if (budget === 1) { + return [lines[0] ?? ""]; + } + + const head = lines[0] ?? ""; + const tail = lines.slice(lines.length - (budget - 1)); + + return [head, ...tail]; +} + +/** One main-pane body row (+ optional panel), with scrollbar thumb overlay. */ +function buildMainSplitContent(opts: { + readonly mainText: string; + readonly mainCols: number; + readonly thumb: string | undefined; + readonly gutter: string; + readonly ruleGutter: string; + readonly panelCell: string | null; +}): string { + let main = insetX(opts.mainText, opts.mainCols); + + if (opts.thumb !== undefined && opts.thumb !== " ") { + main = overlayScrollbarCol(main, opts.mainCols, opts.thumb); + } + + if (opts.panelCell === null) { + return main; + } + + const splitGutter = isRailUnderRule(opts.panelCell) + ? opts.ruleGutter + : opts.gutter; + + return paintSplitRow(main, splitGutter, opts.panelCell); +} + +/** + * Writing into the bottom-right cell wraps the alt screen (xterm etc.), scrolling + * the frame and parking the cursor on a phantom row under the footer. Keep the + * last row at most `cols - 1` wide; EL_EOL clears the final cell. + */ +function lastRowSafe( + fittedLine: string, + row: number, + rows: number, + cols: number +): string { + if (row !== rows - 1 || cols <= 1) { + return fittedLine; + } + + if (displayWidth(stripSgr(fittedLine)) < cols) { + return fittedLine; + } + + // fitAnsiLine pads with trailing spaces — drop one to free the corner cell. + if (fittedLine.endsWith(" ")) { + return fittedLine.slice(0, -1); + } + + return fitAnsiLine(fittedLine, cols - 1); +} diff --git a/packages/core/src/render/frame/scrollback.ts b/packages/core/src/render/frame/scrollback.ts new file mode 100644 index 00000000..c944509a --- /dev/null +++ b/packages/core/src/render/frame/scrollback.ts @@ -0,0 +1,372 @@ +import { wrapAnsiLines } from "./wrap-line"; +import type { IScrollMetrics } from "./scrollbar"; + +/** Width-stable bookmark for the viewport top (Grok ScrollAnchor idea). */ +export interface IScrollAnchor { + /** Index into logical (unwrapped) lines. */ + readonly logicalIndex: number; + /** Wrapped-row offset within that logical line. */ + readonly offsetInEntry: number; +} + +/** + * Line ring buffer + viewport for the main pane. Newest lines append at the end; + * the viewport shows a window that sticks to the bottom unless the user scrolls up. + * + * Logical lines are wrapped to the pane width at viewport time so resize reflows + * and scroll never "loses" the tail of a truncated row. + * + * Wrapped rows are cached — recomputing wrap over thousands of transcript lines + * on every keystroke (via PaneScreen.paint) was multi‑tens-of-ms of lag. + */ +export class Scrollback { + private lines: string[] = []; + /** Incomplete trailing line (not yet terminated by `\n`). */ + private partial = ""; + private offsetFromBottom = 0; + private wrapCols = 80; + /** Invalidated on append / clear / wrap-width change. */ + private cachedWrapped: string[] | null = null; + /** Overflow flag from the last following viewport walk (`view` keyed). */ + private followingOverflow = false; + private followingOverflowView = -1; + + constructor( + private readonly capacity = 5_000, + private viewportRows = 1 + ) {} + + setViewportRows(rows: number): void { + const next = Math.max(1, rows); + + if (next !== this.viewportRows) { + this.followingOverflowView = -1; + } + + this.viewportRows = next; + this.clampOffset(); + } + + /** Column width used when wrapping logical lines into the viewport. */ + setWrapCols(cols: number): void { + const next = Math.max(1, cols); + + if (next === this.wrapCols) { + this.clampOffset(); + + return; + } + + this.wrapCols = next; + this.invalidateWrap(); + this.clampOffset(); + } + + /** + * Change wrap width; when scrolled up, re-pin via anchor so the same logical + * line stays at the viewport top. + */ + reflow(cols: number): void { + const following = this.offsetFromBottom === 0; + const anchor = following ? null : this.captureAnchor(); + const next = Math.max(1, cols); + + if (next !== this.wrapCols) { + this.wrapCols = next; + this.invalidateWrap(); + } + + if (anchor !== null) { + this.restoreAnchor(anchor); + } else { + this.offsetFromBottom = 0; + this.clampOffset(); + } + } + + /** Append text, splitting on newlines. Bare `\r` is ignored. */ + append(text: string): void { + const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, ""); + + if (normalized.length === 0) { + return; + } + + const combined = this.partial + normalized; + const parts = combined.split("\n"); + + this.partial = parts.pop() ?? ""; + + for (const line of parts) { + this.lines.push(line); + } + + let trimmed = false; + + while (this.lines.length > this.capacity) { + this.lines.shift(); + trimmed = true; + } + + this.invalidateWrap(); + + // Following (offset 0) never needs a wrap pass to clamp. Rebuilding the + // wrap cache on every streamed line was O(n²) and made typing after a long + // transcript feel like molasses once paint touched scrollback. + if (trimmed || this.offsetFromBottom > 0) { + this.clampOffset(); + } + } + + /** Scroll by delta wrapped rows (positive = older / up; negative = newer / down). */ + scroll(delta: number): void { + this.offsetFromBottom = Math.max(0, this.offsetFromBottom + delta); + this.clampOffset(); + } + + /** Jump to the newest content. */ + follow(): void { + this.offsetFromBottom = 0; + } + + /** Drop all buffered lines (e.g. `/clear` in the pane TUI). */ + clear(): void { + this.lines = []; + this.partial = ""; + this.offsetFromBottom = 0; + this.invalidateWrap(); + } + + get following(): boolean { + return this.offsetFromBottom === 0; + } + + /** + * Metrics for the main-pane scrollbar. + * Following + overflow avoids a full wrap of the transcript (thumb sits at + * the bottom); scrolled-up reuses the wrap cache already built by `visible()`. + */ + metrics(): IScrollMetrics { + const viewport = this.viewportRows; + + if (this.offsetFromBottom === 0) { + const overflow = this.hasOverflowFollowing(viewport); + + return { + total: overflow ? viewport + 1 : viewport, + viewport, + offset: overflow ? viewport : 0, + following: true, + }; + } + + const total = this.wrapped().length; + const maxOffset = Math.max(0, total - viewport); + + return { + total, + viewport, + offset: Math.max(0, maxOffset - this.offsetFromBottom), + following: false, + }; + } + + /** All complete lines plus the current partial, for viewport/dump. */ + private allLines(): string[] { + if (this.partial.length === 0) { + return this.lines; + } + + return [...this.lines, this.partial]; + } + + private invalidateWrap(): void { + this.cachedWrapped = null; + this.followingOverflowView = -1; + } + + private wrapped(): string[] { + this.cachedWrapped ??= wrapAnsiLines(this.allLines(), this.wrapCols); + + return this.cachedWrapped; + } + + /** + * Map each wrapped row to its logical line index and offset within that line. + */ + private wrapMap(): { rows: string[]; owners: IScrollAnchor[] } { + const logical = this.allLines(); + const rows: string[] = []; + const owners: IScrollAnchor[] = []; + + for (let i = 0; i < logical.length; i += 1) { + const parts = wrapAnsiLines([logical[i] ?? ""], this.wrapCols); + + for (let o = 0; o < parts.length; o += 1) { + rows.push(parts[o] ?? ""); + owners.push({ logicalIndex: i, offsetInEntry: o }); + } + } + + return { rows, owners }; + } + + /** Visible wrapped rows for the current viewport (top → bottom). */ + visible(): string[] { + const view = this.viewportRows; + + // Hot path: stick-to-bottom. Walk logical lines from the tail so a long + // transcript never pays for wrapping the entire buffer on every paint. + if (this.offsetFromBottom === 0) { + return this.visibleFollowing(view); + } + + const all = this.wrapped(); + const end = all.length - this.offsetFromBottom; + const start = Math.max(0, end - view); + const slice = all.slice(start, end); + + while (slice.length < view) { + slice.unshift(""); + } + + return slice; + } + + /** Bottom-following viewport — O(viewport) wraps, not O(transcript). */ + private visibleFollowing(view: number): string[] { + const logical = this.allLines(); + const collected: string[] = []; + let moreAbove = false; + + for (let i = logical.length - 1; i >= 0; i -= 1) { + const parts = wrapAnsiLines([logical[i] ?? ""], this.wrapCols); + + for (let j = parts.length - 1; j >= 0; j -= 1) { + if (collected.length >= view) { + moreAbove = true; + i = -1; + break; + } + + collected.unshift(parts[j] ?? ""); + } + } + + this.followingOverflow = moreAbove; + this.followingOverflowView = view; + + // Short content: top-align (pad below) so the landing isn't a void above. + if (!moreAbove) { + while (collected.length < view) { + collected.push(""); + } + + return collected; + } + + while (collected.length < view) { + collected.unshift(""); + } + + return collected; + } + + /** + * True when following and there are wrapped rows above the viewport. + * Reuses the flag from `visibleFollowing` when that walk already ran for + * the same view (compose calls `visible()` before `metrics()`). + */ + private hasOverflowFollowing(view: number): boolean { + if (this.followingOverflowView === view) { + return this.followingOverflow; + } + + const logical = this.allLines(); + let count = 0; + + for (let i = logical.length - 1; i >= 0; i -= 1) { + const parts = wrapAnsiLines([logical[i] ?? ""], this.wrapCols); + + count += parts.length; + + if (count > view) { + this.followingOverflow = true; + this.followingOverflowView = view; + + return true; + } + } + + this.followingOverflow = false; + this.followingOverflowView = view; + + return false; + } + + /** Capture anchor for the current viewport top when scrolled up. */ + captureAnchor(): IScrollAnchor | null { + if (this.offsetFromBottom === 0) { + return null; + } + + const { rows, owners } = this.wrapMap(); + const end = rows.length - this.offsetFromBottom; + const start = Math.max(0, end - this.viewportRows); + const owner = owners[start]; + + return owner ?? null; + } + + /** Restore viewport so `anchor` sits at the top after a wrap-width change. */ + restoreAnchor(anchor: IScrollAnchor): void { + const { rows, owners } = this.wrapMap(); + let top = 0; + + for (let i = 0; i < owners.length; i += 1) { + const o = owners[i]; + + if ( + o?.logicalIndex === anchor.logicalIndex && + o.offsetInEntry === anchor.offsetInEntry + ) { + top = i; + break; + } + + if ( + o?.logicalIndex === anchor.logicalIndex && + o.offsetInEntry > anchor.offsetInEntry + ) { + top = i; + break; + } + } + + const end = Math.min(rows.length, top + this.viewportRows); + + this.offsetFromBottom = Math.max(0, rows.length - end); + this.clampOffset(); + } + + /** Full transcript for dump-to-scrollback (logical lines, not wrapped). */ + dump(): string { + return this.allLines().join("\n"); + } + + get length(): number { + return this.allLines().length; + } + + private clampOffset(): void { + if (this.offsetFromBottom === 0) { + return; + } + + const maxOffset = Math.max(0, this.wrapped().length - this.viewportRows); + + if (this.offsetFromBottom > maxOffset) { + this.offsetFromBottom = maxOffset; + } + } +} diff --git a/packages/core/src/render/frame/scrollbar.ts b/packages/core/src/render/frame/scrollbar.ts new file mode 100644 index 00000000..32729618 --- /dev/null +++ b/packages/core/src/render/frame/scrollbar.ts @@ -0,0 +1,95 @@ +import { paint } from "../style"; +import { fitAnsiLine } from "./fit-line"; +import { CONSOLE } from "./chrome"; + +/** Scroll position for a vertical track (grok-build ScrollInfo shape). */ +export interface IScrollMetrics { + /** Wrapped content rows. */ + readonly total: number; + /** Visible rows in the track. */ + readonly viewport: number; + /** Rows from the top of the content (0 = showing the oldest). */ + readonly offset: number; + /** Stick-to-bottom live mode. */ + readonly following: boolean; +} + +const THUMB = "█"; + +/** Content overflows the viewport — show the track. */ +export function needsScrollbar(metrics: IScrollMetrics): boolean { + return metrics.total > metrics.viewport && metrics.viewport > 0; +} + +/** + * Inclusive-exclusive `[start, end)` thumb window on a `track`-tall column. + * Proportional to viewport/total; pinned to the bottom while following. + */ +export function thumbWindow( + metrics: IScrollMetrics, + track: number +): { start: number; end: number } | null { + if (!needsScrollbar(metrics) || track <= 0) { + return null; + } + + const thumbLen = Math.min( + track, + Math.max(1, Math.round((metrics.viewport / metrics.total) * track)) + ); + const travel = track - thumbLen; + const maxOffset = Math.max(1, metrics.total - metrics.viewport); + const offset = metrics.following + ? maxOffset + : Math.max(0, Math.min(metrics.offset, maxOffset)); + const start = travel === 0 ? 0 : Math.round((offset / maxOffset) * travel); + + return { start, end: start + thumbLen }; +} + +/** + * One painted cell per track row: dim/bright `█` thumb over blank track. + * Following → muted thumb (content is live); scrolled-up → brighter. + */ +export function formatScrollbarColumn( + metrics: IScrollMetrics, + track: number, + color = true +): string[] | null { + const win = thumbWindow(metrics, track); + + if (win === null) { + return null; + } + + const thumbStyle = metrics.following ? CONSOLE.muted : CONSOLE.bright; + const thumb = paint(THUMB, thumbStyle, color); + const cells: string[] = []; + + for (let r = 0; r < track; r += 1) { + cells.push(r >= win.start && r < win.end ? thumb : " "); + } + + return cells; +} + +/** + * Replace the rightmost column of a main-pane line with a scrollbar cell. + * Uses the existing right inset pad so wrap width stays unchanged. + * Fast path: `insetX` pads with trailing spaces — drop one and append. + */ +export function overlayScrollbarCol( + line: string, + cols: number, + cell: string +): string { + if (cols <= 1) { + return cell; + } + + if (line.endsWith(" ")) { + return `${line.slice(0, -1)}${cell}`; + } + + return `${fitAnsiLine(line, cols - 1)}${cell}`; +} diff --git a/packages/core/src/render/frame/wrap-line.ts b/packages/core/src/render/frame/wrap-line.ts new file mode 100644 index 00000000..c972c42d --- /dev/null +++ b/packages/core/src/render/frame/wrap-line.ts @@ -0,0 +1,260 @@ +import { STYLE, paint } from "../style"; +import { displayWidth, sliceToWidth } from "../width"; +import { stripSgr } from "./ansi-plain"; + +/** Left-rail prefixes that must repeat on every soft-wrapped continuation row. */ +const HANG_PREFIX = /^(│ {2}|│ |▌ {2}|▌ |\| {2}|\| )/; + +/** + * Wrap a (possibly ANSI) line to `cols` columns. + * Prefers word boundaries; when the line starts with a card rail (`│ ` / `▌ `), + * every continuation row re-emits that prefix so scrollback reflow cannot break + * the left gutter. Closed agent rows (`│ … │`) keep both rails on each wrap. + */ +/** Chrome / cyan / plan — whichever the source line used on its rails. */ +function boxedRailCode(line: string): string { + if (line.includes(STYLE.cyan)) { + return STYLE.cyan; + } + + if (line.includes(STYLE.plan)) { + return STYLE.plan; + } + + return STYLE.chrome; +} + +/** Empty closed row as one SGR span (avoids a dark/bright right-rail fleck). */ +function atomicBoxedPad(cols: number, railCode: string): string { + return paint(`│${" ".repeat(Math.max(0, cols - 2))}│`, railCode, true); +} + +export function wrapAnsiLine(line: string, cols: number): string[] { + if (cols <= 0) { + return [""]; + } + + const plain = stripSgr(line); + + if (plain.length === 0) { + return [""]; + } + + const boxed = parseBoxedRow(plain); + const railCode = boxedRailCode(line); + + // Fits: still re-seal empty boxed rows. A mid-line RESET between `│…│` + // left the right rail on the default/dark FG in iTerm on blank card rows. + if (displayWidth(plain) <= cols) { + if (boxed !== null && boxed.body.trim().length === 0) { + return [atomicBoxedPad(cols, railCode)]; + } + + if (boxed !== null) { + return [resealBoxedRails(line, boxed, cols, railCode)]; + } + + return [line]; + } + + if (boxed !== null) { + const inner = Math.max(1, cols - boxed.leftCols - boxed.rightCols); + const bodyRows = wrapPlainWords(boxed.body, inner); + const leftPad = boxed.left.slice(1); // spaces after the glyph + const left = paint("│", railCode, true) + leftPad; + const right = paint("│", railCode, true); + + return bodyRows.map((row) => { + if (row.length === 0) { + return atomicBoxedPad(cols, railCode); + } + + const pad = Math.max(0, inner - displayWidth(row)); + + return `${left}${row}${" ".repeat(pad)}${right}`; + }); + } + + const hang = HANG_PREFIX.exec(plain); + const prefix = hang?.[1] ?? ""; + const prefixCols = displayWidth(prefix); + const body = prefix.length > 0 ? plain.slice(prefix.length) : plain; + const inner = Math.max(1, cols - prefixCols); + const bodyRows = wrapPlainWords(body, inner); + + return bodyRows.map((row) => `${prefix}${row}`); +} + +/** + * Keep content SGR, but force both rails to `railCode` and pad to `cols` + * so the right │ cannot inherit a stale/default foreground. + */ +function resealBoxedRails( + line: string, + boxed: { + left: string; + leftCols: number; + rightCols: number; + body: string; + }, + cols: number, + railCode: string +): string { + const inner = Math.max(1, cols - boxed.leftCols - boxed.rightCols); + const bodyAnsi = extractBoxedBodyAnsi(line, boxed.left.length); + const pad = Math.max(0, inner - displayWidth(boxed.body)); + const leftPad = boxed.left.slice(1); + const left = paint("│", railCode, true) + leftPad; + const right = paint("│", railCode, true); + + return `${left}${bodyAnsi}${" ".repeat(pad)}${right}`; +} + +/** Visible body between the leading `│…` rail and the trailing `│`. */ +function extractBoxedBodyAnsi(line: string, leftPlainLen: number): string { + let plainCount = 0; + let i = 0; + let start = 0; + + while (i < line.length) { + if (line[i] === "\x1b") { + const end = line.indexOf("m", i); + + if (end === -1) { + break; + } + + i = end + 1; + continue; + } + + plainCount += 1; + i += 1; + + if (plainCount === leftPlainLen) { + start = i; + break; + } + } + + const lastPipe = line.lastIndexOf("│"); + + if (lastPipe <= start) { + return ""; + } + + // Drop the trailing rail and any SGR that paints it (…chrome│reset). + let end = lastPipe; + + while (end > start && line[end - 1] === "m") { + const esc = line.lastIndexOf("\x1b", end - 1); + + if (esc < start || line[esc + 1] !== "[") { + break; + } + + end = esc; + } + + return line.slice(start, end).trimEnd(); +} + +/** Detect a closed card row: `│ … │`. */ +function parseBoxedRow(plain: string): { + left: string; + right: string; + leftCols: number; + rightCols: number; + body: string; +} | null { + if (!plain.startsWith("│") || !plain.endsWith("│") || plain.length < 2) { + return null; + } + + // `│ content… │` / `│ content… │` / `│content…│` — keep the left pad intact. + const left = plain.startsWith("│ ") + ? "│ " + : plain.startsWith("│ ") + ? "│ " + : "│"; + const right = "│"; + const body = plain.slice(left.length, plain.length - right.length).trimEnd(); + + return { + left, + right, + leftCols: displayWidth(left), + rightCols: displayWidth(right), + body, + }; +} + +/** Hard-break one token wider than `width` into `out`; return the leftover. */ +function breakWideToken(word: string, width: number, out: string[]): string { + let rest = word; + + while (displayWidth(rest) > width) { + const head = sliceToWidth(rest, width); + + if (head.text.length === 0) { + out.push(rest.slice(0, 1)); + rest = rest.slice(1); + continue; + } + + out.push(head.text); + rest = rest.slice(head.text.length); + } + + return rest; +} + +/** Word-wrap plain text; hard-break a single token wider than `width`. */ +function wrapPlainWords(text: string, width: number): string[] { + if (width <= 0) { + return [text]; + } + + const out: string[] = []; + + for (const rawLine of text.split("\n")) { + let cur = ""; + + for (const word of rawLine.split(" ")) { + const candidate = cur.length === 0 ? word : `${cur} ${word}`; + + if (displayWidth(candidate) <= width) { + cur = candidate; + continue; + } + + if (cur.length > 0) { + out.push(cur); + } + + cur = breakWideToken(word, width, out); + } + + out.push(cur); + } + + return out.length > 0 ? out : [""]; +} + +/** Wrap every logical line; empty input → one empty row. */ +export function wrapAnsiLines( + lines: readonly string[], + cols: number +): string[] { + if (lines.length === 0) { + return [""]; + } + + const out: string[] = []; + + for (const line of lines) { + out.push(...wrapAnsiLine(line, cols)); + } + + return out; +} diff --git a/packages/core/src/render/index.ts b/packages/core/src/render/index.ts index b513cf38..00233356 100644 --- a/packages/core/src/render/index.ts +++ b/packages/core/src/render/index.ts @@ -10,10 +10,19 @@ export { userBubble, agentCardTop, agentCardBottom, + agentCardRow, + agentCardPadRow, agentBar, + agentRight, + agentRailInnerCols, + roleCardCols, + filledRoleBadge, + roleBadgeCols, + roleHairline, } from "./ansi"; export { StatusBar, + formatStatusBarLine, MIN_ROWS, PROMPT_COLS, type IStatusBarTerminal, @@ -23,6 +32,17 @@ export { box, table, GLYPH } from "./box"; export { renderMarkdown, formatTables, highlightCode } from "./markdown"; export { StreamingMarkdown } from "./stream-markdown"; export { STYLE, RESET, paint } from "./style"; +export { + formatOverlayShell, + formatMenuRow, + menuRule, + menuFooter, + menuScrollCue, + menuWindow, + menuBodyBudget, + MENU_FOOTER_NAV, + MENU_GUTTER_COLS, +} from "./menu-chrome"; export { makeAgentRail, type IAgentRail } from "./agent-rail"; export { formatAgentSummary, @@ -36,3 +56,18 @@ export { type IRowMeta, } from "./agent-tree"; export { LiveRegion, type ILiveRegionOut } from "./live-region"; +export { + PaneScreen, + computeLayout, + canUsePaneTui, + Scrollback, + PANE_MIN_ROWS, + INPUT_INNER_ROWS_MAX, + FORGE_PROMPT_COLS, + FORGE_EDITOR_GUTTER, + inputContentCols, + stripSgr, + stripMouseReports, + type IPaneInput, + type IPaneScreenTerminal, +} from "./frame"; diff --git a/packages/core/src/render/inline-menu.ts b/packages/core/src/render/inline-menu.ts index d5d9b04d..a86b11b6 100644 --- a/packages/core/src/render/inline-menu.ts +++ b/packages/core/src/render/inline-menu.ts @@ -1,6 +1,13 @@ import { emitKeypressEvents } from "node:readline"; import { STYLE, paint } from "./style"; -import { displayWidth, sliceToWidth } from "./width"; +import { + MENU_FOOTER_NAV, + formatMenuRow, + formatOverlayShell, + menuBodyBudget, + menuScrollCue, + menuWindow, +} from "./menu-chrome"; /** Keep `selected` within `[0, count)` (wraps), so ↑/↓ never points off-list. * Lives here (the menu core); `command-menu` re-exports it for its importers. */ @@ -18,56 +25,6 @@ export function clampIndex(selected: number, count: number): number { */ const MAX_VISIBLE = 8; -/** Terminal rows the status bar consumes BELOW the overlay (input row + bar - * border + bar + one row of margin). The overlay must fit in what remains, or - * the status bar's relative-redraw can't clear a region taller than the screen - * and the menu stacks as you scroll. */ -const REGION_CHROME_ROWS = 4; - -/** Non-row overlay lines: title + divider + describe + footer, plus up to two - * scroll indicators. Budgeted so the whole region fits the terminal height. */ -const OVERLAY_OVERHEAD = 6; - -const FOOTER = "↑/↓ move enter select esc close"; - -/** Clip to a display-column budget, grapheme-safe (never splits a wide cell). */ -function clip(text: string, max: number): string { - return sliceToWidth(text, max).text; -} - -/** One menu row: `› label hint`. The SELECTED row is the only styled - * line (brand + bold); every other row is plain default text so it stays fully - * legible. Composed as raw text and fitted to width BEFORE coloring, so clipping - * can never cut an ANSI escape. */ -function formatRow( - row: IMenuRowData, - active: boolean, - columns: number, - color: boolean -): string { - const avail = Math.max(0, columns - 2); // "› " / " " gutter - const hint = row.hint ?? ""; - let body: string; - - if (hint.length > 0) { - const shownHint = clip(hint, Math.floor(avail / 2)); - const labelMax = Math.max(0, avail - displayWidth(shownHint) - 1); - const shownLabel = clip(row.label, labelMax); - const gap = Math.max( - 1, - avail - displayWidth(shownLabel) - displayWidth(shownHint) - ); - - body = `${shownLabel}${" ".repeat(gap)}${shownHint}`; - } else { - body = clip(row.label, avail); - } - - const raw = `${active ? "›" : " "} ${body}`; - - return active ? paint(raw, `${STYLE.brand}${STYLE.bold}`, color) : raw; -} - /** Menu row data — flat list, no groups (cursor index == row index). */ export interface IMenuRowData { readonly id: string; @@ -99,7 +56,7 @@ interface IKeyInfo { * and a footer hint. Pure/width-aware so it can be asserted without a terminal. * Empty list ⇒ a single "no rows" line so the dropdown never silently vanishes. * - * Returns an array of formatted lines ready to paint via `statusBar.setOverlay()`. + * Returns lines ready for {@link PaneScreen.setOverlay}. */ export function formatMenuRows( rows: readonly IMenuRowData[], @@ -110,34 +67,26 @@ export function formatMenuRows( title: string ): string[] { const width = Math.max(20, columns); - const lines: string[] = []; - - // Title: a crisp bold header at the TOP (default color — NOT blue; only the - // selected row is blue). - lines.push(paint(clip(title, width), STYLE.bold, color)); if (rows.length === 0) { - lines.push(` ${paint("(no items)", STYLE.dim, color)}`); - lines.push(paint(clip(FOOTER, width), STYLE.dim, color)); - - return lines; + return formatOverlayShell({ + title, + bodyLines: [` ${paint("(no items)", STYLE.dim, color)}`], + footer: MENU_FOOTER_NAV, + columns: width, + color, + }); } - // Cap visible rows so the WHOLE region (overlay + input + bar) fits the - // terminal height — otherwise the status bar can't clear it and it stacks. - const budget = viewportRows > 0 ? viewportRows : 24; - const visible = Math.max( - 1, - Math.min(MAX_VISIBLE, budget - REGION_CHROME_ROWS - OVERLAY_OVERHEAD) + const bodyCap = Math.min( + MAX_VISIBLE, + menuBodyBudget(viewportRows, { hasDescribe: true }) ); - - // Scroll window: keep the cursor visible (flat list ⇒ cursor is a direct index). - const windowTop = Math.max(0, cursor - Math.floor(visible / 2)); - const end = Math.min(rows.length, windowTop + visible); - const start = Math.max(0, end - visible); + const { start, end } = menuWindow(rows.length, cursor, bodyCap); + const bodyLines: string[] = []; if (start > 0) { - lines.push(` ${paint(`↑ ${start} more`, STYLE.dim, color)}`); + bodyLines.push(menuScrollCue("up", start, color)); } for (let i = start; i < end; i += 1) { @@ -147,26 +96,31 @@ export function formatMenuRows( break; } - lines.push(formatRow(row, i === cursor, width, color)); + bodyLines.push( + formatMenuRow({ + label: row.label, + hint: row.hint, + active: i === cursor, + columns: width, + color, + }) + ); } if (end < rows.length) { - lines.push(` ${paint(`↓ ${rows.length - end} more`, STYLE.dim, color)}`); + bodyLines.push(menuScrollCue("down", rows.length - end, color)); } - // Divider + the selected row's full description (default color — legible) at the - // BOTTOM, then the footer hint. - lines.push(paint("─".repeat(width), STYLE.dim, color)); - const selected = rows[cursor]; - if (selected !== undefined) { - lines.push(clip(selected.describe, width)); - } - - lines.push(paint(clip(FOOTER, width), STYLE.dim, color)); - - return lines; + return formatOverlayShell({ + title, + bodyLines, + describe: selected?.describe ?? "", + footer: MENU_FOOTER_NAV, + columns: width, + color, + }); } /** @@ -177,6 +131,13 @@ export interface IInlineMenuDeps { readonly title: string; readonly render: (lines: readonly string[]) => void; readonly close: () => void; + /** Overlay width. Prefer main-pane inner cols when the pane console is live. */ + readonly columns?: number; + /** + * Max overlay rows (pane chrome budget). Prefer + * {@link PaneScreen.overlayBudgetRows} when panes are live. + */ + readonly viewportRows?: number; } /** @@ -199,7 +160,12 @@ export function runInlineMenu( return new Promise((resolve) => { let cursor = 0; - const columns = process.stdout.columns > 0 ? process.stdout.columns : 80; + const columns = + deps.columns !== undefined && deps.columns > 0 + ? deps.columns + : process.stdout.columns > 0 + ? process.stdout.columns + : 80; const color = process.stdout.isTTY; emitKeypressEvents(stdin); @@ -210,7 +176,12 @@ export function runInlineMenu( const draw = (): void => { cursor = clampIndex(cursor, rows.length); - const viewportRows = process.stdout.rows > 0 ? process.stdout.rows : 24; + const viewportRows = + deps.viewportRows !== undefined && deps.viewportRows > 0 + ? deps.viewportRows + : process.stdout.rows > 0 + ? process.stdout.rows + : 24; const lines = formatMenuRows( rows, cursor, diff --git a/packages/core/src/render/menu-chrome.ts b/packages/core/src/render/menu-chrome.ts new file mode 100644 index 00000000..cfdf4d3f --- /dev/null +++ b/packages/core/src/render/menu-chrome.ts @@ -0,0 +1,186 @@ +/** + * Shared overlay-menu chrome for the pane console. + * + * Every interactive overlay (/, /help, /config, @ picker, setup/scaffold) paints + * through {@link formatOverlayShell} + {@link formatMenuRow} so selection, + * rules, and footers stay one dialect — CONSOLE greens, not StatusBar-era cyan ›. + */ +import { CONSOLE } from "./frame/chrome"; +import { STYLE, paint } from "./style"; +import { displayWidth, sliceToWidth } from "./width"; + +/** Selection gutter width: `▸ ` / ` `. */ +export const MENU_GUTTER_COLS = 2; + +/** Default list-menu key hints. */ +export const MENU_FOOTER_NAV = "↑/↓ move · enter select · esc close"; + +/** Clip to a display-column budget, grapheme-safe. */ +export function menuClip(text: string, max: number): string { + return sliceToWidth(text, max).text; +} + +/** Hairline rule at content width — CONSOLE.rule (chrome gray). */ +export function menuRule(columns: number, color: boolean): string { + const width = Math.max(0, columns); + + return paint("─".repeat(width), CONSOLE.rule, color); +} + +/** Dim footer line, clipped to width. */ +export function menuFooter( + text: string, + columns: number, + color: boolean +): string { + return paint(menuClip(text, Math.max(0, columns)), CONSOLE.muted, color); +} + +/** Quiet scroll cue (`↑ N more` / `↓ N more`). */ +export function menuScrollCue( + direction: "up" | "down", + count: number, + color: boolean +): string { + const arrow = direction === "up" ? "↑" : "↓"; + + return ` ${paint(`${arrow} ${String(count)} more`, CONSOLE.muted, color)}`; +} + +/** + * One selectable row: `▸ label hint` when active (green), plain when not. + * Fitted to width BEFORE coloring so clipping never cuts an ANSI escape. + */ +export function formatMenuRow(opts: { + readonly label: string; + readonly hint?: string; + readonly active: boolean; + readonly columns: number; + readonly color: boolean; + /** Prefix inside the gutter body (e.g. `◉ ` / `◯ ` for multi-select). */ + readonly marker?: string; +}): string { + const width = Math.max(0, opts.columns); + const avail = Math.max(0, width - MENU_GUTTER_COLS); + const marker = opts.marker ?? ""; + const markerCols = displayWidth(marker); + const hint = opts.hint ?? ""; + let body: string; + + if (hint.length > 0) { + const shownHint = menuClip(hint, Math.floor(avail / 2)); + const labelMax = Math.max( + 0, + avail - markerCols - displayWidth(shownHint) - 1 + ); + const shownLabel = menuClip(opts.label, labelMax); + const gap = Math.max( + 1, + avail - markerCols - displayWidth(shownLabel) - displayWidth(shownHint) + ); + + body = `${marker}${shownLabel}${" ".repeat(gap)}${shownHint}`; + } else { + body = `${marker}${menuClip(opts.label, Math.max(0, avail - markerCols))}`; + } + + const gutter = opts.active ? "▸" : " "; + const raw = `${gutter} ${body}`; + + if (!opts.active) { + return raw; + } + + return paint(raw, CONSOLE.bright, opts.color); +} + +export interface IOverlayShellOpts { + readonly title: string; + /** Optional second header line (e.g. `Step 1 of 3 · Naming`). */ + readonly subtitle?: string; + /** Already-windowed content rows (scroll cues, options, sections). */ + readonly bodyLines: readonly string[]; + /** Selected-item blurb under a rule (list menus). */ + readonly describe?: string; + readonly footer: string; + readonly columns: number; + readonly color: boolean; + /** Default {@link STYLE.bold}; wizards pass {@link STYLE.brand}. */ + readonly titleStyle?: string; +} + +/** + * Compose a complete overlay block: title → subtitle? → body → rule+describe? → footer. + * Pure / width-aware for unit tests without a terminal. + */ +export function formatOverlayShell(opts: IOverlayShellOpts): string[] { + const width = Math.max(20, opts.columns); + const titleStyle = opts.titleStyle ?? STYLE.bold; + const lines: string[] = [ + paint(menuClip(opts.title, width), titleStyle, opts.color), + ]; + + if (opts.subtitle !== undefined && opts.subtitle.length > 0) { + lines.push(menuClip(opts.subtitle, width)); + } + + for (const line of opts.bodyLines) { + lines.push(line); + } + + if (opts.describe !== undefined) { + lines.push(menuRule(width, opts.color)); + lines.push(menuClip(opts.describe, width)); + } + + lines.push(menuFooter(opts.footer, width, opts.color)); + + return lines; +} + +/** + * Window a flat list so `cursor` stays visible within `visible` rows. + * Returns the slice bounds (end exclusive). + */ +export function menuWindow( + count: number, + cursor: number, + visible: number +): { start: number; end: number } { + if (count <= 0 || visible <= 0) { + return { start: 0, end: 0 }; + } + + const cap = Math.max(1, visible); + const windowTop = Math.max(0, cursor - Math.floor(cap / 2)); + const end = Math.min(count, windowTop + cap); + const start = Math.max(0, end - cap); + + return { start, end }; +} + +/** + * How many body option rows fit in an overlay budget after shell chrome. + * `budget` is total overlay rows available (pane chrome budget). + * Fixed chrome: title + rule + describe + footer (+ optional subtitle). + */ +export function menuBodyBudget( + overlayBudget: number, + opts: { readonly hasSubtitle?: boolean; readonly hasDescribe?: boolean } = {} +): number { + const budget = overlayBudget > 0 ? overlayBudget : 24; + // title + footer + (subtitle?) + (rule+describe?) + up to 2 scroll cues + let chrome = 2; // title + footer + + if (opts.hasSubtitle === true) { + chrome += 1; + } + + if (opts.hasDescribe !== false) { + chrome += 2; // rule + describe + } + + chrome += 2; // reserve for ↑/↓ cues + + return Math.max(1, budget - chrome); +} diff --git a/packages/core/src/render/status-bar.ts b/packages/core/src/render/status-bar.ts index d2adb63f..ac5e8a25 100644 --- a/packages/core/src/render/status-bar.ts +++ b/packages/core/src/render/status-bar.ts @@ -136,6 +136,18 @@ function assemble(segs: ISegment[], columns: number, color: boolean): string { return ` ${painted}`; } +/** + * Same metrics line (model, mode, activity, meter, tok/s, turns, status, scope). + * Shared formatter for pane-console footer chrome and StatusBar unit tests. + */ +export function formatStatusBarLine( + info: IStatusInfo, + columns: number, + color = true +): string { + return assemble(barSegments(info), columns, color); +} + /** A dim rule with a leading corner tick, spanning the width. */ function topBorder(columns: number, color: boolean): string { return paint(`╶${"─".repeat(Math.max(0, columns - 3))}`, STYLE.dim, color); @@ -253,6 +265,9 @@ export class StatusBar { * region — above the overlay and input — while subagents run. Its own slot so * it composes with the `@`-picker/palette overlay instead of fighting it. */ private agentTreeLines: readonly string[] = []; + /** Worklist checklist slot (gate-derived ticks), below the agent tree and above + * the overlay/input. */ + private worklistLines: readonly string[] = []; /** While a drag-resize storm is in flight, ALL painting is suspended and streamed * output is buffered; flushed once the size settles (so the region isn't churned * against a reflowing terminal). */ @@ -294,7 +309,11 @@ export class StatusBar { } { const columns = this.out.columns ?? 80; const info = this.lastInfo; - const lines: string[] = [...this.agentTreeLines, ...this.overlayLines]; + const lines: string[] = [ + ...this.agentTreeLines, + ...this.worklistLines, + ...this.overlayLines, + ]; let cursorRow: number; let cursorCol: number; @@ -530,6 +549,28 @@ export class StatusBar { } } + /** Show/replace the worklist checklist above the input row, then repaint. */ + setWorklist(lines: readonly string[]): void { + this.worklistLines = lines; + + if (this.installed && this.withInput) { + this.renderRegion(); + } + } + + /** Erase the worklist slot and repaint. Idempotent. */ + clearWorklist(): void { + if (this.worklistLines.length === 0) { + return; + } + + this.worklistLines = []; + + if (this.installed && this.withInput) { + this.renderRegion(); + } + } + /** Erase the `@`-picker dropdown and repaint. Idempotent. */ clearOverlay(info: IStatusInfo): void { this.lastInfo = info; diff --git a/packages/core/src/render/style.ts b/packages/core/src/render/style.ts index 0c169fac..ab5eab84 100644 --- a/packages/core/src/render/style.ts +++ b/packages/core/src/render/style.ts @@ -18,6 +18,22 @@ export const STYLE = { brandLight: `${ESC}[38;2;96;165;250m`, /** #2563eb — darker accent */ brandDark: `${ESC}[38;2;37;99;235m`, + /** #22d3ee — USER turn cyan */ + cyan: `${ESC}[38;2;34;211;238m`, + /** #22d3ee — filled USER badge background */ + cyanBg: `${ESC}[48;2;34;211;238m`, + /** #0a0a0a — ink on filled USER badge */ + ink: `${ESC}[38;2;10;10;10m`, + /** #52525b — AGENT / input chrome outline (readable on #141414; #3f3f46 vanished) */ + chrome: `${ESC}[38;2;82;82;91m`, + /** #f4f4f5 — filled AGENT badge background (light pill on dark canvas) */ + chromeBg: `${ESC}[48;2;244;244;245m`, + /** #0a0a0a — ink on filled AGENT badge */ + chromeInk: `${ESC}[38;2;10;10;10m`, + /** #ff9900 — plan-mode accent (hairline, rail, body) */ + plan: `${ESC}[38;2;255;153;0m`, + /** #ff9900 — filled PLAN badge background */ + planBg: `${ESC}[48;2;255;153;0m`, } as const; /** Wrap `text` in an ANSI code when color is on; otherwise return it untouched. */ @@ -29,3 +45,8 @@ export function paint(text: string, code: string, color: boolean): string { export function truecolor(r: number, g: number, b: number): string { return `${ESC}[38;2;${r};${g};${b}m`; } + +/** A 24-bit truecolor background SGR code (opaque canvas fill). */ +export function truecolorBg(r: number, g: number, b: number): string { + return `${ESC}[48;2;${r};${g};${b}m`; +} diff --git a/packages/core/src/render/width.ts b/packages/core/src/render/width.ts index fcf10ae4..dc26b037 100644 --- a/packages/core/src/render/width.ts +++ b/packages/core/src/render/width.ts @@ -4,22 +4,60 @@ export { graphemes } from "../editor/segments"; /** * Terminal display-width helpers. A monospace cell is one column, but a CJK - * ideograph, a fullwidth form, or an emoji occupies TWO, and combining / - * zero-width marks occupy NONE. Everywhere we size a box, pad a table cell, fit - * status segments, or wrap an editor line we previously counted `.length` - * (UTF-16 code units) or grapheme count — both wrong for wide text, which then - * overflowed or mis-aligned. These functions count *columns*, building on the - * existing grapheme segmenter so a cluster is measured as one unit. + * ideograph, a fullwidth form, or a *terminal-wide* emoji occupies TWO, and + * combining / zero-width marks occupy NONE. Everywhere we size a box, pad a + * table cell, fit status segments, or wrap an editor line we previously counted + * `.length` (UTF-16 code units) or grapheme count — both wrong for wide text, + * which then overflowed or mis-aligned. These functions count *columns*, + * building on the existing grapheme segmenter so a cluster is measured as one + * unit. + * + * Widths follow what common terminals (iTerm2, Terminal.app, VTE) advance for + * the cursor — roughly macOS/glibc `wcwidth`, *not* Unicode's ideal "every + * emoji presentation is 2". Neutral emoji like 🛋️ (U+1F6CB) advance one cell + * even when the glyph is double-wide; counting them as 2 under-pads closed + * cards and zig-zags the right rail. */ /** Half-open `[start, end)` code-point ranges, kept sorted for a binary search. */ type Range = readonly [start: number, end: number]; -/** Code points that render two columns wide: East Asian Wide/Fullwidth plus the - * emoji blocks that terminals draw double-width. Sorted ascending. */ +/** + * Code points that advance two columns. CJK / fullwidth plus the emoji and + * symbol scalars that terminals actually treat as wide (`wcwidth == 2`). + * Neutral emoji (🛋️, 🖥️, …) are intentionally absent. + */ const WIDE: readonly Range[] = [ [0x1100, 0x1160], // Hangul Jamo + [0x231a, 0x231c], // watch / hourglass [0x2329, 0x232b], // angle brackets + [0x2614, 0x2616], // umbrella / coffee + [0x2648, 0x2654], // zodiac + [0x267f, 0x2680], // wheelchair + [0x2693, 0x2694], // anchor + [0x26a1, 0x26a2], // high voltage + [0x26aa, 0x26ac], // circles + [0x26bd, 0x26bf], // soccer / baseball + [0x26c4, 0x26c6], // snowman + [0x26ce, 0x26cf], // Ophiuchus + [0x26d4, 0x26d5], // no entry + [0x26ea, 0x26eb], // church + [0x26f2, 0x26f4], // fountain + [0x26f5, 0x26f6], // sailboat + [0x26fa, 0x26fb], // tent + [0x26fd, 0x26fe], // fuel pump + [0x2705, 0x2706], // check mark + [0x270a, 0x270c], // fist / hand + [0x2728, 0x2729], // sparkles + [0x274c, 0x274d], // cross mark + [0x274e, 0x274f], // cross mark box + [0x2753, 0x2756], // question marks + [0x2757, 0x2758], // exclamation + [0x2795, 0x2798], // plus / minus + [0x27b0, 0x27b1], // curly loop + [0x27bf, 0x27c0], // double curly loop + [0x2b50, 0x2b51], // star + [0x2b55, 0x2b56], // heavy large circle [0x2e80, 0x303f], // CJK radicals … Kangxi [0x3041, 0x33ff], // Hiragana … CJK compatibility [0x3400, 0x4dc0], // CJK Extension A @@ -31,10 +69,52 @@ const WIDE: readonly Range[] = [ [0xfe30, 0xfe70], // CJK compatibility / small forms [0xff00, 0xff61], // Fullwidth forms [0xffe0, 0xffe7], // Fullwidth signs - [0x1f1e6, 0x1f200], // Regional indicators (flags) - [0x1f300, 0x1f650], // Misc symbols, emoticons - [0x1f680, 0x1f700], // Transport & map - [0x1f900, 0x1fa00], // Supplemental symbols & pictographs + [0x1f004, 0x1f005], + [0x1f0cf, 0x1f0d0], + [0x1f18e, 0x1f18f], + [0x1f191, 0x1f19b], + [0x1f200, 0x1f203], + [0x1f210, 0x1f23c], + [0x1f240, 0x1f249], + [0x1f250, 0x1f252], + [0x1f260, 0x1f266], + [0x1f300, 0x1f321], + [0x1f32d, 0x1f336], + [0x1f337, 0x1f37d], + [0x1f37e, 0x1f394], + [0x1f3a0, 0x1f3cb], + [0x1f3cf, 0x1f3d4], + [0x1f3e0, 0x1f3f1], + [0x1f3f4, 0x1f3f5], + [0x1f3f8, 0x1f43f], + [0x1f440, 0x1f441], + [0x1f442, 0x1f4fd], + [0x1f4ff, 0x1f53e], + [0x1f54b, 0x1f54f], + [0x1f550, 0x1f568], + [0x1f57a, 0x1f57b], + [0x1f595, 0x1f597], + [0x1f5a4, 0x1f5a5], // black heart suite — not U+1F5A5 desktop + [0x1f5fb, 0x1f650], // smileys through gesture + [0x1f680, 0x1f6c6], // transport (stops before couch U+1F6CB) + [0x1f6cc, 0x1f6cd], + [0x1f6d0, 0x1f6d3], + [0x1f6d5, 0x1f6d8], + [0x1f6dc, 0x1f6e0], + [0x1f6eb, 0x1f6ed], + [0x1f6f4, 0x1f6fd], + [0x1f7e0, 0x1f7ec], + [0x1f7f0, 0x1f7f1], + [0x1f90c, 0x1f93b], + [0x1f93c, 0x1f946], + [0x1f947, 0x1fa00], + [0x1fa70, 0x1fa7d], + [0x1fa80, 0x1fa89], + [0x1fa90, 0x1fabe], + [0x1fabf, 0x1fac6], + [0x1face, 0x1fadc], + [0x1fae0, 0x1fae9], + [0x1faf0, 0x1faf9], [0x20000, 0x3fffe], // CJK Extension B and beyond ]; @@ -56,14 +136,12 @@ const ZERO: readonly Range[] = [ [0x202a, 0x202f], // Bidi embedding/override [0x2060, 0x2065], // Word joiner, invisibles [0x20d0, 0x2100], // Combining marks for symbols - [0xfe00, 0xfe10], // Variation selectors (see VS16 note below) + [0xfe00, 0xfe10], // Variation selectors (VS16 = U+FE0F is zero-width) [0xfe20, 0xfe30], // Combining half marks ]; -/** Variation Selector-16 forces emoji (wide) presentation of the preceding - * base character, so a cluster containing it is two columns regardless of the - * base's own width (e.g. `#️`, `❤️`). */ -const VS16 = 0xfe0f; +const RI_START = 0x1f1e6; +const RI_END = 0x1f200; /** True if `cp` falls in any of the sorted half-open ranges (binary search). */ function inRanges(cp: number, ranges: readonly Range[]): boolean { @@ -92,8 +170,12 @@ function inRanges(cp: number, ranges: readonly Range[]): boolean { return false; } +function isRegionalIndicator(cp: number): boolean { + return cp >= RI_START && cp < RI_END; +} + /** The column width of a single code point: 0 (combining / zero-width / C0–C1 - * control), 2 (wide / fullwidth / emoji), or 1 (everything else). */ + * control), 2 (wide / fullwidth / terminal-wide emoji), or 1 (everything else). */ export function codePointWidth(cp: number): 0 | 1 | 2 { // C0 controls, DEL, and C1 controls render nothing meaningful here. if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) { @@ -111,26 +193,33 @@ export function codePointWidth(cp: number): 0 | 1 | 2 { return 1; } -/** The column width of one grapheme cluster: zero-width if its base is, two if - * it carries VS16 or any wide code point, else the base width. Combining marks - * contribute nothing, so `e` + accent is still one column. */ +/** The column width of one grapheme cluster. Combining marks and VS16 + * contribute nothing — iTerm/Terminal.app do not widen a Neutral base when + * VS16 requests emoji presentation. ZWJ sequences take the widest scalar + * (typically 2). Flag pairs are special-cased to 2. */ function clusterWidth(cluster: string): number { let width = 0; - let hasVs16 = false; + let riCount = 0; + let hasNonRi = false; for (const ch of cluster) { const cp = ch.codePointAt(0) ?? 0; - if (cp === VS16) { - hasVs16 = true; + if (isRegionalIndicator(cp)) { + riCount += 1; + } else if (codePointWidth(cp) > 0) { + hasNonRi = true; } - // The cluster's width is the widest of its code points: a base (1 or 2) - // dominates its trailing combining marks (0). width = Math.max(width, codePointWidth(cp)); } - return hasVs16 ? 2 : width; + // 🇯🇵 — one grapheme, two regional indicators, two terminal columns. + if (!hasNonRi && riCount === 2) { + return 2; + } + + return width; } /** Total terminal columns `str` occupies, measured per grapheme cluster. */ diff --git a/packages/core/src/render/wizard.ts b/packages/core/src/render/wizard.ts index 1ed5597b..8af8a299 100644 --- a/packages/core/src/render/wizard.ts +++ b/packages/core/src/render/wizard.ts @@ -1,11 +1,22 @@ import { emitKeypressEvents } from "node:readline"; import { STYLE, paint } from "./style"; import { clampIndex } from "./command-menu"; +import { CONSOLE } from "./frame/chrome"; +import { + formatMenuRow, + formatOverlayShell, + menuBodyBudget, + menuClip, + menuRule, + menuScrollCue, + menuWindow, +} from "./menu-chrome"; import type { IWizardAction, IWizardOption, IWizardState, IWizardStep, + IWizardView, } from "./wizard.types"; const ESC = String.fromCharCode(27); @@ -14,7 +25,9 @@ const EXIT_ALT = `${ESC}[?1049l`; const HIDE_CURSOR = `${ESC}[?25l`; const SHOW_CURSOR = `${ESC}[?25h`; const CLEAR_HOME = `${ESC}[2J${ESC}[H`; -const RULE = "─".repeat(52); + +/** Default overlay width when the host does not pass pane inner cols. */ +const DEFAULT_WIZARD_COLS = 80; // ─────────────────────────── pure state model ─────────────────────────── @@ -424,14 +437,26 @@ export function checkedValues( // ──────────────────────────── pure rendering ──────────────────────────── -function evidenceBlock(step: IWizardStep, color: boolean): string[] { +export interface IWizardFrameOpts { + readonly columns?: number; + readonly viewportRows?: number; +} + +function evidenceBlock( + step: IWizardStep, + color: boolean, + columns: number +): string[] { if (step.evidence.length === 0) { return []; } return [ paint("Evidence", STYLE.bold, color), - ...step.evidence.map((e) => ` ${paint(e, STYLE.dim, color)}`), + ...step.evidence.map( + (e) => + ` ${paint(menuClip(e, Math.max(0, columns - 2)), STYLE.dim, color)}` + ), "", ]; } @@ -440,50 +465,91 @@ function optionRow( opt: IWizardOption, active: boolean, marker: string, - color: boolean + color: boolean, + columns: number ): string { - const gutter = active ? paint("›", STYLE.brand, color) : " "; - const label = paint(opt.label, active ? STYLE.brand : STYLE.bold, color); - const rec = - opt.recommended === true - ? ` ${paint("recommended", STYLE.dim, color)}` - : ""; - const note = - opt.note === undefined ? "" : ` ${paint(opt.note, STYLE.dim, color)}`; + const hintBits: string[] = []; + + if (opt.recommended === true) { + hintBits.push("recommended"); + } - return `${gutter} ${marker}${label}${rec}${note}`; + if (opt.note !== undefined && opt.note.length > 0) { + hintBits.push(opt.note); + } + + return formatMenuRow({ + label: opt.label, + hint: hintBits.length > 0 ? hintBits.join(" · ") : undefined, + active, + columns, + color, + marker, + }); } -function singleChoiceRows( +function choiceRows( step: IWizardStep, - cursor: number, - color: boolean + state: IWizardState, + color: boolean, + columns: number ): string[] { - return step.options.map((opt, i) => optionRow(opt, i === cursor, "", color)); + if (step.kind === "multi") { + const checked = new Set(state.multi[step.key] ?? []); + + return step.options.map((opt, i) => + optionRow( + opt, + i === state.cursor, + `${checked.has(i) ? "◉" : "◯"} `, + color, + columns + ) + ); + } + + return step.options.map((opt, i) => + optionRow(opt, i === state.cursor, "", color, columns) + ); } -function multiChoiceRows( - step: IWizardStep, +/** Window Choices so the selected row stays visible under a short pane. */ +function windowedChoices( + allRows: readonly string[], cursor: number, - checkedIdx: readonly number[], + maxVisible: number, color: boolean ): string[] { - const checked = new Set(checkedIdx); + if (allRows.length <= maxVisible) { + return [...allRows]; + } - return step.options.map((opt, i) => - optionRow(opt, i === cursor, `${checked.has(i) ? "◉" : "◯"} `, color) - ); + const { start, end } = menuWindow(allRows.length, cursor, maxVisible); + const out: string[] = []; + + if (start > 0) { + out.push(menuScrollCue("up", start, color)); + } + + out.push(...allRows.slice(start, end)); + + if (end < allRows.length) { + out.push(menuScrollCue("down", allRows.length - end, color)); + } + + return out; } -function hints(step: IWizardStep, color: boolean): string { - const parts = - step.kind === "text" - ? ["type to edit", "← back", "enter continue", "esc cancel"] - : step.kind === "multi" - ? ["space toggle", "enter continue", "b back", "q cancel"] - : ["↑/↓ move", "enter select", "b back", "q cancel"]; +function hints(step: IWizardStep): string { + if (step.kind === "text") { + return "type to edit · enter continue · esc cancel"; + } + + if (step.kind === "multi") { + return "space toggle · enter continue · b back · q cancel"; + } - return paint(parts.join(" "), STYLE.dim, color); + return "↑/↓ move · enter select · b back · q cancel"; } /** The editable field for a text step: value (or placeholder) + caret, masked for @@ -500,7 +566,7 @@ function textFieldRows( : step.mask === true ? "•".repeat(raw.length) : raw; - const field = `${shown}${paint("▏", STYLE.brand, color)}`; + const field = `${shown}${paint("▏", CONSOLE.bright, color)}`; const error = step.validate === undefined ? null : step.validate(raw); const errorLine = error === null ? [] : ["", paint(error, STYLE.yellow, color)]; @@ -511,7 +577,9 @@ function textFieldRows( function stepBody( step: IWizardStep, state: IWizardState, - color: boolean + color: boolean, + columns: number, + choiceBudget: number ): string[] { if (step.kind === "text") { return textFieldRows(step, state, color); @@ -520,14 +588,16 @@ function stepBody( const active = step.options[clampIndex(state.cursor, step.options.length)]; const outcome = step.kind === "single" && active?.outcome !== undefined - ? ["", paint("Outcome", STYLE.bold, color), ` ${active.outcome}`] + ? [ + "", + paint("Outcome", STYLE.bold, color), + ` ${menuClip(active.outcome, Math.max(0, columns - 2))}`, + ] : []; - const rows = - step.kind === "multi" - ? multiChoiceRows(step, state.cursor, state.multi[step.key] ?? [], color) - : singleChoiceRows(step, state.cursor, color); + const rows = choiceRows(step, state, color, columns); + const windowed = windowedChoices(rows, state.cursor, choiceBudget, color); - return [paint("Choices", STYLE.bold, color), ...rows, ...outcome]; + return [paint("Choices", STYLE.bold, color), ...windowed, ...outcome]; } function renderStep( @@ -536,19 +606,34 @@ function renderStep( color: boolean, position: number, total: number, - title: string + title: string, + columns: number, + viewportRows: number ): string { - return [ - paint(title, STYLE.brand, color), - `${paint(`Step ${position} of ${total}`, STYLE.bold, color)} · ${step.title}`, - RULE, - step.explanation, - "", - ...evidenceBlock(step, color), - ...stepBody(step, state, color), + const width = Math.max(20, columns); + // Reserve room for explanation + evidence + section headers inside the body. + const choiceBudget = Math.max( + 1, + Math.min(8, menuBodyBudget(viewportRows, { hasSubtitle: true }) - 6) + ); + const subtitle = `${paint(`Step ${position} of ${total}`, STYLE.bold, color)} · ${step.title}`; + const bodyLines = [ + menuRule(width, color), + menuClip(step.explanation, width), "", - hints(step, color), - ].join("\n"); + ...evidenceBlock(step, color, width), + ...stepBody(step, state, color, width, choiceBudget), + ]; + + return formatOverlayShell({ + title, + subtitle, + bodyLines, + footer: hints(step), + columns: width, + color, + titleStyle: STYLE.brand, + }).join("\n"); } /** One readable summary line per step for the overview ("Title: chosen"). */ @@ -592,17 +677,25 @@ function renderOverview( state: IWizardState, color: boolean, extra: string, - title: string + title: string, + columns: number ): string { - return [ - paint(title, STYLE.brand, color), - `${paint("Review", STYLE.bold, color)} · nothing is written until you Apply`, - RULE, + const width = Math.max(20, columns); + const bodyLines = [ + menuRule(width, color), ...overviewLines(steps, state, color), - ...(extra.length > 0 ? ["", extra] : []), - "", - paint("enter apply b back q cancel", STYLE.dim, color), - ].join("\n"); + ...(extra.length > 0 ? ["", menuClip(extra, width)] : []), + ]; + + return formatOverlayShell({ + title, + subtitle: `${paint("Review", STYLE.bold, color)} · nothing is written until you Apply`, + bodyLines, + footer: "enter apply · b back · q cancel", + columns: width, + color, + titleStyle: STYLE.brand, + }).join("\n"); } /** Render the current frame (a step, or the final overview). `extra` is appended @@ -613,10 +706,20 @@ export function renderFrame( steps: readonly IWizardStep[], color: boolean, extra = "", - title = "tsforge setup" + title = "tsforge setup", + frameOpts: IWizardFrameOpts = {} ): string { + const columns = + frameOpts.columns !== undefined && frameOpts.columns > 0 + ? frameOpts.columns + : DEFAULT_WIZARD_COLS; + const viewportRows = + frameOpts.viewportRows !== undefined && frameOpts.viewportRows > 0 + ? frameOpts.viewportRows + : 40; + if (state.stepIndex >= steps.length) { - return renderOverview(steps, state, color, extra, title); + return renderOverview(steps, state, color, extra, title, columns); } const step = steps[state.stepIndex]; @@ -629,7 +732,9 @@ export function renderFrame( color, visiblePosition(steps, state, state.stepIndex), visibleTotal(steps, state), - title + title, + columns, + viewportRows ); } @@ -699,6 +804,14 @@ export interface IRunWizardOpts { readonly extra?: (state: IWizardState) => string; /** Output sink (default process.stdout.write). */ readonly out?: (s: string) => void; + /** When set, paint into the host chrome (main pane / status overlay) instead of + * opening a nested alt-screen. Required under the pane console so setup/scaffold + * do not fight PaneScreen. */ + readonly view?: IWizardView; + /** Overlay width. Prefer main-pane inner cols when the pane console is live. */ + readonly columns?: number; + /** Max overlay rows. Prefer pane chrome budget when panes are live. */ + readonly viewportRows?: number; } /** @@ -734,6 +847,7 @@ export function runWizard( return new Promise((resolve) => { let state = initWizard(steps); + const view = opts.view; emitKeypressEvents(stdin); @@ -763,9 +877,26 @@ export function runWizard( } const draw = (): void => { - out( - `${CLEAR_HOME}${renderFrame(state, steps, color, extra(state), title)}` - ); + const frame = renderFrame(state, steps, color, extra(state), title, { + columns: + opts.columns !== undefined && opts.columns > 0 + ? opts.columns + : process.stdout.columns > 0 + ? process.stdout.columns + : DEFAULT_WIZARD_COLS, + viewportRows: + opts.viewportRows !== undefined && opts.viewportRows > 0 + ? opts.viewportRows + : process.stdout.rows > 0 + ? process.stdout.rows + : 40, + }); + + if (view !== undefined) { + view.render(frame.split("\n")); + } else { + out(`${CLEAR_HOME}${frame}`); + } }; const finish = (): void => { @@ -783,7 +914,11 @@ export function runWizard( // wedging the terminal (dead keypress, hung Promise) on exit. The terminal // is already gone in that case, so there's nothing to restore on it. try { - out(`${SHOW_CURSOR}${EXIT_ALT}`); + if (view !== undefined) { + view.close(); + } else { + out(`${SHOW_CURSOR}${EXIT_ALT}`); + } } catch { // swallow — the stream is closed; cleanup below still runs } @@ -841,7 +976,11 @@ export function runWizard( }; stdin.on("keypress", onKey); - out(`${ENTER_ALT}${HIDE_CURSOR}`); + + if (view === undefined) { + out(`${ENTER_ALT}${HIDE_CURSOR}`); + } + draw(); }); } diff --git a/packages/core/src/render/wizard.types.ts b/packages/core/src/render/wizard.types.ts index 4292a3c9..a4e6aaa4 100644 --- a/packages/core/src/render/wizard.types.ts +++ b/packages/core/src/render/wizard.types.ts @@ -71,3 +71,10 @@ export interface IWizardState { readonly text: Readonly>; readonly status: "active" | "apply" | "cancel"; } + +/** Host-owned paint surface for an in-REPL / pane overlay (no nested alt-screen). + * Mirrors `IConfigMenuView` so wizards share chrome with `/config`. */ +export interface IWizardView { + render(lines: readonly string[]): void; + close(): void; +} diff --git a/packages/core/src/session-store.ts b/packages/core/src/session-store.ts index 422f408d..c9ae3b73 100644 --- a/packages/core/src/session-store.ts +++ b/packages/core/src/session-store.ts @@ -47,6 +47,10 @@ export interface ISessionRecord { * restored on `--continue`/`--resume` so a resumed session re-gates that edit on its * first send instead of silently dropping the deferred gate (WS-C, same as /clear). */ pausedWithEdit?: boolean; + /** Session-bound checklist plan id (`plans/.json`). Restored on `--continue` + * so task_* tools, turn inject, and the Tasks rail stay scoped to this session's + * plan (concurrent sessions in one project each bind their own id). */ + activePlanId?: string | null; /** The full conversation, including the system message. */ messages: IChatMessage[]; } @@ -195,6 +199,9 @@ async function readRecord(path: string): Promise { ...(typeof data.pausedWithEdit === "boolean" ? { pausedWithEdit: data.pausedWithEdit } : {}), + ...(data.activePlanId === null || typeof data.activePlanId === "string" + ? { activePlanId: data.activePlanId } + : {}), messages: toMessages(data.messages), }; } diff --git a/packages/core/src/setup/run-setup.ts b/packages/core/src/setup/run-setup.ts index 4f910e28..1c2b8723 100644 --- a/packages/core/src/setup/run-setup.ts +++ b/packages/core/src/setup/run-setup.ts @@ -2,6 +2,7 @@ import { scanRepo, recommendConventions } from "../infer-rules/scan"; import type { IConventions } from "../infer-rules/conventions.types"; import type { IScanReport } from "../infer-rules/scan.types"; import { runWizard } from "../render/wizard"; +import type { IWizardView } from "../render/wizard.types"; import { buildSteps, configPreview, @@ -21,6 +22,13 @@ export interface IRunSetupOptions { /** FALSE when launched from the REPL (the editor/readline owns stdin) so the * wizard doesn't pause stdin on exit and quit the process. Default true. */ readonly manageInput?: boolean; + /** Host chrome overlay (pane main). When set, the wizard skips its nested + * alt-screen and paints through the REPL chrome instead. */ + readonly view?: IWizardView; + /** Overlay width. Prefer main-pane inner cols when the pane console is live. */ + readonly columns?: number; + /** Max overlay rows. Prefer pane chrome budget when panes are live. */ + readonly viewportRows?: number; } const SAFETY_NOTE = @@ -113,6 +121,12 @@ export async function runSetup(opts: IRunSetupOptions): Promise { ...(opts.manageInput === undefined ? {} : { manageInput: opts.manageInput }), + ...(opts.view === undefined ? {} : { view: opts.view }), + ...(opts.out === undefined ? {} : { out: opts.out }), + ...(opts.columns === undefined ? {} : { columns: opts.columns }), + ...(opts.viewportRows === undefined + ? {} + : { viewportRows: opts.viewportRows }), extra: (state) => `${configPreview(selectionsToConventions(state))}\n\n${SAFETY_NOTE}`, }); diff --git a/packages/core/tests/agent-rail.test.ts b/packages/core/tests/agent-rail.test.ts index 8c3285bc..36f07d45 100644 --- a/packages/core/tests/agent-rail.test.ts +++ b/packages/core/tests/agent-rail.test.ts @@ -5,6 +5,8 @@ import { displayWidth } from "../src/render/width"; import { VirtualScreen } from "./helpers/virtual-screen"; const RAIL_COLS = 2; // "│ " +/** Strip SGR without a control-char regex literal (no-control-regex). */ +const SGR_STRIP = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); /** Feed a paragraph through the streaming markdown renderer token-by-token (as the * live loop does), then through the rail wrapper, and replay onto a screen. */ @@ -18,6 +20,7 @@ function railedRows(paragraph: string, cols: number): string[] { } streamed += rail.feed(md.flush(true)); + streamed += rail.flush(); const screen = new VirtualScreen(24, cols); @@ -65,22 +68,87 @@ describe("makeAgentRail — streaming semantics", () => { // The card top emits "\n" first; the first content must NOT be preceded by a // blank rail line. expect(rail.feed("\n")).toBe(""); - expect(rail.feed("hello")).toBe("| hello"); + expect(rail.feed("hello") + rail.flush()).toBe("| hello"); }); test("keeps the rail on interior blank lines", () => { const rail = makeAgentRail("| ", () => 40); - rail.feed("first"); + expect(rail.feed("first") + rail.flush()).toBe("| first"); // A blank line BETWEEN paragraphs keeps the rail (card stays continuous). - expect(rail.feed("\n\nsecond")).toBe("\n| \n| second"); + expect(rail.feed("\n\nsecond") + rail.flush()).toBe("\n| \n| second"); }); test("a line split across chunks keeps a single rail and correct wrap", () => { const rail = makeAgentRail("| ", () => 30); // inner budget 30 (above the min) // 35 chars split across two chunks → one wrap after 30, rail on both lines. - const out = rail.feed("a".repeat(20)) + rail.feed("a".repeat(15)); + const out = + rail.feed("a".repeat(20)) + rail.feed("a".repeat(15)) + rail.flush(); expect(out).toBe(`| ${"a".repeat(30)}\n| ${"a".repeat(5)}`); }); + + test("prefers wrapping at spaces instead of mid-word", () => { + const rail = makeAgentRail("| ", () => 20); + const out = rail.feed("hello wonderful world") + rail.flush(); + + expect(out).toBe("| hello wonderful\n| world"); + expect(out).not.toContain("wond\n"); + }); + + test("right rail pads and closes every completed visual line", () => { + const rail = makeAgentRail("| ", () => 20, "|"); + const out = rail.feed("hello wonderful world") + rail.flush(); + const rows = out.replace(/\n$/, "").split("\n"); + + expect(rows).toEqual([ + `| hello wonderful${" ".repeat(5)}|`, + `| world${" ".repeat(15)}|`, + ]); + }); + + test("blank closed rows use one SGR span (no bright right-rail fleck)", () => { + const left = "\x1b[38;2;82;82;91m│\x1b[0m "; + const right = "\x1b[38;2;82;82;91m│\x1b[0m"; + const rail = makeAgentRail(left, () => 20, right); + const out = rail.feed("hi\n\nthere") + rail.flush(); + const blank = out + .replace(/\n$/, "") + .split("\n") + .find((row) => { + const plain = row.replace(SGR_STRIP, ""); + + return /^│\s+│$/.test(plain); + }); + + expect(blank).toBeDefined(); + const first = blank!.indexOf("│"); + const last = blank!.lastIndexOf("│"); + + expect(blank!.slice(first + 1, last).includes("\x1b[0m")).toBe(false); + }); + + test("Neutral emoji + VS16 does not under-pad the right rail (closed box stays square)", () => { + const left = "│ "; + const right = "│"; + const cardCols = 40; + const inner = cardCols - displayWidth(left) - displayWidth(right); + const rail = makeAgentRail(left, () => inner, right); + const out = + rail.feed("not ratatui 🖥️ — fits here cleanly") + + rail.flush() + + rail.feed("\nHa, fair enough. 😊") + + rail.flush() + + rail.feed("\nEnjoy the chill. 🛋️") + + rail.flush(); + const rows = out.replace(/\n$/, "").split("\n"); + + for (const row of rows) { + const plain = row.replace(SGR_STRIP, ""); + + // Blank rows are a single chrome SGR span — measure the visible cells. + expect(displayWidth(plain)).toBe(cardCols); + expect(plain.endsWith("│")).toBe(true); + } + }); }); diff --git a/packages/core/tests/banner.test.ts b/packages/core/tests/banner.test.ts index 6517f5ad..2ef5d363 100644 --- a/packages/core/tests/banner.test.ts +++ b/packages/core/tests/banner.test.ts @@ -1,5 +1,6 @@ import { test, expect } from "bun:test"; import { welcomeBanner } from "../src/render"; +import { planHint } from "../src/cli/banner"; const ESC = String.fromCharCode(27); @@ -44,3 +45,31 @@ test("welcomeBanner: paints a cyan→violet gradient across the wordmark", () => // Stripping the color codes leaves the wordmark glyphs intact. expect(stripAnsi(banner)).toContain("███████╗"); }); + +test("planHint: filled PLAN badge + orange rail strip", () => { + const out = planHint(false, 40); + const plain = stripAnsi(out); + + expect(out).toContain("[48;2;255;153;0m"); // planBg + expect(plain).toContain(" PLAN "); + expect(plain).toMatch(/^ PLAN /m); + expect(plain).toContain("REPLY TO REFINE"); + expect(plain).toContain("[ APPROVE ]"); + expect(plain).toContain("TO CONTINUE"); + expect(plain).toContain("│ REPLY TO REFINE"); + expect(plain).not.toContain("◆"); + // Pad row between hairline and body. + const rows = plain.split("\n"); + + expect(rows[0]?.startsWith(" PLAN ")).toBe(true); + expect(rows[1]?.trim()).toBe("│"); + expect(rows[2]).toContain("REPLY TO REFINE"); +}); + +test("planHint: ready state nudges approve to build", () => { + const plain = stripAnsi(planHint(true, 40)); + + expect(plain).toContain(" PLAN "); + expect(plain).toContain("[ APPROVE ]"); + expect(plain).toContain("TO BUILD"); +}); diff --git a/packages/core/tests/checklist-nudge.test.ts b/packages/core/tests/checklist-nudge.test.ts new file mode 100644 index 00000000..79520f11 --- /dev/null +++ b/packages/core/tests/checklist-nudge.test.ts @@ -0,0 +1,62 @@ +import { test, expect, describe } from "bun:test"; +import { checklistOpenNudge, isEphemeralUserInject } from "../src/loop/session"; + +describe("checklistOpenNudge (Phase B messaging)", () => { + test("gate-green / checklist-open without task_complete this turn", () => { + const msg = checklistOpenNudge({ + openCount: 2, + calledTaskComplete: false, + }); + + expect(msg).toMatch(/Gate is GREEN/i); + expect(msg).toMatch(/2 open/i); + expect(msg).toMatch(/did not call task_complete/i); + expect(msg).toMatch(/BOTH gate green AND every checklist/i); + }); + + test("gate-green / checklist-open after some completes", () => { + const msg = checklistOpenNudge({ + openCount: 1, + calledTaskComplete: true, + }); + + expect(msg).toMatch(/Continue with the next open item/i); + expect(msg).not.toMatch(/did not call task_complete/i); + }); +}); + +describe("isEphemeralUserInject (resume transcript)", () => { + test("filters per-turn checklist inject and Phase B nudge", () => { + expect( + isEphemeralUserInject({ + role: "user", + content: + "[checklist — session plan 31fbd6a0-ad34-4ad8-ad5a-efff0e8e44e5]\ngoal: x", + }) + ).toBe(true); + expect( + isEphemeralUserInject({ + role: "user", + content: checklistOpenNudge({ + openCount: 1, + calledTaskComplete: true, + }), + }) + ).toBe(true); + }); + + test("keeps real user / assistant turns", () => { + expect( + isEphemeralUserInject({ + role: "user", + content: "Build a notes CLI", + }) + ).toBe(false); + expect( + isEphemeralUserInject({ + role: "assistant", + content: "[checklist — session plan x]\ngoal: nope", + }) + ).toBe(false); + }); +}); diff --git a/packages/core/tests/checklist-store.test.ts b/packages/core/tests/checklist-store.test.ts new file mode 100644 index 00000000..b79c524d --- /dev/null +++ b/packages/core/tests/checklist-store.test.ts @@ -0,0 +1,201 @@ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + completeItemInPlan, + focusItemInPlan, + isChecklistComplete, + loadPlan, + savePlan, + uncompleteItemInPlan, +} from "../src/loop/worklist/checklist-store"; +import type { IPlanDocument } from "../src/loop/worklist/checklist.types"; +import { + doTaskComplete, + doTaskFocus, + doTaskList, +} from "../src/loop/tools/task-tools"; +import type { IToolContext } from "../src/loop/tools/tool-context"; + +function samplePlan(id: string): IPlanDocument { + return { + schemaVersion: 2, + id, + goal: "ship", + activeItemId: null, + updatedAt: "2026-01-01T00:00:00.000Z", + items: [ + { + id: "parent", + title: "Parent", + status: "pending", + children: [ + { id: "child-a", title: "Child A", status: "pending" }, + { id: "child-b", title: "Child B", status: "pending" }, + ], + }, + ], + }; +} + +describe("checklist-store", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "tsforge-plan-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + test("refuses parent complete while children open; auto-completes parent", () => { + let plan = samplePlan("p1"); + const refuse = completeItemInPlan(plan, "parent"); + + expect(refuse.ok).toBe(false); + + const a = completeItemInPlan(plan, "child-a"); + + expect(a.ok).toBe(true); + + if (!a.ok) { + return; + } + + plan = a.plan; + const b = completeItemInPlan(plan, "child-b"); + + expect(b.ok).toBe(true); + + if (!b.ok) { + return; + } + + expect(b.plan.items[0]?.status).toBe("done"); + expect(isChecklistComplete(b.plan)).toBe(true); + }); + + test("focus + uncomplete persist via tools scoped to activePlanId", async () => { + const plan = samplePlan("bound"); + + savePlan(dir, plan); + + const reports: string[] = []; + const ctx: IToolContext = { + cwd: dir, + files: ["**/*"], + report: (e) => { + if (e.kind === "tool" && typeof e.message === "string") { + reports.push(e.message); + } + }, + task: "session", + activePlanId: "bound", + runTaskGate: async () => ({ + passed: true, + errors: [], + output: "", + autoFixed: [], + }), + }; + + expect(doTaskList({}, ctx)).toContain("Parent"); + + const focused = doTaskFocus({ id: "child-a" }, ctx); + + expect(focused).toContain("focused:"); + expect(loadPlan(dir, "bound")?.activeItemId).toBe("child-a"); + + await doTaskComplete({ id: "child-a" }, ctx); + expect(loadPlan(dir, "bound")?.items[0]?.children?.[0]?.status).toBe( + "done" + ); + expect(reports.some((m) => m.startsWith("task_complete:"))).toBe(true); + + const other: IToolContext = { ...ctx, activePlanId: "missing" }; + + expect(doTaskList({}, other)).toMatch(/no active plan|missing/i); + }); + + test("task_complete refuses when gate is red — item stays open", async () => { + const plan = samplePlan("red"); + + savePlan(dir, plan); + + const ctx: IToolContext = { + cwd: dir, + files: ["**/*"], + report: () => undefined, + task: "session", + activePlanId: "red", + runTaskGate: async () => ({ + passed: false, + errors: [{ key: "x", message: "TS2304: Cannot find name 'x'" }], + output: "fail", + autoFixed: [], + }), + }; + + const out = await doTaskComplete({ id: "child-a" }, ctx); + + expect(out).toMatch(/gate RED/i); + expect(loadPlan(dir, "red")?.items[0]?.children?.[0]?.status).toBe( + "pending" + ); + }); + + test("task_complete refuses when no gate is wired", async () => { + const plan = samplePlan("nogate"); + + savePlan(dir, plan); + + const ctx: IToolContext = { + cwd: dir, + files: ["**/*"], + report: () => undefined, + task: "session", + activePlanId: "nogate", + }; + + const out = await doTaskComplete({ id: "child-a" }, ctx); + + expect(out).toMatch(/no gate/i); + expect(loadPlan(dir, "nogate")?.items[0]?.children?.[0]?.status).toBe( + "pending" + ); + }); + + test("focus/uncomplete helpers", () => { + let plan = samplePlan("p2"); + const focused = focusItemInPlan(plan, "child-b"); + + expect(focused.ok).toBe(true); + + if (!focused.ok) { + return; + } + + plan = focused.plan; + expect(plan.activeItemId).toBe("child-b"); + + const done = completeItemInPlan(plan, "child-b"); + + expect(done.ok).toBe(true); + + if (!done.ok) { + return; + } + + const undone = uncompleteItemInPlan(done.plan, "child-b"); + + expect(undone.ok).toBe(true); + + if (!undone.ok) { + return; + } + + expect(undone.plan.items[0]?.children?.[1]?.status).toBe("pending"); + }); +}); diff --git a/packages/core/tests/chrome.test.ts b/packages/core/tests/chrome.test.ts new file mode 100644 index 00000000..a99364aa --- /dev/null +++ b/packages/core/tests/chrome.test.ts @@ -0,0 +1,261 @@ +import { test, expect, describe } from "bun:test"; +import { + formatHints, + formatTopStatus, + formatConsoleTopbar, + formatConsoleTitle, + formatRailHeader, + formatRailTitleBlock, + hairline, + insetX, + CHROME_PAD_X, + CHROME_PAD_Y, + RAIL_TITLE_ROWS, +} from "../src/render/frame/chrome"; +import { STYLE } from "../src/render/style"; +import { stripSgr } from "../src/render/frame/ansi-plain"; + +describe("formatConsoleTitle", () => { + test("mode chip: plan is amber pill, normal is quiet chrome pill", () => { + const plan = formatConsoleTitle({ + info: { + model: "m", + contextTokens: 0, + contextWindow: 100, + turns: 0, + elapsedMs: 0, + status: "ready", + scope: "repo", + mode: "plan", + }, + cwd: "/tmp", + cols: 80, + color: true, + }); + const normal = formatConsoleTitle({ + info: { + model: "m", + contextTokens: 0, + contextWindow: 100, + turns: 0, + elapsedMs: 0, + status: "ready", + scope: "repo", + mode: "normal", + }, + cwd: "/tmp", + cols: 80, + color: true, + }); + + expect(plan).toContain(" PLAN "); + expect(plan).toContain(STYLE.planBg); + expect(normal).toContain(" NORMAL "); + expect(normal).toContain(STYLE.chromeBg); + expect(plan).not.toContain("◆"); + expect(normal).not.toContain("◆"); + }); + + test("dense strip: brand+path+scope left, live chips right", () => { + const row = formatConsoleTitle({ + info: { + model: "deepseek", + contextTokens: 40, + contextWindow: 100, + turns: 0, + elapsedMs: 0, + status: "ready", + scope: "repo", + mode: "plan", + tokensPerSecond: 42, + }, + cwd: "/tmp/demo", + worklistBadge: "2/5", + cols: 100, + color: false, + }); + + expect(row).toContain("TSFORGE"); + expect(row).not.toContain("agent console"); + expect(row).toContain("/tmp/demo"); + expect(row).toContain("repo"); + expect(row).toContain("deepseek"); + expect(row).toContain("40%"); + expect(row).toContain("PLAN"); + expect(row).not.toContain("◆"); + expect(row).toContain("✓"); + expect(row).toContain("42t"); + expect(row).toContain("#2/5"); + // Where (brand…scope) then live chips — path before model. + expect(row.indexOf("/tmp/demo")).toBeLessThan(row.indexOf("deepseek")); + expect(row.indexOf("repo")).toBeLessThan(row.indexOf("deepseek")); + }); +}); + +describe("formatConsoleTopbar", () => { + test("one-row air above title, tight air below, then hairline", () => { + const lines = formatConsoleTopbar({ + info: { + model: "deepseek", + contextTokens: 40, + contextWindow: 100, + turns: 0, + elapsedMs: 0, + status: "ready", + scope: "repo", + mode: "plan", + }, + cwd: "/tmp/demo", + cols: 80, + color: false, + splitCol: 40, + }); + + expect(lines.length).toBe(CHROME_PAD_Y + 3); + expect(lines.slice(0, CHROME_PAD_Y).every((l) => l.trim() === "")).toBe( + true + ); + const title = lines[CHROME_PAD_Y] ?? ""; + const padBottom = lines[CHROME_PAD_Y + 1] ?? ""; + const rule = lines[CHROME_PAD_Y + 2] ?? ""; + + expect(padBottom.trim()).toBe(""); + expect(title).toContain("TSFORGE"); + expect(title.startsWith(" ".repeat(CHROME_PAD_X))).toBe(true); + expect(rule).toContain("┬"); + expect(rule.replace(/┬/g, "─")).toMatch(/^─+$/); + // Hairline continues through the panel column (right of ┬). + const split = rule.indexOf("┬"); + + expect(split).toBe(40); + expect(rule.slice(split + 1)).toMatch(/^─+$/); + }); +}); + +describe("formatRailHeader", () => { + test("Tasks left, cyan done/total right, bordered title block", () => { + const header = formatRailHeader({ + done: 2, + total: 6, + cols: 28, + color: true, + }); + const plain = stripSgr(header); + const block = formatRailTitleBlock({ + done: 2, + total: 6, + cols: 28, + color: false, + }); + + expect(plain).toContain("Tasks"); + expect(plain).toContain("2/6"); + expect(plain.indexOf("Tasks")).toBeLessThan(plain.indexOf("2/6")); + expect(header).toContain(STYLE.cyan); + expect(RAIL_TITLE_ROWS).toBe(2); + expect(block).toHaveLength(2); + expect(block[0]).toContain("Tasks"); + expect(block[1]).toMatch(/^─+$/); + }); + + test("empty counts stay muted 0/0", () => { + const header = formatRailHeader({ + done: 0, + total: 0, + cols: 28, + color: true, + }); + + expect(stripSgr(header)).toContain("0/0"); + expect(header).not.toContain(STYLE.cyan); + }); +}); + +describe("hairline", () => { + test("inserts junction glyphs at the split", () => { + const rule = hairline(10, "─", { + splitCol: 4, + junction: "┬", + color: false, + }); + + expect(rule).toBe("────┬─────"); + expect(hairline(8, "─", { splitCol: 3, junction: "┴", color: false })).toBe( + "───┴────" + ); + }); +}); + +describe("insetX", () => { + test("keeps hairline-width content off the edges", () => { + const line = insetX("hi", 12, 3); + + expect(line.startsWith(" ")).toBe(true); + expect(line.endsWith(" ")).toBe(true); + expect(line.length).toBe(12); + expect(CHROME_PAD_X).toBe(3); + }); + + test("hard-clamps oversized content so pane gutters cannot be overwritten", () => { + const line = insetX("x".repeat(200) + " overflow", 20, 3); + + expect(line.length).toBe(20); + expect(line.startsWith(" ")).toBe(true); + expect(line.endsWith(" ")).toBe(true); + expect(line).not.toContain("overflow"); + }); +}); + +describe("formatTopStatus", () => { + test("delegates to the dense title strip", () => { + const line = formatTopStatus({ + info: { + model: "deepseek", + contextTokens: 1, + contextWindow: 100, + turns: 0, + elapsedMs: 0, + status: "ready", + scope: "repo", + mode: "plan", + }, + worklistBadge: "2/5", + cols: 80, + color: false, + }); + + expect(line).toContain("deepseek"); + expect(line).toContain("PLAN"); + expect(line).toContain("#2/5"); + }); + + test("truncates when cols are tight", () => { + const line = formatTopStatus({ + info: { + model: "very-long-model-name-here", + contextTokens: 1, + contextWindow: 100, + turns: 0, + elapsedMs: 0, + status: "ready", + scope: "repo", + mode: "plan", + activity: "thinking", + }, + worklistBadge: "9/9", + cols: 20, + color: false, + }); + + expect(line.length).toBeLessThanOrEqual(20); + }); +}); + +describe("formatHints", () => { + test("changes with focus surface", () => { + expect(formatHints("prompt", false)).toContain("Ctrl+G"); + expect(formatHints("panel", false)).toContain("Esc prompt"); + expect(formatHints("scrollback", false)).toContain("scroll"); + expect(formatHints("prompt", true)).toContain("abort"); + }); +}); diff --git a/packages/core/tests/cli.test.ts b/packages/core/tests/cli.test.ts index ff686f55..ab62b646 100644 --- a/packages/core/tests/cli.test.ts +++ b/packages/core/tests/cli.test.ts @@ -4,6 +4,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseArgs, isOneShot, applyRecipe, runNotify } from "../src/cli"; import { cliUsage, valueFlagError } from "../src/cli/args"; +import { paneConsoleRejectReason } from "../src/cli/repl"; +import { PANE_MIN_ROWS } from "../src/render"; import { PROFILE_IDS } from "../src/config/profiles"; import type { ITaskRecipe } from "../src/config/recipes"; @@ -489,10 +491,58 @@ test("--version/-V and --help/-h parse as flags, not as a task", () => { test("cliUsage documents the print-and-exit flags it is reached by", () => { const usage = cliUsage(); + expect(usage).not.toContain("--work"); + expect(usage).not.toContain("--tick"); + expect(usage).toContain("--version"); expect(usage).toContain("--help"); expect(usage).toContain("--accept"); expect(usage).toContain("tsforge review"); + expect(usage).not.toContain("--tui-panes"); + expect(usage).not.toContain("--no-tui-panes"); +}); + +test("removed --tui-panes / --no-tui-panes are ignored (not swallowed into the task)", () => { + expect(parseArgs(["--tui-panes"]).task).toBe(""); + expect(parseArgs(["--no-tui-panes"]).task).toBe(""); + expect(parseArgs(["--tui-panes", "ship", "it"]).task).toBe("ship it"); +}); + +test("paneConsoleRejectReason: tiny interactive TTY fails closed; pipes do not", () => { + expect( + paneConsoleRejectReason({ + stdinTty: true, + stdoutTty: true, + rows: PANE_MIN_ROWS - 1, + }) + ).toContain(String(PANE_MIN_ROWS)); + + expect( + paneConsoleRejectReason({ + stdinTty: true, + stdoutTty: true, + rows: PANE_MIN_ROWS, + }) + ).toBeNull(); + + // Non-TTY uses the plain path — not an error, and never the classic StatusBar. + expect( + paneConsoleRejectReason({ + stdinTty: false, + stdoutTty: false, + rows: 4, + }) + ).toBeNull(); +}); + +test("repl product path never constructs or installs StatusBar", async () => { + const src = await Bun.file( + new URL("../src/cli/repl.ts", import.meta.url) + ).text(); + + expect(src).not.toContain("new StatusBar"); + expect(src).not.toContain("statusBar.install"); + expect(src).not.toMatch(/import\s*\{[^}]*\bStatusBar\b/); }); test("agents subcommand: list mode, ids+task mode, recipe fill", () => { diff --git a/packages/core/tests/cursor-state.test.ts b/packages/core/tests/cursor-state.test.ts new file mode 100644 index 00000000..0bae511e --- /dev/null +++ b/packages/core/tests/cursor-state.test.ts @@ -0,0 +1,23 @@ +import { test, expect, describe } from "bun:test"; +import { CursorState } from "../src/render/frame/cursor-state"; +import { SHOW_CURSOR } from "../src/render/frame/codes"; + +describe("CursorState", () => { + test("emits Show+CUP on first move, then nothing when unchanged", () => { + const cur = new CursorState(); + const first = cur.move(10, 3); + + expect(first).toContain(SHOW_CURSOR); + expect(first).toContain("[10;3H"); + expect(cur.move(10, 3)).toBe(""); + }); + + test("emits again after position changes or reset", () => { + const cur = new CursorState(); + + cur.move(1, 1); + expect(cur.move(2, 1)).toContain("[2;1H"); + cur.reset(); + expect(cur.move(2, 1)).toContain("[2;1H"); + }); +}); diff --git a/packages/core/tests/file-menu.test.ts b/packages/core/tests/file-menu.test.ts index 517402df..da329e6f 100644 --- a/packages/core/tests/file-menu.test.ts +++ b/packages/core/tests/file-menu.test.ts @@ -52,7 +52,7 @@ test("formatCompletionRows: one truncated row per file; selected gutter; no wrap const rows = formatCompletionRows(FILES, 1, 40, false); expect(rows).toHaveLength(FILES.length); - expect(rows[1]?.startsWith("›")).toBe(true); // selected row marked + expect(rows[1]?.startsWith("▸")).toBe(true); // selected row marked for (const r of rows) { expect(r.length).toBeLessThanOrEqual(40); // never wider than the terminal diff --git a/packages/core/tests/fit-line.test.ts b/packages/core/tests/fit-line.test.ts new file mode 100644 index 00000000..cdf11f77 --- /dev/null +++ b/packages/core/tests/fit-line.test.ts @@ -0,0 +1,26 @@ +import { test, expect, describe } from "bun:test"; +import { fitAnsiLine } from "../src/render/frame/fit-line"; +import { RESET } from "../src/render/style"; + +describe("fitAnsiLine", () => { + test("keeps SGR when the visible width fits", () => { + const red = `\x1b[31mhi\x1b[0m`; + const out = fitAnsiLine(red, 10); + + expect(out.startsWith(red)).toBe(true); + expect(out).toContain(RESET); + expect(out.endsWith(" ".repeat(8))).toBe(true); // 10 - 2 visible + }); + + test("truncates to plain text when overflowing", () => { + const red = `\x1b[31mabcdef\x1b[0m`; + const out = fitAnsiLine(red, 3); + + expect(out).toBe(`abc${RESET}`); + expect(out).not.toContain("[31m"); + }); + + test("empty line pads to width", () => { + expect(fitAnsiLine("", 4)).toBe(`${RESET} `); + }); +}); diff --git a/packages/core/tests/focus.test.ts b/packages/core/tests/focus.test.ts new file mode 100644 index 00000000..d42aa20d --- /dev/null +++ b/packages/core/tests/focus.test.ts @@ -0,0 +1,61 @@ +import { test, expect, describe } from "bun:test"; +import { PaneFocus } from "../src/render/frame/focus"; + +describe("PaneFocus", () => { + test("togglePanel with items cycles unfocused ↔ focused", () => { + const f = new PaneFocus(); + + f.syncHasItems(true); + expect(f.panel).toBe("visibleUnfocused"); + + expect(f.togglePanel(true)).toBe("changed"); + expect(f.panel).toBe("visibleFocused"); + expect(f.active).toBe("panel"); + + expect(f.togglePanel(true)).toBe("changed"); + expect(f.panel).toBe("visibleUnfocused"); + expect(f.active).toBe("prompt"); + }); + + test("escape from panel returns to prompt", () => { + const f = new PaneFocus(); + + f.syncHasItems(true); + f.togglePanel(true); + expect(f.escape()).toBe("changed"); + expect(f.active).toBe("prompt"); + expect(f.panel).toBe("visibleUnfocused"); + }); + + test("tab moves prompt ↔ panel when items exist", () => { + const f = new PaneFocus(); + + f.syncHasItems(true); + expect(f.tab(true)).toBe("changed"); + expect(f.active).toBe("panel"); + expect(f.tab(true)).toBe("changed"); + expect(f.active).toBe("prompt"); + }); + + test("moveSelection clamps and ignores when not focused", () => { + const f = new PaneFocus(); + + f.syncHasItems(true); + expect(f.moveSelection(1, 3)).toBe("ignored"); + f.togglePanel(true); + expect(f.moveSelection(2, 3)).toBe("changed"); + expect(f.selection).toBe(2); + expect(f.moveSelection(9, 3)).toBe("changed"); + expect(f.selection).toBe(3); + }); + + test("syncHasItems hides panel when emptied", () => { + const f = new PaneFocus(); + + f.syncHasItems(true); + f.togglePanel(true); + f.syncHasItems(false); + expect(f.panel).toBe("hidden"); + expect(f.active).toBe("prompt"); + }); +}); diff --git a/packages/core/tests/frame-tui.test.ts b/packages/core/tests/frame-tui.test.ts new file mode 100644 index 00000000..e3c4ed84 --- /dev/null +++ b/packages/core/tests/frame-tui.test.ts @@ -0,0 +1,1344 @@ +import { test, expect, describe } from "bun:test"; +import { + blankFrame, + writeRect, + freezeFrame, + diffFrames, + cloneFrame, + Scrollback, + computeLayout, + canUsePaneTui, + panelWidthFor, + PaneScreen, + ENTER_ALT, + EXIT_ALT, + BEGIN_SYNC, + END_SYNC, + CLEAR_SCREEN, + PANE_MIN_ROWS, + BOTTOM_CHROME_ROWS, + INPUT_BAND_ROWS, + CHROME_PAD_X, + insetInnerCols, + inputCursorCol, + outerInsets, + OUTER_MARGIN, + TOP_PAD_ROWS, + BOTTOM_PAD_ROWS, +} from "../src/render/frame"; +import { formatMenuRows } from "../src/render/inline-menu"; +import { formatWorklistLines } from "../src/loop/worklist/panel"; +import { VirtualScreen } from "./helpers/virtual-screen"; + +function findPromptRow(feed: string, rows: number, cols: number): number { + const screen = new VirtualScreen(rows, cols); + + screen.feed(feed); + + // Input box ╭ (not the outer window) — next row holds `> `. + for (let r = 1; r <= rows; r += 1) { + if (screen.row(r).includes("╭") && screen.row(r + 1).includes(">")) { + return r + 1; + } + } + + return expectedPromptRow(rows, cols); +} + +function expectedPromptRow(termRows: number, cols = 100): number { + const insets = outerInsets(termRows, cols); + const layout = computeLayout({ + rows: insets.contentRows, + cols: insets.contentCols, + }); + const top = layout.input.rows >= 3 ? 1 : 0; + + return insets.originRow + layout.input.row + top + 1; +} + +function promptBoxTop(termRows: number, cols = 100): number { + const insets = outerInsets(termRows, cols); + const layout = computeLayout({ + rows: insets.contentRows, + cols: insets.contentCols, + }); + + return insets.originRow + layout.input.row + 1; +} + +/** 1-based title strip row inside the floating window. */ +function titleRow(termRows: number, cols = 100): number { + const insets = outerInsets(termRows, cols); + + // Topbar: TOP_PAD_ROWS air, then title. + return insets.originRow + TOP_PAD_ROWS + 1; +} + +/** 1-based outer-window bottom edge (`╰─╯`). */ +function outerBottomRow(termRows: number): number { + return termRows - OUTER_MARGIN; +} + +function contentLayout( + termRows: number, + cols = 100, + showPanel?: boolean +): ReturnType { + const insets = outerInsets(termRows, cols); + + return computeLayout({ + rows: insets.contentRows, + cols: insets.contentCols, + showPanel, + }); +} + +/** True when a framed row has no ink inside the outer `│…│`. */ +function isFramedAir(row: string): boolean { + return row.replace(/[│╭╮╰╯─]/gu, "").trim() === ""; +} + +describe("grid diff", () => { + test("full redraw when prev is null", () => { + const grid = cloneFrame(blankFrame(2, 4)); + + writeRect(grid, { row: 0, col: 0, rows: 1, cols: 4 }, ["abcd"]); + const frame = freezeFrame(grid, 2, 4); + const bytes = diffFrames(null, frame); + + expect(bytes).toContain("abcd"); + }); + + test("dirty rows only on second paint", () => { + const a = cloneFrame(blankFrame(2, 4)); + + writeRect(a, { row: 0, col: 0, rows: 1, cols: 4 }, ["aaaa"]); + const prev = freezeFrame(a, 2, 4); + + const b = cloneFrame(prev); + + writeRect(b, { row: 1, col: 0, rows: 1, cols: 4 }, ["bbbb"]); + const next = freezeFrame(b, 2, 4); + const bytes = diffFrames(prev, next); + + expect(bytes).toContain("bbbb"); + // Row 0 unchanged — only one CUP to row 2 (1-based). + const cupRe = new RegExp(`${String.fromCharCode(27)}\\[\\d+;1H`, "g"); + + expect(bytes.match(cupRe)?.length).toBe(1); + }); +}); + +describe("Scrollback", () => { + test("follows the bottom and scrolls up to older lines", () => { + const sb = new Scrollback(100, 2); + + sb.setWrapCols(80); + sb.append("one\n"); + sb.append("two\n"); + sb.append("three\n"); + + // 3 lines > viewport 2 → bottom window. + expect(sb.visible()).toEqual(["two", "three"]); + + sb.scroll(1); + expect(sb.visible()).toEqual(["one", "two"]); + + sb.follow(); + expect(sb.visible()).toEqual(["two", "three"]); + }); + + test("metrics report overflow while following and real offsets when scrolled", () => { + const sb = new Scrollback(100, 2); + + sb.setWrapCols(80); + sb.append("one\n"); + expect(sb.metrics().total).toBeLessThanOrEqual(sb.metrics().viewport); + + sb.append("two\n"); + sb.append("three\n"); + const live = sb.metrics(); + + expect(live.following).toBe(true); + expect(live.total).toBeGreaterThan(live.viewport); + + sb.scroll(1); + const up = sb.metrics(); + + expect(up.following).toBe(false); + expect(up.total).toBe(3); + expect(up.offset).toBe(0); + }); + + test("short following content is top-aligned (no void above the banner)", () => { + const sb = new Scrollback(100, 5); + + sb.setWrapCols(80); + sb.append("hello\n"); + + expect(sb.visible()).toEqual(["hello", "", "", "", ""]); + }); + + test("wraps long lines so overflow is not truncated away", () => { + const sb = new Scrollback(100, 3); + + sb.setWrapCols(4); + sb.append("abcdefgh\n"); + + // Short + following → top-aligned (pad below). + expect(sb.visible()).toEqual(["abcd", "efgh", ""]); + }); + + test("reflow preserves the logical line at the viewport top when scrolled up", () => { + const sb = new Scrollback(100, 3); + + sb.setWrapCols(20); + + for (let i = 0; i < 10; i += 1) { + sb.append(`LINE_${String(i)}_UNIQUE\n`); + } + + sb.scroll(5); + const before = sb.visible().find((l) => l.includes("LINE_")); + + expect(before !== undefined).toBe(true); + sb.reflow(10); + const after = sb.visible().join("\n"); + + expect(after).toContain((before ?? "").slice(0, 8)); + expect(sb.following).toBe(false); + }); + + test("follow mode stays at bottom across reflow", () => { + const sb = new Scrollback(100, 2); + + sb.setWrapCols(40); + sb.append("one\n"); + sb.append("two\n"); + sb.append("three\n"); + sb.reflow(10); + expect(sb.following).toBe(true); + expect(sb.visible().join(" ")).toContain("three"); + }); + + test("wrap cache keeps repeated visible() cheap after a large append", () => { + const sb = new Scrollback(5_000, 20); + + sb.setWrapCols(80); + + for (let i = 0; i < 2_000; i += 1) { + sb.append(`line-${String(i)}-${"x".repeat(60)}\n`); + } + + // Warm the cache once, then hammer visible() — must stay well under a + // re-wrap-all-lines budget (the old path was tens of ms per call). + sb.visible(); + const t0 = performance.now(); + + for (let i = 0; i < 200; i += 1) { + expect(sb.visible().length).toBe(20); + } + + expect(performance.now() - t0).toBeLessThan(50); + }); +}); + +describe("computeLayout", () => { + test("splits into main + panel when wide enough", () => { + const layout = computeLayout({ rows: 20, cols: 100 }); + + expect(layout.collapsedPanel).toBe(false); + expect(layout.panel).not.toBeNull(); + expect(layout.panel?.cols).toBe(panelWidthFor(100)); + expect(layout.main.cols + 1 + (layout.panel?.cols ?? 0)).toBe(100); + expect(layout.top.rows).toBe(TOP_PAD_ROWS + 3); // pad + title + pad + rule + expect(layout.footer.rows).toBe(BOTTOM_PAD_ROWS); + expect(layout.footer.row + layout.footer.rows).toBe(20); + expect(layout.input.rows).toBe(INPUT_BAND_ROWS); + }); + + test("panelWidthFor adapts by content width", () => { + // Content cols ≈ term − 4; thresholds match 100- and 140-col terminals. + expect(panelWidthFor(80)).toBe(32); + expect(panelWidthFor(96)).toBe(36); + expect(panelWidthFor(136)).toBe(40); + expect(computeLayout({ rows: 20, cols: 160 }).panel?.cols).toBe(40); + }); + + test("keeps pinned topbar at PANE_MIN_ROWS content height", () => { + const layout = contentLayout(PANE_MIN_ROWS, 100); + + expect(layout.top.rows).toBe(TOP_PAD_ROWS + 3); + // Short height may shrink the input band slightly; never below 1. + expect(layout.input.rows).toBeGreaterThanOrEqual(1); + expect(layout.input.rows).toBeLessThanOrEqual(INPUT_BAND_ROWS); + }); + + test("collapses the panel on narrow terminals", () => { + const layout = computeLayout({ rows: 20, cols: 60 }); + + expect(layout.collapsedPanel).toBe(true); + expect(layout.panel).toBeNull(); + expect(layout.main.cols).toBe(60); + }); + + test("canUsePaneTui gates on PANE_MIN_ROWS", () => { + expect(canUsePaneTui(PANE_MIN_ROWS - 1)).toBe(false); + expect(canUsePaneTui(PANE_MIN_ROWS)).toBe(true); + }); + + test("input band grows with inputInnerRows and shrinks the body", () => { + const idle = computeLayout({ rows: 24, cols: 100, inputInnerRows: 1 }); + const tall = computeLayout({ rows: 24, cols: 100, inputInnerRows: 6 }); + + expect(idle.input.rows).toBe(INPUT_BAND_ROWS); + expect(tall.input.rows).toBeGreaterThan(idle.input.rows); + expect(tall.main.rows).toBeLessThan(idle.main.rows); + // Cap: asking for more than max does not grow further. + const capped = computeLayout({ rows: 24, cols: 100, inputInnerRows: 99 }); + + expect(capped.input.rows).toBe(tall.input.rows); + }); +}); + +describe("PaneScreen", () => { + class FakeTerm { + writes: string[] = []; + isTTY = true; + rows = 24; + columns = 100; + + write(data: string): boolean { + this.writes.push(data); + + return true; + } + + text(): string { + return this.writes.join(""); + } + } + + test("setInput stays fast with a large transcript (no full re-wrap)", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 40, 120); + + panes.enter(); + + // Warm a large wrap cache once — then keystrokes must stay on paintInputOnly. + const bulk = Array.from( + { length: 2_000 }, + (_, i) => `line-${String(i)}-${"x".repeat(80)}` + ).join("\n"); + + panes.appendMain(`${bulk}\n`); + + term.writes = []; + const t0 = performance.now(); + let text = ""; + + for (let i = 0; i < 100; i += 1) { + text += i % 5 === 0 ? " " : "a"; + panes.setInput({ lines: [text], cursorRow: 0, cursorCol: text.length }); + } + + // Full re-wrap on every key used to land in the multi‑second range here. + expect(performance.now() - t0).toBeLessThan(150); + expect(term.writes.length).toBeGreaterThan(0); + }); + + test("identical paint is a no-op (no cursor thrash); frames use sync wrap", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + expect(term.text()).toContain(BEGIN_SYNC); + expect(term.text()).toContain(END_SYNC); + + term.writes = []; + panes.paint(); + expect(term.writes).toHaveLength(0); + + // Status with the same formatted line as a prior identical setStatus is skipped. + panes.setStatus({ + model: "m", + contextTokens: 1, + contextWindow: 100, + turns: 0, + elapsedMs: 0, + status: "ready", + scope: "repo", + }); + const afterFirst = term.writes.length; + + panes.setStatus({ + model: "m", + contextTokens: 1, + contextWindow: 100, + turns: 0, + elapsedMs: 0, + status: "ready", + scope: "repo", + }); + expect(term.writes.length).toBe(afterFirst); + }); + + test("input box grows with draft lines and collapses when cleared", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + const idlePrompt = findPromptRow(term.text(), 24, 100); + + panes.setInput({ + lines: ["one", "two", "three"], + cursorRow: 2, + cursorCol: 5, + }); + + const screen = new VirtualScreen(24, 100); + + screen.feed(term.text()); + const grownPrompt = findPromptRow(term.text(), 24, 100); + + expect(grownPrompt).toBeLessThan(idlePrompt); + expect(screen.text()).toContain("one"); + expect(screen.text()).toContain("two"); + expect(screen.text()).toContain("three"); + + panes.setInput({ lines: [""], cursorRow: 0, cursorCol: 0 }); + expect(findPromptRow(term.text(), 24, 100)).toBe(idlePrompt); + }); + + test("enter paints both columns; leave exits alt screen", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + expect(panes.enter()).toBe(true); + expect(term.text()).toContain(ENTER_ALT); + // Opaque canvas — transparent terminals must not show wallpaper through blanks. + expect(term.text()).toContain("[48;2;20;20;20m"); + + panes.appendMain("hello main\n"); + panes.setPanel(["worklist 0/2", "[>] First"]); + panes.setInput({ lines: ["type"], cursorRow: 0, cursorCol: 4 }); + + const screen = new VirtualScreen(24, 100); + + screen.feed(term.text()); + expect(screen.text()).toContain("hello main"); + expect(screen.row(1).trim()).toBe(""); + expect(screen.row(2)).toContain("╭"); + expect(screen.row(titleRow(24))).toContain("TSFORGE"); + expect(screen.row(titleRow(24))).toContain("#0/2"); + expect(screen.row(outerBottomRow(24))).toContain("╰"); + expect(screen.text()).toContain("[>] First"); + expect(screen.text()).not.toContain("forge>"); + expect(screen.text()).toContain("type"); + expect(screen.row(expectedPromptRow(24))).toContain("type"); + + panes.leave(); + expect(term.text()).toContain(EXIT_ALT); + expect(panes.active).toBe(false); + }); + + test("overflow paints a main-pane scrollbar thumb; short content does not", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + panes.appendMain("short\n"); + + let screen = new VirtualScreen(24, 100); + + screen.feed(term.text()); + expect(screen.text()).not.toContain("█"); + + for (let i = 0; i < 40; i += 1) { + panes.appendMain(`scroll-line-${String(i)}\n`); + } + + screen = new VirtualScreen(24, 100); + screen.feed(term.text()); + expect(screen.text()).toContain("█"); + + // Thumb lives on the right edge of the main column (left of the panel gutter). + const insets = outerInsets(24, 100); + const layout = contentLayout(24, 100, true); + const thumbIdx = insets.originCol + layout.main.cols - 1; + let found = false; + + for (let r = 1; r <= 24; r += 1) { + if (screen.row(r)[thumbIdx] === "█") { + found = true; + break; + } + } + + expect(found).toBe(true); + }); + + test("landing keeps the side panel under the pinned topbar", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + panes.setHeader({ cwd: "/tmp/demo", sessionId: "abcd1234" }); + panes.setStatus({ + model: "deepseek", + contextTokens: 0, + contextWindow: 100, + turns: 0, + elapsedMs: 0, + status: "ready", + scope: "repo", + mode: "plan", + }); + panes.appendMain("/help commands\n"); + panes.setInput({ lines: [""], cursorRow: 0, cursorCol: 0 }); + + const screen = new VirtualScreen(24, 100); + + screen.feed(term.text()); + // Floating window + inner split: outer ╭, title, ┬ under title, gutter │. + const title = titleRow(24); + + expect(screen.row(1).trim()).toBe(""); + expect(screen.row(2)).toContain("╭"); + expect(screen.row(title)).toContain("TSFORGE"); + expect(screen.row(title)).toContain("deepseek"); + expect(screen.row(title)).toContain("PLAN"); + expect(screen.row(title)).toContain("✓"); + expect(screen.row(title)).toContain("#0/0"); + expect(screen.row(title)).toContain("repo"); + expect(isFramedAir(screen.row(title + 1))).toBe(true); + expect(screen.row(title + 2)).toContain("┬"); + expect(screen.row(title + 3)).toContain("/help commands"); + expect(screen.row(title + 3)).toContain("│"); + // Hairline ┬ → gutter │/├ → outer ┴ closes the panel spine. + const rule = screen.row(title + 2); + const gutterIdx = rule.indexOf("┬"); + // Sticky rail title under the ┬ hairline: Tasks … 0/0, then under-rule. + const railTitle = screen.row(title + 3); + const railRule = screen.row(title + 4); + + expect(gutterIdx).toBeGreaterThan(0); + expect(railTitle).toContain("Tasks"); + expect(railTitle).toContain("0/0"); + expect(railTitle.indexOf("Tasks")).toBeLessThan(railTitle.indexOf("0/0")); + expect(railRule).toMatch(/─{4,}/); + // Under-rule joins the gutter spine (├) and the outer rail (┤). + expect(railRule[gutterIdx]).toBe("├"); + expect(railRule.trimEnd().endsWith("┤")).toBe(true); + expect(screen.text()).toContain("approve a plan"); + expect(screen.row(promptBoxTop(24))).toContain("╭"); + expect(screen.row(expectedPromptRow(24))).toContain(">"); + expect(screen.row(expectedPromptRow(24))).toContain("describe a task"); + expect(screen.row(outerBottomRow(24))).toContain("╯"); + expect(screen.text()).not.toContain("forge>"); + // Input box right corners share a column (not the outer-window │). + const topPlain = screen.row(promptBoxTop(24)); + const midPlain = screen.row(expectedPromptRow(24)); + const botPlain = screen.row(promptBoxTop(24) + 2); + const topRight = topPlain.lastIndexOf("╮"); + const outerBot = screen.row(outerBottomRow(24)); + + expect(topRight).toBeGreaterThan(0); + expect(midPlain[topRight]).toBe("│"); + expect(botPlain[topRight]).toBe("╯"); + expect(rule.slice(gutterIdx + 1).includes("─")).toBe(true); + expect(midPlain[gutterIdx]).toBe("│"); + expect(outerBot[gutterIdx]).toBe("┴"); + // Input sits on the outer floor — last content row is the box bottom. + expect(screen.row(outerBottomRow(24) - 1)).toContain("╯"); + }); + + test("growing the input band still paints a single frame (≤ term rows)", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + term.writes = []; + panes.setInput({ + lines: ["a", "b", "c", "d", "e", "f"], + cursorRow: 5, + cursorCol: 1, + }); + + const cups = term + .text() + .match(new RegExp(`${String.fromCharCode(27)}\\[\\d+;1H`, "g")); + + // A full-frame paint touches ≤24 rows — not one CUP per editor line unboundedly. + expect((cups ?? []).length).toBeLessThanOrEqual(24); + }); + + test("wrapped scrollback keeps overflow text when scrolling", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, PANE_MIN_ROWS, 40); + + expect(panes.enter()).toBe(true); + panes.appendMain(`${"word ".repeat(20)}\n`); + panes.appendMain("TAIL_MARKER\n"); + + // Scroll up: older wrapped chunks must still be reconstructable from dump, + // and the viewport must still contain wrapped fragments (not a blank hole). + panes.handleKey("\x1b[A"); + panes.handleKey("\x1b[A"); + + expect(panes.dumpTranscript()).toContain("TAIL_MARKER"); + expect(term.text().length).toBeGreaterThan(0); + }); + + test("refuses to enter below PANE_MIN_ROWS", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 4, 100); + + expect(panes.enter()).toBe(false); + expect(term.writes).toHaveLength(0); + }); + + test("refuses to enter when the terminal is not a TTY", () => { + const term = new FakeTerm(); + + term.isTTY = false; + const panes = new PaneScreen(term, 24, 100); + + expect(panes.enter()).toBe(false); + expect(term.writes).toHaveLength(0); + }); + + test("resize re-enters after a shrink-leave; never auto-enters a fresh screen", () => { + const term = new FakeTerm(); + const neverStarted = new PaneScreen(term, 24, 100); + + neverStarted.resize(24, 100); + expect(neverStarted.active).toBe(false); + expect(term.writes).toHaveLength(0); + + const panes = new PaneScreen(term, 24, 100); + + expect(panes.enter()).toBe(true); + const afterEnter = term.writes.length; + + panes.resize(4, 100); + expect(panes.active).toBe(false); + + panes.resize(24, 100); + expect(panes.active).toBe(true); + expect(term.writes.length).toBeGreaterThan(afterEnter); + }); + + test("Ctrl+O requests dump; scrollMain moves transcript", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + panes.appendMain("a\nb\nc\nd\ne\n"); + + expect(panes.handleKey("\x0f")).toBe("dump"); + // Prompt keeps arrows; empty-prompt scroll uses scrollMain (REPL wiring). + expect(panes.handleKey("\x1b[A")).toBe("passthrough"); + panes.scrollMain(1); + expect(term.text().length).toBeGreaterThan(0); + expect(panes.handleKey("x")).toBe("passthrough"); + }); + + test("enter enables mouse capture so the host cannot scroll the window", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + expect(term.text()).toContain("\x1b[?1000h"); + expect(term.text()).toContain("\x1b[?1006h"); + expect(term.text()).toContain(ENTER_ALT); + }); + + test("mouse reports are swallowed as handled", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + expect(panes.handleKey("\x1b[<0;98;13M")).toBe("handled"); + expect(panes.handleKey("\x1b[<0;98;13m")).toBe("handled"); + }); + + test("wheel over main scrolls transcript; wheel over panel scrolls rail only", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + + for (let i = 0; i < 40; i += 1) { + panes.appendMain(`MAIN_${String(i)}\n`); + } + + panes.setPanel( + Array.from({ length: 40 }, (_, i) => + i === 0 ? "worklist 0/39" : `PANEL_${String(i)}` + ) + ); + + // Main column: wheel must not move the input band. + expect(panes.handleKey("\x1b[<64;10;5M")).toBe("handled"); + expect(findPromptRow(term.text(), 24, 100)).toBe(expectedPromptRow(24)); + + // Panel column: scroll rail — early PANEL lines leave the viewport. + panes.scrollPanel(20); + const after = new VirtualScreen(24, 100); + + after.feed(term.text()); + expect(after.text()).not.toContain("PANEL_1"); + expect(after.text()).toMatch(/PANEL_\d+/); + expect(findPromptRow(term.text(), 24, 100)).toBe(expectedPromptRow(24)); + expect(after.row(expectedPromptRow(24)).length).toBeGreaterThan(0); + }); + + test("preserves SGR in appended main text", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + panes.appendMain("\x1b[31mred\x1b[0m plain\n"); + + expect(term.text()).toContain("\x1b[31mred"); + + const screen = new VirtualScreen(24, 100); + + screen.feed(term.text()); + expect(screen.text()).toContain("red plain"); + }); + + test("oversized main lines never overwrite the panel gutter", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + const insets = outerInsets(24, 100); + const layout = contentLayout(24, 100); + + expect(layout.panel).not.toBeNull(); + const gutterCol = insets.originCol + layout.main.cols; + + panes.enter(); + // Wider than the main pane — old path let this punch through the gutter. + panes.appendMain(`│ ${"W".repeat(200)}\n`); + panes.setPanel(["approve a plan"]); + + const screen = new VirtualScreen(24, 100); + + screen.feed(term.text()); + + let sawContentRow = false; + const mainTop = insets.originRow + layout.main.row + 1; + const mainBot = insets.originRow + layout.main.row + layout.main.rows; + + for (let r = mainTop; r <= mainBot; r += 1) { + const row = screen.row(r); + + if (!row.includes("W")) { + continue; + } + + sawContentRow = true; + // Panel gutter column stays a gutter glyph (│ spine or ├ under-rule) — never "W". + const gutter = row[gutterCol] ?? ""; + + expect(gutter === "│" || gutter === "├").toBe(true); + // First panel cell is not overflowing main content. + expect(row[gutterCol + 1]).not.toBe("W"); + } + + expect(sawContentRow).toBe(true); + }); + + test("setOverlay paints dropdown rows above the input strip", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + panes.setInput({ lines: [""], cursorRow: 0, cursorCol: 0 }); + panes.setOverlay([" src/a.ts", "▸ src/b.ts"]); + + const screen = new VirtualScreen(24, 100); + + screen.feed(term.text()); + expect(screen.text()).toContain("src/a.ts"); + expect(screen.text()).toContain("src/b.ts"); + expect(screen.text()).toContain("describe a task"); + expect(screen.text()).not.toContain("forge>"); + }); + + test("setOverlay stays in the main pane and keeps the panel gutter", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + const insets = outerInsets(24, 100); + const layout = contentLayout(24, 100); + + expect(layout.panel).not.toBeNull(); + const gutterCol = insets.originCol + layout.main.cols; + + panes.enter(); + panes.setPanel(["worklist", "item-a"]); + panes.setInput({ lines: [""], cursorRow: 0, cursorCol: 0 }); + // Full-width hairline — old paint path drew this across the panel. + panes.setOverlay([ + "/help", + `› /help${" ".repeat(80)}`, + "─".repeat(200), + "show this help", + ]); + + const screen = new VirtualScreen(24, 100); + + screen.feed(term.text()); + + let sawOverlay = false; + const mainTop = insets.originRow + layout.main.row + 1; + const mainBot = insets.originRow + layout.main.row + layout.main.rows; + + for (let r = mainTop; r <= mainBot; r += 1) { + const row = screen.row(r); + const mainSlice = row.slice(0, gutterCol); + + // Only rows where the overlay painted into the main column. + if (!mainSlice.includes("/help") && !mainSlice.includes("─".repeat(20))) { + continue; + } + + sawOverlay = true; + expect(row[gutterCol]).toBe("│"); + // Overlay must not punch through the gutter (panel may have its own short title rule). + const panelSlice = row.slice(gutterCol + 1); + + expect(panelSlice.includes("─".repeat(40))).toBe(false); + } + + expect(sawOverlay).toBe(true); + expect(screen.text()).toContain("Tasks"); + expect(screen.text()).toContain("item-a"); + }); + + test("Tasks rail shows wrapped checklist with adaptive panel width", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + const wrapCols = panes.panelInnerCols(); + + panes.setWorklistBadge("0/2"); + panes.setPanel( + formatWorklistLines( + { + schemaVersion: 2, + id: "plan-tui", + goal: "PLAN.md", + activeItemId: "a", + updatedAt: "2026-01-01T00:00:00.000Z", + items: [ + { + id: "a", + title: + "Accept a one-line description via argument or interactive prompt.", + status: "active", + }, + { + id: "b", + title: "Create a flat list of independent verifiable features.", + status: "pending", + }, + ], + }, + { columns: wrapCols, color: false } + ) + ); + panes.setInput({ lines: [""], cursorRow: 0, cursorCol: 0 }); + + const screen = new VirtualScreen(24, 100); + + screen.feed(term.text()); + const text = screen.text(); + + expect(wrapCols).toBe(insetInnerCols(panelWidthFor(100))); + expect(wrapCols).toBeLessThan(panelWidthFor(100)); + expect(text).toContain("Tasks"); + expect(text).toContain("0/2"); + expect(text).toContain("PLAN.md"); + expect(text).toContain("▸"); + // Word-aware wrap + inset-matched budget — no mid-word clip at the rail edge. + expect(text).toContain("independent"); + expect(text).toContain("via"); + expect(text).not.toMatch(/independen[^t]/u); + expect(text).not.toMatch(/\bvi\b/u); + }); + + test("setOverlay pins the title when the menu exceeds the chrome budget", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + panes.setInput({ lines: [""], cursorRow: 0, cursorCol: 0 }); + panes.setOverlay([ + "menu-title-line", + ...Array.from({ length: 40 }, (_, i) => `menu-body-${String(i)}`), + "menu-footer-line", + ]); + + const screen = new VirtualScreen(24, 100); + + screen.feed(term.text()); + const text = screen.text(); + + expect(text).toContain("menu-title-line"); + expect(text).toContain("menu-footer-line"); + // Mid-body rows are sacrificed for the title + tail pin. + expect(text).not.toContain("menu-body-0"); + }); + + test("formatMenuRows overlay keeps title and ▸ cursor on a short pane", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + panes.setInput({ lines: [""], cursorRow: 0, cursorCol: 0 }); + + const rows = Array.from({ length: 30 }, (_, i) => ({ + id: `r${String(i)}`, + label: `cmd-${String(i)}`, + describe: `about ${String(i)}`, + })); + const budget = panes.overlayBudgetRows(); + const lines = formatMenuRows( + rows, + 12, + panes.mainInnerCols(), + budget, + false, + "commands" + ); + + panes.setOverlay(lines); + + const screen = new VirtualScreen(24, 100); + + screen.feed(term.text()); + const text = screen.text(); + + expect(text).toContain("commands"); + expect(text).toContain("▸ cmd-12"); + expect(text).toContain("about 12"); + }); + + test("setStatus paints live chips on the dense top strip", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + panes.setWorklistBadge("2/5"); + panes.setStatus({ + model: "test-model", + contextTokens: 12_000, + contextWindow: 100_000, + turns: 2, + elapsedMs: 1500, + status: "responded", + scope: "repo", + mode: "plan", + tokensPerSecond: 42, + }); + + const screen = new VirtualScreen(24, 100); + + screen.feed(term.text()); + // All live chips live on the dense top strip; bottom is caret + placeholder. + const title = titleRow(24); + + expect(screen.row(title)).toContain("TSFORGE"); + expect(screen.row(title)).toContain("test-model"); + expect(screen.row(title)).toContain("12%"); + expect(screen.row(title)).toContain("42t"); + expect(screen.row(title)).toContain("#2/5"); + expect(screen.row(title)).toContain("✓"); + expect(screen.row(expectedPromptRow(24))).toContain("describe a task"); + expect(screen.text()).not.toContain("tok/s"); + expect(screen.text()).not.toContain("forge>"); + }); + + test("after paint, cursor sits on the input row (not under the footer)", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + const promptRow = expectedPromptRow(24); + + panes.enter(); + panes.setInput({ lines: [""], cursorRow: 0, cursorCol: 0 }); + panes.setStatus({ + model: "deepseek", + contextTokens: 0, + contextWindow: 100, + turns: 1, + elapsedMs: 1100, + status: "responded", + scope: "repo", + mode: "plan", + tokensPerSecond: 51, + }); + // Second status tick used to skip the cursor CUP (CursorState dedupe) while + // the footer write left the hardware cursor on a scrolled row under the frame. + panes.setStatus({ + model: "deepseek", + contextTokens: 0, + contextWindow: 100, + turns: 1, + elapsedMs: 1200, + status: "responded", + scope: "repo", + mode: "plan", + tokensPerSecond: 50, + }); + panes.appendMain("◆ plan · reply to refine\n"); + + const screen = new VirtualScreen(24, 100); + + screen.feed(term.text()); + const { row, col } = screen.cursorPosition(); + + // On caret row, after outer chrome + left inset + `│` + pad + `> `. + const insets = outerInsets(24, 100); + + expect(row).toBe(promptRow); + expect(col).toBe(insets.originCol + CHROME_PAD_X + inputCursorCol(0) + 1); + expect(screen.row(promptRow)).toContain("describe a task"); + expect(screen.row(outerBottomRow(24))).toContain("╯"); + // Input box shares the agent card's left edge (chrome inset). + expect(screen.row(promptBoxTop(24)).indexOf("╭")).toBe( + insets.originCol + CHROME_PAD_X + ); + expect(screen.row(titleRow(24))).toContain("deepseek"); + expect(screen.row(titleRow(24))).toContain("✓"); + }); + + test("setStatus activity ticks do not rewrite body rows", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + panes.appendMain("chat line unique-body-marker\n"); + panes.setStatus({ + model: "deepseek", + contextTokens: 0, + contextWindow: 100, + turns: 1, + elapsedMs: 1100, + status: "working", + scope: "entire workspace", + mode: "plan", + tokensPerSecond: 47, + activity: "⠋ thinking · 0s", + }); + + term.writes = []; + + for (let i = 1; i <= 8; i += 1) { + panes.setStatus({ + model: "deepseek", + contextTokens: 0, + contextWindow: 100, + turns: 1, + elapsedMs: 1100 + i * 100, + status: "working", + scope: "entire workspace", + mode: "plan", + tokensPerSecond: 47, + activity: `⠋ thinking · ${String(i)}s`, + }); + } + + const out = term.writes.join(""); + + // Top strip updated; body rows stayed warm (differential paint). + expect(out).toContain("thinking"); + expect(out).not.toContain("unique-body-marker"); + expect(out.length).toBeLessThan(8 * 24 * 100); + // Transcript buffer still holds the body line. + expect(panes.dumpTranscript()).toContain("unique-body-marker"); + }); + + test("appendMain while following patches without rewriting the top strip", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + panes.setStatus({ + model: "deepseek", + contextTokens: 0, + contextWindow: 100, + turns: 0, + elapsedMs: 0, + status: "ready", + scope: "repo", + mode: "plan", + }); + panes.appendMain("seed\n"); + term.writes = []; + + for (let i = 0; i < 20; i += 1) { + panes.appendMain(`stream-chunk-${String(i)}\n`); + } + + const out = term.writes.join(""); + + expect(out).toContain("stream-chunk-19"); + // Top strip brand should not be repainted on every follow-mode chunk. + expect(out).not.toContain("TSFORGE"); + }); + + test("resize with paint:false then setStatus clears once", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + panes.setStatus({ + model: "m", + contextTokens: 0, + contextWindow: 100, + turns: 0, + elapsedMs: 0, + status: "ready", + scope: "repo", + }); + term.writes = []; + panes.resize(40, 120, { paint: false }); + expect(term.writes).toHaveLength(0); + + panes.setStatus({ + model: "m", + contextTokens: 0, + contextWindow: 100, + turns: 0, + elapsedMs: 0, + status: "ready", + scope: "repo", + }); + const out = term.writes.join(""); + const clears = out.split(CLEAR_SCREEN).length - 1; + + expect(clears).toBe(1); + expect(out).toContain(CLEAR_SCREEN); + }); + + test("Ctrl+G focuses panel; Esc restores prompt focus", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + panes.setPanel(["worklist 0/2", "[>] First", "[ ] Second"]); + expect(panes.handleKey("\x07")).toBe("handled"); + expect(panes.focusState.panelFocused).toBe(true); + + const focused = new VirtualScreen(24, 100); + + focused.feed(term.text()); + expect(focused.text()).toContain("▸"); + + expect(panes.handleKey("\x1b")).toBe("handled"); + expect(panes.focusState.promptFocused).toBe(true); + }); + + test("input caret stays put across status ticks/overlays; multiline moves the band", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + const promptRow = expectedPromptRow(24); + + panes.enter(); + panes.setInput({ lines: ["stable"], cursorRow: 0, cursorCol: 6 }); + expect(findPromptRow(term.text(), 24, 100)).toBe(promptRow); + + panes.setStatus({ + model: "m", + contextTokens: 1, + contextWindow: 100, + turns: 1, + elapsedMs: 100, + status: "ready", + scope: "repo", + tokensPerSecond: 10, + }); + expect(findPromptRow(term.text(), 24, 100)).toBe(promptRow); + + panes.setStatus({ + model: "m", + contextTokens: 50, + contextWindow: 100, + turns: 3, + elapsedMs: 9000, + status: "responded", + scope: "repo", + mode: "plan", + tokensPerSecond: 99, + activity: "⠋ thinking · 12s", + }); + expect(findPromptRow(term.text(), 24, 100)).toBe(promptRow); + + panes.setBusy(true); + expect(findPromptRow(term.text(), 24, 100)).toBe(promptRow); + + panes.setOverlay( + Array.from({ length: 40 }, (_, i) => `overlay-${String(i)}`) + ); + expect(findPromptRow(term.text(), 24, 100)).toBe(promptRow); + + panes.setInput({ + lines: ["a", "b", "c", "d", "e", "f"], + cursorRow: 5, + cursorCol: 1, + }); + const grownPrompt = findPromptRow(term.text(), 24, 100); + + expect(grownPrompt).toBeLessThan(promptRow); + + for (let i = 0; i < 30; i += 1) { + panes.appendMain(`stream-line-${String(i)}\n`); + } + + // Streaming must not shove the grown band around. + expect(findPromptRow(term.text(), 24, 100)).toBe(grownPrompt); + + const finalScreen = new VirtualScreen(24, 100); + + finalScreen.feed(term.text()); + expect(finalScreen.row(grownPrompt + 5)).toContain("f"); + expect(finalScreen.row(titleRow(24))).toMatch(/✓|●|thinking|abort/i); + expect(finalScreen.text()).toContain("╭"); + expect(finalScreen.text()).not.toContain("forge>"); + }); + + test("resize clears the screen and keeps input+footer pinned", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + panes.setStatus({ + model: "m", + contextTokens: 0, + contextWindow: 100, + turns: 0, + elapsedMs: 0, + status: "ready", + scope: "repo", + }); + panes.appendMain("KEEP_ME\n"); + panes.setInput({ lines: ["hi"], cursorRow: 0, cursorCol: 2 }); + + term.writes = []; + panes.resize(40, 120); + expect(term.text()).toContain(CLEAR_SCREEN); + + const tall = new VirtualScreen(40, 120); + + tall.feed(term.text()); + const tallBox = findPromptRow(term.text(), 40, 120); + + expect(tallBox).toBe(expectedPromptRow(40, 120)); + expect(tall.row(expectedPromptRow(40, 120)).length).toBeGreaterThan(0); + expect(tall.text()).toContain("KEEP_ME"); + expect(tall.text()).toContain("hi"); + expect(tall.row(expectedPromptRow(40, 120))).toContain("hi"); + expect(tall.row(titleRow(40, 120))).toContain("#0/0"); + expect(tall.text()).not.toContain("forge>"); + + term.writes = []; + panes.resize(20, 60); + expect(term.text()).toContain(CLEAR_SCREEN); + + const narrow = new VirtualScreen(20, 60); + + narrow.feed(term.text()); + const narrowLayout = contentLayout(20, 60); + + expect(narrowLayout.collapsedPanel).toBe(true); + expect(findPromptRow(term.text(), 20, 60)).toBe(expectedPromptRow(20, 60)); + expect(narrow.row(expectedPromptRow(20, 60))).toContain("hi"); + // Narrow: no panel split tee (outer │ still frames the window). + expect(narrow.row(titleRow(20, 60) + 2)).not.toContain("┬"); + expect(narrow.text()).not.toContain("approve a plan"); + }); + + test("resize no-op when geometry unchanged does not clear", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + term.writes = []; + panes.resize(24, 100); + expect(term.text()).not.toContain(CLEAR_SCREEN); + expect(term.writes).toHaveLength(0); + }); + + test("landing and mid-stream paint fill every row without holes in chrome", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + panes.setStatus({ + model: "deepseek", + contextTokens: 0, + contextWindow: 100, + turns: 0, + elapsedMs: 0, + status: "ready", + scope: "repo", + mode: "plan", + }); + panes.appendMain("LOG_LINE\n"); + panes.setInput({ lines: [""], cursorRow: 0, cursorCol: 0 }); + + let screen = new VirtualScreen(24, 100); + + screen.feed(term.text()); + const title = titleRow(24); + + expect(screen.row(2)).toContain("╭"); + expect(screen.row(title)).toContain("TSFORGE"); + expect(screen.row(title)).toContain("PLAN"); + expect(screen.row(title)).toContain("deepseek"); + expect(isFramedAir(screen.row(title + 1))).toBe(true); + expect(screen.row(title + 2)).toContain("┬"); + expect(screen.row(title + 3)).toContain("LOG_LINE"); + expect(screen.row(title + 3)).toContain("│"); + expect(findPromptRow(term.text(), 24, 100)).toBe(expectedPromptRow(24)); + expect(screen.row(expectedPromptRow(24))).toContain("describe a task"); + + for (let i = 0; i < 50; i += 1) { + panes.appendMain(`line-${String(i)}\n`); + } + + screen = new VirtualScreen(24, 100); + screen.feed(term.text()); + expect(findPromptRow(term.text(), 24, 100)).toBe(expectedPromptRow(24)); + expect(screen.row(expectedPromptRow(24))).toContain("describe a task"); + expect(screen.row(titleRow(24))).toContain("#0/0"); + expect(screen.text()).not.toContain("forge>"); + }); + + test("layout pins top and bottom chrome without stealing the input band", () => { + const wide = contentLayout(24, 100); + const wideInsets = outerInsets(24, 100); + + expect(wide.input.rows).toBe(INPUT_BAND_ROWS); + expect(wide.footer.rows).toBe(BOTTOM_PAD_ROWS); + expect(wide.input.rows + wide.footer.rows).toBe(BOTTOM_CHROME_ROWS); + expect(wide.top.rows + wide.main.rows + BOTTOM_CHROME_ROWS).toBe( + wideInsets.contentRows + ); + expect(wide.top.rows).toBe(TOP_PAD_ROWS + 3); + expect(wide.collapsedPanel).toBe(false); + expect(wide.panel).not.toBeNull(); + + const short = contentLayout(PANE_MIN_ROWS, 100); + const shortInsets = outerInsets(PANE_MIN_ROWS, 100); + + expect(short.top.rows).toBe(TOP_PAD_ROWS + 3); + expect(short.input.rows).toBeGreaterThanOrEqual(1); + expect( + short.top.rows + short.main.rows + short.input.rows + short.footer.rows + ).toBe(shortInsets.contentRows); + }); +}); diff --git a/packages/core/tests/greenfield.test.ts b/packages/core/tests/greenfield.test.ts index 0e918aa5..469c5067 100644 --- a/packages/core/tests/greenfield.test.ts +++ b/packages/core/tests/greenfield.test.ts @@ -106,6 +106,23 @@ describe("greenfield state", () => { expect(md).toContain("- [ ] b"); }); + test("optional stateName writes under .tsforge// without touching greenfield/", async () => { + const s = state("a"); + + await saveState(dir, s, "worklist"); + + expect(await hasState(dir, "worklist")).toBe(true); + expect(await hasState(dir)).toBe(false); + expect(greenfieldDir(dir, "worklist")).toBe( + join(dir, ".tsforge", "worklist") + ); + + const loaded = await loadState(dir, "worklist"); + + expect(loaded?.features.map((f) => f.id)).toEqual(["a"]); + expect(await loadState(dir)).toBeNull(); + }); + test("saveState → loadState round-trips lastError when present", async () => { const s = state("a"); diff --git a/packages/core/tests/input-box.test.ts b/packages/core/tests/input-box.test.ts new file mode 100644 index 00000000..756c6096 --- /dev/null +++ b/packages/core/tests/input-box.test.ts @@ -0,0 +1,91 @@ +import { test, expect, describe } from "bun:test"; +import { + formatInputBox, + formatInputBoxBottom, + formatInputBoxMid, + formatInputBoxTop, + formatInputStatusLabel, + INPUT_EDITOR_GUTTER, + INPUT_PROMPT, + inputContentCols, + inputCursorCol, +} from "../src/render/frame/input-box"; +import { stripSgr } from "../src/render/frame/ansi-plain"; +import { displayWidth } from "../src/render/width"; + +describe("input box", () => { + test("gutter reserves borders, pads, and prompt", () => { + expect(INPUT_EDITOR_GUTTER).toBe(10); + expect(inputContentCols(80)).toBe(70); + expect(inputCursorCol(0)).toBe(1 + 3 + INPUT_PROMPT.length); + }); + + test("status label stays empty — top strip owns session chips", () => { + expect( + formatInputStatusLabel({ + model: "deepseek-chat", + contextTokens: 0, + contextWindow: 100, + turns: 0, + elapsedMs: 0, + status: "ready", + scope: "repo", + mode: "plan", + }) + ).toBe(""); + + expect(formatInputStatusLabel(null)).toBe(""); + }); + + test("top / mid / bottom are closed box rows of exact width", () => { + const cols = 40; + const top = stripSgr(formatInputBoxTop(cols, false)); + const mid = stripSgr(formatInputBoxMid(cols, "hi", false)); + const bottom = stripSgr(formatInputBoxBottom(cols, "", false)); + + expect(top.startsWith("╭")).toBe(true); + expect(top.endsWith("╮")).toBe(true); + expect(displayWidth(top)).toBe(cols); + + expect(mid.startsWith("│")).toBe(true); + expect(mid.endsWith("│")).toBe(true); + expect(mid).toContain(`${INPUT_PROMPT}hi`); + expect(displayWidth(mid)).toBe(cols); + // Equal inner air: 3 spaces after left rail, ≥3 before right rail. + expect(mid.startsWith("│ >")).toBe(true); + expect(mid.endsWith(" │")).toBe(true); + + expect(bottom.startsWith("╰")).toBe(true); + expect(bottom.endsWith("╯")).toBe(true); + expect(displayWidth(bottom)).toBe(cols); + }); + + test("empty draft shows placeholder; caret sits after prompt", () => { + const box = formatInputBox({ + cols: 36, + draft: "", + placeholder: "describe a task, or /help", + color: false, + }); + + expect(box.lines).toHaveLength(3); + expect(box.lines[1] ?? "").toContain("> "); + expect(box.lines[1] ?? "").toContain("describe a task, or /help"); + expect(box.cursorCol).toBe(inputCursorCol(0)); + }); + + test("multi-line draft grows the box; only the first mid row has >", () => { + const box = formatInputBox({ + cols: 36, + draftLines: ["hello", "world", "again"], + color: false, + }); + + expect(box.lines).toHaveLength(5); // top + 3 mids + bottom + expect(stripSgr(box.lines[1] ?? "")).toContain("> hello"); + expect(stripSgr(box.lines[2] ?? "")).toContain("world"); + expect(stripSgr(box.lines[2] ?? "")).not.toContain("> world"); + expect(stripSgr(box.lines[0] ?? "").startsWith("╭")).toBe(true); + expect(stripSgr(box.lines[4] ?? "").startsWith("╰")).toBe(true); + }); +}); diff --git a/packages/core/tests/menu-chrome.test.ts b/packages/core/tests/menu-chrome.test.ts new file mode 100644 index 00000000..eff9d215 --- /dev/null +++ b/packages/core/tests/menu-chrome.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { CONSOLE } from "../src/render/frame/chrome"; +import { + MENU_FOOTER_NAV, + formatMenuRow, + formatOverlayShell, + menuBodyBudget, + menuRule, + menuWindow, +} from "../src/render/menu-chrome"; +import { formatMenuRows } from "../src/render/inline-menu"; +import { STYLE } from "../src/render/style"; +import { stripSgr } from "../src/render/frame"; + +describe("menu-chrome", () => { + test("formatMenuRow uses ▸ + CONSOLE.bright when active", () => { + const row = formatMenuRow({ + label: "/copy", + hint: "on", + active: true, + columns: 40, + color: true, + }); + + expect(stripSgr(row)).toMatch(/^▸ /); + expect(row).toContain(CONSOLE.bright); + expect(row).not.toContain(STYLE.cyan); + }); + + test("formatMenuRow inactive has no accent paint", () => { + const row = formatMenuRow({ + label: "/clear", + active: false, + columns: 40, + color: true, + }); + + expect(stripSgr(row)).toMatch(/^ {2}/); + expect(row).not.toContain(CONSOLE.bright); + }); + + test("formatOverlayShell orders title, body, rule+describe, footer", () => { + const lines = formatOverlayShell({ + title: "commands", + bodyLines: [" body"], + describe: "does a thing", + footer: MENU_FOOTER_NAV, + columns: 40, + color: false, + }); + const plain = lines.map(stripSgr); + + expect(plain[0]).toBe("commands"); + expect(plain).toContain(" body"); + expect(plain.some((l) => l.includes("─"))).toBe(true); + expect(plain).toContain("does a thing"); + expect(plain.at(-1)).toContain("↑/↓ move"); + }); + + test("menuRule uses CONSOLE.rule", () => { + const rule = menuRule(20, true); + + expect(rule).toContain(CONSOLE.rule); + expect(stripSgr(rule)).toBe("─".repeat(20)); + }); + + test("menuWindow keeps cursor visible", () => { + expect(menuWindow(12, 9, 4)).toEqual({ start: 7, end: 11 }); + expect(menuWindow(3, 0, 8)).toEqual({ start: 0, end: 3 }); + }); + + test("menuBodyBudget leaves room for chrome", () => { + expect(menuBodyBudget(20, { hasDescribe: true })).toBeLessThan(20); + expect(menuBodyBudget(20, { hasDescribe: true })).toBeGreaterThanOrEqual(1); + }); + + test("formatMenuRows fits a short overlay budget and keeps ▸ on cursor", () => { + const rows = Array.from({ length: 20 }, (_, i) => ({ + id: `r${String(i)}`, + label: `Item ${String(i)}`, + describe: `desc ${String(i)}`, + })); + const lines = formatMenuRows(rows, 10, 60, 12, false, "commands"); + const plain = lines.map(stripSgr).join("\n"); + + expect(plain).toContain("commands"); + expect(plain).toContain("▸ Item 10"); + expect(plain).toContain("desc 10"); + expect(plain).toContain(MENU_FOOTER_NAV); + // Must not dump the full list into a 12-row budget. + expect(plain).not.toContain("Item 0"); + }); +}); diff --git a/packages/core/tests/message-render.test.ts b/packages/core/tests/message-render.test.ts index 9c95d166..098cb4d7 100644 --- a/packages/core/tests/message-render.test.ts +++ b/packages/core/tests/message-render.test.ts @@ -1,5 +1,14 @@ import { test, expect, describe } from "bun:test"; -import { renderMessage, userBubble, agentCardTop } from "../src/render"; +import { + renderMessage, + userBubble, + agentCardTop, + agentCardBottom, + agentCardPadRow, + agentCardRow, + roleCardCols, +} from "../src/render"; +import { planHint } from "../src/cli/banner"; import { displayWidth } from "../src/render/width"; const ESC = String.fromCharCode(27); @@ -9,7 +18,7 @@ function stripAnsi(s: string): string { } describe("renderMessage — hybrid bubbles", () => { - test("a user message renders a full rounded bubble", () => { + test("a user message renders a closed USER card (AGENT twin, cyan)", () => { const out = stripAnsi( renderMessage( { role: "user", content: "hey there" }, @@ -17,12 +26,17 @@ describe("renderMessage — hybrid bubbles", () => { ) ); - expect(out).toContain("╭─ you "); - expect(out).toContain("│ hey there"); - expect(out).toContain("╯"); // bottom-right corner closes the bubble + expect(out).toContain(" USER "); + expect(out).toContain("┐"); + expect(out).toContain("└"); + expect(out).toContain("┘"); + expect(out).toMatch(/│ {2}hey there\s+│/); + expect(out).not.toContain("▌"); + expect(out).not.toContain("╭"); + expect(out).not.toContain("╰"); }); - test("an assistant message renders a left-accent card with a rail", () => { + test("an assistant message renders a closed AGENT card", () => { const out = stripAnsi( renderMessage( { role: "assistant", content: "line one\nline two" }, @@ -30,16 +44,91 @@ describe("renderMessage — hybrid bubbles", () => { ) ); - expect(out).toContain("╭ some-model"); // rounded top cap + model label - expect(out).toContain("│ line one"); - expect(out).toContain("│ line two"); - expect(out).toContain("╰"); // bottom cap closes the card + expect(out).toContain(" AGENT "); + expect(out).toContain("┐"); + expect(out).toContain("└"); + expect(out).toContain("┘"); + expect(out).not.toContain("some-model"); + expect(out).toMatch(/│ {2}line one\s+│/); + expect(out).toMatch(/│ {2}line two\s+│/); + expect(out).not.toContain("╭"); + expect(out).not.toContain("╰"); }); test("system and tool messages render nothing", () => { expect(renderMessage({ role: "system", content: "x" })).toBe(""); expect(renderMessage({ role: "tool", content: "x" })).toBe(""); }); + + test("cards honor columns — resume must pass pane mainInnerCols, not stdout", () => { + const narrow = 40; + const wide = 120; + const userNarrow = stripAnsi( + renderMessage( + { role: "user", content: "hey" }, + { color: false, columns: narrow } + ) + ); + const userWide = stripAnsi( + renderMessage( + { role: "user", content: "hey" }, + { color: false, columns: wide } + ) + ); + const topNarrow = userNarrow.split("\n").find((l) => l.includes("USER")); + const topWide = userWide.split("\n").find((l) => l.includes("USER")); + + expect(topNarrow).toBeDefined(); + expect(topWide).toBeDefined(); + expect(displayWidth(topNarrow ?? "")).toBe(roleCardCols(narrow)); + expect(displayWidth(topWide ?? "")).toBe(roleCardCols(wide)); + expect(displayWidth(topNarrow ?? "")).toBeLessThan( + displayWidth(topWide ?? "") + ); + }); +}); + +describe("role card alignment", () => { + test("USER / AGENT / PLAN hug their labels and share the card right edge", () => { + const cols = 40; + const userTop = + stripAnsi(userBubble("hi", false, cols)).split("\n")[0] ?? ""; + const agentTop = stripAnsi(agentCardTop(false, cols)); + const planTop = stripAnsi(planHint(false, cols)).split("\n")[0] ?? ""; + const agentBottom = stripAnsi(agentCardBottom(false, cols)); + const agentRow = stripAnsi(agentCardRow("hi", false, cols)); + + expect(userTop.startsWith(" USER ")).toBe(true); + expect(agentTop.startsWith(" AGENT ")).toBe(true); + expect(planTop.startsWith(" PLAN ")).toBe(true); + // No fixed-width right pad on shorter labels. + expect(userTop.startsWith(" USER ")).toBe(false); + expect(planTop.startsWith(" PLAN ")).toBe(false); + + expect(displayWidth(userTop)).toBe(cols); + expect(displayWidth(agentTop)).toBe(cols); + expect(displayWidth(planTop)).toBe(cols); + expect(displayWidth(agentBottom)).toBe(cols); + expect(displayWidth(agentRow)).toBe(cols); + + expect(userTop.endsWith("┐")).toBe(true); + expect(agentTop.endsWith("┐")).toBe(true); + expect(agentBottom.endsWith("┘")).toBe(true); + expect(agentRow.endsWith("│")).toBe(true); + + const userBottom = + stripAnsi(userBubble("hi", false, cols)) + .split("\n") + .at(-1) ?? ""; + + expect(userBottom.startsWith("└")).toBe(true); + expect(userBottom.endsWith("┘")).toBe(true); + expect(displayWidth(userBottom)).toBe(cols); + }); + + test("roleCardCols floors at the badge+cap minimum", () => { + expect(roleCardCols(1)).toBeGreaterThanOrEqual(9); + }); }); describe("userBubble", () => { @@ -53,16 +142,66 @@ describe("userBubble", () => { expect(displayWidth(row)).toBeLessThanOrEqual(columns); } }); + + test("filled badge and rails use cyan when color is on", () => { + const out = userBubble("hi", true, 40); + + expect(out).toContain("[48;2;34;211;238m"); + expect(out).toContain("[38;2;34;211;238m"); + expect(stripAnsi(out)).toMatch(/│ {2}hi\s+│/); + expect(stripAnsi(out)).not.toContain("▌"); + }); + + test("closed card: pad row under hairline, body, pad, bottom", () => { + const rows = stripAnsi(userBubble("hi", false, 40)).split("\n"); + + expect(rows[0]?.startsWith(" USER ")).toBe(true); + expect(rows[0]?.endsWith("┐")).toBe(true); + expect(rows[1]?.startsWith("│")).toBe(true); + expect(rows[1]?.endsWith("│")).toBe(true); + expect(/^│\s+│$/.test(rows[1] ?? "")).toBe(true); + expect(rows[2]).toMatch(/│ {2}hi\s+│/); + expect(rows.at(-1)?.startsWith("└")).toBe(true); + expect(rows.at(-1)?.endsWith("┘")).toBe(true); + }); +}); + +describe("agentCardPadRow", () => { + test("empty pad keeps both rails in one SGR span (no mid-line RESET)", () => { + const row = agentCardPadRow(true, 40); + const first = row.indexOf("│"); + const last = row.lastIndexOf("│"); + const between = row.slice(first + 1, last); + + expect(first).toBeGreaterThanOrEqual(0); + expect(last).toBeGreaterThan(first); + // A RESET between the rails was the iTerm bright-fleck bug on empty rows. + expect(between.includes("\x1b[0m")).toBe(false); + expect(row).toContain("[38;2;82;82;91m"); + expect(displayWidth(stripAnsi(row))).toBe(40); + }); }); describe("agentCardTop", () => { - test("labels the card with a rounded cap + model name", () => { - expect(stripAnsi(agentCardTop("qwen3", false))).toBe("╭ qwen3"); + test("labels the card with a filled AGENT badge + closed top rule", () => { + const out = stripAnsi(agentCardTop(false, 40)); + + expect(out.startsWith(" AGENT ")).toBe(true); + expect(out).toContain("┐"); + expect(out).not.toContain("│"); + expect(displayWidth(out)).toBe(40); + }); + + test("filled badge uses chrome background when color is on", () => { + const out = agentCardTop(true, 40); + + expect(out).toContain("[48;2;244;244;245m"); + expect(stripAnsi(out)).toContain(" AGENT "); }); }); describe("agent card replay wrapping (--continue path)", () => { - test("a long replayed line wraps INSIDE the rail — every row keeps │ and fits", () => { + test("a long replayed line wraps INSIDE the rails — every row keeps │ and fits", () => { const columns = 40; const long = "The quick brown fox jumps over the lazy dog again and again and " + @@ -74,13 +213,19 @@ describe("agent card replay wrapping (--continue path)", () => { ) ); const rows = out.split("\n").filter((r) => r.length > 0); - // Drop the top/bottom caps; every BODY row must carry the rail and fit. - const body = rows.filter((r) => r.startsWith("│")); + const body = rows.filter( + (r) => + r.startsWith("│") && + r.endsWith("│") && + !r.includes(" AGENT ") && + !/^│\s+│$/.test(r) + ); - expect(body.length).toBeGreaterThan(1); // it actually wrapped + expect(body.length).toBeGreaterThan(1); for (const row of body) { expect(displayWidth(row)).toBeLessThanOrEqual(columns); + expect(row.endsWith("│")).toBe(true); } }); diff --git a/packages/core/tests/model-call.test.ts b/packages/core/tests/model-call.test.ts index 549733a3..2aa55877 100644 --- a/packages/core/tests/model-call.test.ts +++ b/packages/core/tests/model-call.test.ts @@ -98,6 +98,37 @@ describe("offeredToolsFor (plan mode's read-only guarantee)", () => { expect(names).toContain("mcp__docs__search"); expect(names).not.toContain(TOOL_NAME.create); }); + + test("task_* tools withheld until offerTaskTools (activePlanId bound)", () => { + const withTasks = [ + ...tools, + tool(TOOL_NAME.taskList), + tool(TOOL_NAME.taskComplete), + ]; + + expect( + offeredToolsFor(withTasks, false, []).map((t) => t.function.name) + ).not.toContain(TOOL_NAME.taskList); + + const offered = offeredToolsFor(withTasks, false, [], [], true).map( + (t) => t.function.name + ); + + expect(offered).toContain(TOOL_NAME.taskList); + expect(offered).toContain(TOOL_NAME.taskComplete); + }); + + test("present_plan offered in plan mode only", () => { + const withPresent = [...tools, tool(TOOL_NAME.presentPlan)]; + + expect( + offeredToolsFor(withPresent, false, []).map((t) => t.function.name) + ).not.toContain(TOOL_NAME.presentPlan); + + expect( + offeredToolsFor(withPresent, true, []).map((t) => t.function.name) + ).toContain(TOOL_NAME.presentPlan); + }); }); describe("offeredToolsFor: overlay tool wiring", () => { diff --git a/packages/core/tests/opaque-bg.test.ts b/packages/core/tests/opaque-bg.test.ts new file mode 100644 index 00000000..e5d627af --- /dev/null +++ b/packages/core/tests/opaque-bg.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { withOpaqueBg } from "../src/render/frame/opaque-bg"; +import { RESET, paint, truecolorBg } from "../src/render/style"; +import { fitAnsiLine } from "../src/render/frame/fit-line"; + +const BG = truecolorBg(20, 20, 20); + +describe("withOpaqueBg", () => { + test("prefixes bg and leaves it active for BCE erase", () => { + const out = withOpaqueBg("hello", BG); + + expect(out.startsWith(BG)).toBe(true); + expect(out.endsWith(BG) || out.endsWith("hello")).toBe(true); + expect(out).toBe(`${BG}hello`); + }); + + test("re-applies bg after every SGR reset (paint + pad spaces)", () => { + const styled = paint("ok", "\x1b[32m", true); + const fitted = fitAnsiLine(styled, 8); + const out = withOpaqueBg(fitted, BG); + + // fitAnsiLine: styled + RESET + pad spaces → opaque must paint pad too. + expect(out.startsWith(BG)).toBe(true); + expect(out).toContain(`${RESET}${BG}`); + expect(out.endsWith(`${BG}${" ".repeat(6)}`)).toBe(true); + }); + + test("empty bg is a no-op", () => { + expect(withOpaqueBg("x", "")).toBe("x"); + }); +}); diff --git a/packages/core/tests/outer-frame.test.ts b/packages/core/tests/outer-frame.test.ts new file mode 100644 index 00000000..e764af18 --- /dev/null +++ b/packages/core/tests/outer-frame.test.ts @@ -0,0 +1,137 @@ +import { test, expect, describe } from "bun:test"; +import { + OUTER_CHROME, + OUTER_MARGIN, + outerInsets, + wrapOuterFrame, + frameContentRow, +} from "../src/render/frame/outer-frame"; +import { stripSgr } from "../src/render/frame/ansi-plain"; + +describe("outerInsets", () => { + test("reserves margin+border on every side", () => { + const insets = outerInsets(24, 100); + + expect(insets.originRow).toBe(OUTER_CHROME); + expect(insets.originCol).toBe(OUTER_CHROME); + expect(insets.contentRows).toBe(24 - 2 * OUTER_CHROME); + expect(insets.contentCols).toBe(100 - 2 * OUTER_CHROME); + }); +}); + +describe("wrapOuterFrame", () => { + test("wraps content in a closed chrome box with outer margin", () => { + const termRows = 10; + const termCols = 20; + const { contentRows, contentCols } = outerInsets(termRows, termCols); + const content = Array.from({ length: contentRows }, (_, i) => + `r${String(i)}`.padEnd(contentCols, ".") + ); + const framed = wrapOuterFrame(content, termRows, termCols, false); + + expect(framed).toHaveLength(termRows); + expect(framed.every((line) => stripSgr(line).length === termCols)).toBe( + true + ); + + const top = stripSgr(framed[OUTER_MARGIN] ?? ""); + const bottom = stripSgr(framed[termRows - OUTER_MARGIN - 1] ?? ""); + const mid = stripSgr(framed[OUTER_CHROME] ?? ""); + + expect(stripSgr(framed[0] ?? "").trim()).toBe(""); + expect(stripSgr(framed[termRows - 1] ?? "").trim()).toBe(""); + expect(top.startsWith(" ".repeat(OUTER_MARGIN) + "╭")).toBe(true); + expect(top.endsWith("╮" + " ".repeat(OUTER_MARGIN))).toBe(true); + expect(bottom.startsWith(" ".repeat(OUTER_MARGIN) + "╰")).toBe(true); + expect(bottom.endsWith("╯" + " ".repeat(OUTER_MARGIN))).toBe(true); + expect(bottom).not.toContain("┴"); + expect(mid[OUTER_MARGIN]).toBe("│"); + expect(mid[termCols - OUTER_MARGIN - 1]).toBe("│"); + expect(mid).toContain("r0"); + }); + + test("bottom edge stamps ┴ under the panel gutter so the spine closes", () => { + const termRows = 10; + const termCols = 20; + const { contentRows, contentCols, originCol } = outerInsets( + termRows, + termCols + ); + const splitCol = 8; + const content = Array.from({ length: contentRows }, () => + "".padEnd(contentCols, ".") + ); + const framed = wrapOuterFrame(content, termRows, termCols, { + color: false, + splitCol, + }); + const bottom = stripSgr(framed[termRows - OUTER_MARGIN - 1] ?? ""); + + expect(bottom[originCol + splitCol]).toBe("┴"); + expect(bottom.startsWith(" ".repeat(OUTER_MARGIN) + "╰")).toBe(true); + expect(bottom.endsWith("╯" + " ".repeat(OUTER_MARGIN))).toBe(true); + }); + + test("frameContentRow keeps content inside the side rails", () => { + const plain = stripSgr(frameContentRow("hi", 16, false)); + + expect(plain).toHaveLength(16); + expect(plain[OUTER_MARGIN]).toBe("│"); + expect(plain[15 - OUTER_MARGIN]).toBe("│"); + expect(plain).toContain("hi"); + }); + + test("full-bleed hairline rows use ├/┤ so the rule joins the outer rails", () => { + const termRows = 8; + const termCols = 20; + const { contentRows, contentCols } = outerInsets(termRows, termCols); + const split = 6; + const rule = + "─".repeat(split) + + "┬" + + "─".repeat(Math.max(0, contentCols - split - 1)); + const content = Array.from({ length: contentRows }, (_, i) => + i === 1 ? rule : "".padEnd(contentCols, " ") + ); + const framed = wrapOuterFrame(content, termRows, termCols, { + color: false, + splitCol: split, + }); + const plain = stripSgr(framed[OUTER_CHROME + 1] ?? ""); + + expect(plain[OUTER_MARGIN]).toBe("├"); + expect(plain[termCols - OUTER_MARGIN - 1]).toBe("┤"); + expect(plain).toContain("┬"); + // Interior rows keep plain │ rails. + expect(stripSgr(framed[OUTER_CHROME] ?? "")[OUTER_MARGIN]).toBe("│"); + }); + + test("panel-only under-rule joins with ├ gutter and ┤ outer rail", () => { + const termRows = 8; + const termCols = 20; + const { contentRows, contentCols, originCol } = outerInsets( + termRows, + termCols + ); + const split = 6; + const panelRule = + " ".repeat(split) + + "├" + + "─".repeat(Math.max(0, contentCols - split - 1)); + const content = Array.from({ length: contentRows }, (_, i) => + i === 1 ? panelRule : "".padEnd(contentCols, " ") + ); + const framed = wrapOuterFrame(content, termRows, termCols, { + color: false, + splitCol: split, + }); + const plain = stripSgr(framed[OUTER_CHROME + 1] ?? ""); + + expect(plain[OUTER_MARGIN]).toBe("│"); + expect(plain[originCol + split]).toBe("├"); + expect(plain[termCols - OUTER_MARGIN - 1]).toBe("┤"); + expect( + plain.slice(originCol + split + 1, termCols - OUTER_MARGIN - 1) + ).toMatch(/^─+$/); + }); +}); diff --git a/packages/core/tests/overlay-e2e.test.ts b/packages/core/tests/overlay-e2e.test.ts index 26ffd174..892c178f 100644 --- a/packages/core/tests/overlay-e2e.test.ts +++ b/packages/core/tests/overlay-e2e.test.ts @@ -101,8 +101,8 @@ describe("wizard e2e — rendered screen at each step", () => { expect(screen.text()).toContain("Step 1 of 2"); expect(screen.text()).toContain("Naming"); - // The cursor gutter "›" sits on the first option row (bare PascalCase). - const cursorRow = findRow(screen, "›"); + // The cursor gutter "▸" sits on the first option row (bare PascalCase). + const cursorRow = findRow(screen, "▸"); expect(screen.row(cursorRow)).toContain("bare PascalCase"); }); @@ -113,7 +113,7 @@ describe("wizard e2e — rendered screen at each step", () => { state = reduceWizard(state, "down", STEPS); const screen = screenOf(state); - const cursorRow = findRow(screen, "›"); + const cursorRow = findRow(screen, "▸"); expect(screen.row(cursorRow)).toContain("I-prefix"); }); @@ -227,8 +227,8 @@ describe("@-file picker e2e — rendered dropdown", () => { expect(items.every((p) => p.includes("app"))).toBe(true); expect(screen.rowsContaining("router.ts")).toBe(0); // filtered out - // The first (selected) row carries the active gutter "›". - expect(screen.row(1)).toContain("›"); + // The first (selected) row carries the active gutter "▸". + expect(screen.row(1)).toContain("▸"); }); test("an empty match renders the 'no matching file' hint", () => { diff --git a/packages/core/tests/pane-keys.test.ts b/packages/core/tests/pane-keys.test.ts new file mode 100644 index 00000000..0c1af2ee --- /dev/null +++ b/packages/core/tests/pane-keys.test.ts @@ -0,0 +1,82 @@ +import { test, expect, describe } from "bun:test"; +import { + handleFocusKey, + handleScrollKey, + handleMouseKey, +} from "../src/render/frame/pane-keys"; +import { PaneFocus } from "../src/render/frame/focus"; +import { Scrollback } from "../src/render/frame/scrollback"; + +function deps(overrides: { panelLen?: number; paints?: number[] } = {}) { + const focus = new PaneFocus(); + const scrollback = new Scrollback(100, 5); + const paints = overrides.paints ?? []; + + focus.syncHasItems((overrides.panelLen ?? 3) > 0); + + return { + focus, + scrollback, + panelLen: overrides.panelLen ?? 3, + paint: () => { + paints.push(1); + }, + invalidate: () => undefined, + paints, + }; +} + +describe("handleFocusKey", () => { + test("Ctrl+G toggles panel focus", () => { + const d = deps(); + + expect(handleFocusKey("\x07", d)).toBe("handled"); + expect(d.focus.panelFocused).toBe(true); + expect(d.paints.length).toBe(1); + }); + + test("Esc from panel returns handled", () => { + const d = deps(); + + handleFocusKey("\x07", d); + expect(handleFocusKey("\x1b", d)).toBe("handled"); + expect(d.focus.promptFocused).toBe(true); + }); +}); + +describe("handleScrollKey", () => { + test("up arrow does not steal from the prompt editor", () => { + const d = deps(); + + d.scrollback.append("a\nb\nc\nd\ne\n"); + // Prompt-focused: arrows stay with the editor (null → passthrough). + expect(handleScrollKey("\x1b[A", d)).toBeNull(); + expect(d.paints.length).toBe(0); + }); + + test("up arrow scrolls when scrollback is focused", () => { + const d = deps(); + + d.scrollback.append("a\nb\nc\nd\ne\n"); + d.focus.focusScrollback(); + expect(handleScrollKey("\x1b[A", d)).toBe("handled"); + expect(d.paints.length).toBe(1); + }); +}); + +describe("handleMouseKey", () => { + test("wheel invokes onWheel with delta and column", () => { + const wheels: { delta: number; col: number }[] = []; + const d = { + ...deps(), + onWheel: (delta: number, col: number) => { + wheels.push({ delta, col }); + }, + }; + + expect(handleMouseKey("\x1b[<64;12;4M", d)).toBe("handled"); + expect(wheels).toEqual([{ delta: 3, col: 12 }]); + expect(handleMouseKey("\x1b[<65;80;4M", d)).toBe("handled"); + expect(wheels[1]).toEqual({ delta: -3, col: 80 }); + }); +}); diff --git a/packages/core/tests/plan-mode.test.ts b/packages/core/tests/plan-mode.test.ts index 1d3de373..94f79813 100644 --- a/packages/core/tests/plan-mode.test.ts +++ b/packages/core/tests/plan-mode.test.ts @@ -167,8 +167,8 @@ test("the plan-mode note asks for prioritized clarifying questions and states th expect(note).toContain("At most 3-4"); // Blunt greenfield guidance — verbatim principle the user asked for. expect(note).toContain("the more detail and research"); - // Still ends in the `## Plan` + approval contract. - expect(note).toContain("## Plan"); + // Still ends in the present_plan + approval contract. + expect(note).toContain("present_plan"); }); }); diff --git a/packages/core/tests/present-plan-tool.test.ts b/packages/core/tests/present-plan-tool.test.ts new file mode 100644 index 00000000..cea88d99 --- /dev/null +++ b/packages/core/tests/present-plan-tool.test.ts @@ -0,0 +1,69 @@ +import { test, expect, describe } from "bun:test"; +import { + doPresentPlan, + presentPlanArgsToRaw, +} from "../src/loop/tools/present-plan-tool"; +import type { IToolContext } from "../src/loop/tools/tool-context"; +import type { IPlanDocument } from "../src/loop/worklist/checklist.types"; + +function ctx(onPlanPresented?: (plan: IPlanDocument) => void): IToolContext { + return { + cwd: "/tmp", + files: ["**/*"], + task: "session", + report: () => undefined, + ...(onPlanPresented === undefined ? {} : { onPlanPresented }), + }; +} + +describe("presentPlanArgsToRaw", () => { + test("accepts top-level goal + items", () => { + expect( + presentPlanArgsToRaw({ + goal: "g", + items: [{ title: "A" }], + }) + ).toEqual({ goal: "g", items: [{ title: "A" }] }); + }); + + test("accepts nested plan object", () => { + expect( + presentPlanArgsToRaw({ + plan: { goal: "g", items: [{ title: "A" }] }, + }) + ).toEqual({ goal: "g", items: [{ title: "A" }] }); + }); +}); + +describe("doPresentPlan", () => { + test("validates, notifies, does not invent done", () => { + const presented: IPlanDocument[] = []; + const result = doPresentPlan( + { + goal: "ship rail", + items: [ + { + title: "Parent", + children: [{ title: "Child", verify: "bun test" }], + }, + ], + }, + ctx((p) => { + presented.push(p); + }) + ); + + expect(result).toMatch(/presented/i); + expect(result).toMatch(/Do NOT paste/i); + expect(presented).toHaveLength(1); + expect(presented[0]?.goal).toBe("ship rail"); + expect(presented[0]?.items[0]?.children?.[0]?.verify).toBe("bun test"); + expect(presented[0]?.items[0]?.status).toBe("pending"); + }); + + test("rejects empty items", () => { + const result = doPresentPlan({ goal: "x", items: [] }, ctx()); + + expect(result).toMatch(/invalid|empty|items/i); + }); +}); diff --git a/packages/core/tests/scrollbar.test.ts b/packages/core/tests/scrollbar.test.ts new file mode 100644 index 00000000..aa0556ff --- /dev/null +++ b/packages/core/tests/scrollbar.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { + needsScrollbar, + thumbWindow, + formatScrollbarColumn, + overlayScrollbarCol, + type IScrollMetrics, +} from "../src/render/frame/scrollbar"; +import { displayWidth } from "../src/render/width"; +import { stripSgr } from "../src/render/frame/ansi-plain"; + +function metrics( + partial: Partial & + Pick +): IScrollMetrics { + return { + following: partial.following ?? false, + ...partial, + }; +} + +describe("needsScrollbar", () => { + test("hidden when content fits", () => { + expect( + needsScrollbar(metrics({ total: 10, viewport: 10, offset: 0 })) + ).toBe(false); + expect(needsScrollbar(metrics({ total: 5, viewport: 10, offset: 0 }))).toBe( + false + ); + }); + + test("shown when content overflows", () => { + expect( + needsScrollbar(metrics({ total: 11, viewport: 10, offset: 0 })) + ).toBe(true); + }); +}); + +describe("thumbWindow", () => { + test("null when no overflow", () => { + expect( + thumbWindow(metrics({ total: 5, viewport: 10, offset: 0 }), 10) + ).toBeNull(); + }); + + test("sits at the bottom while following", () => { + const win = thumbWindow( + metrics({ total: 100, viewport: 20, offset: 0, following: true }), + 20 + ); + + expect(win).not.toBeNull(); + expect(win?.end).toBe(20); + expect(win!.end - win!.start).toBeGreaterThanOrEqual(1); + }); + + test("moves toward the top as offset shrinks", () => { + const track = 20; + const atBottom = thumbWindow( + metrics({ total: 100, viewport: 20, offset: 80, following: false }), + track + ); + const atTop = thumbWindow( + metrics({ total: 100, viewport: 20, offset: 0, following: false }), + track + ); + + expect(atTop?.start).toBe(0); + expect(atBottom?.start).toBeGreaterThan(atTop!.start); + }); + + test("thumb length scales with viewport/total", () => { + const small = thumbWindow( + metrics({ total: 200, viewport: 20, offset: 0 }), + 40 + ); + const large = thumbWindow( + metrics({ total: 40, viewport: 20, offset: 0 }), + 40 + ); + + expect(large!.end - large!.start).toBeGreaterThan( + small!.end - small!.start + ); + }); +}); + +describe("formatScrollbarColumn", () => { + test("paints █ on the thumb and spaces on the track", () => { + const col = formatScrollbarColumn( + metrics({ total: 40, viewport: 10, offset: 0, following: false }), + 10, + false + ); + + expect(col).not.toBeNull(); + expect(col!.length).toBe(10); + expect(col![0]).toBe("█"); + expect(col!.includes(" ")).toBe(true); + expect(col!.filter((c) => c === "█").length).toBeGreaterThan(0); + }); + + test("returns null when content fits", () => { + expect( + formatScrollbarColumn( + metrics({ total: 5, viewport: 10, offset: 0 }), + 10, + false + ) + ).toBeNull(); + }); +}); + +describe("overlayScrollbarCol", () => { + test("keeps total width and parks the thumb in the last column", () => { + const line = "hello world"; + const out = overlayScrollbarCol(line, 20, "█"); + const plain = stripSgr(out); + + expect(displayWidth(plain)).toBe(20); + expect(plain.endsWith("█")).toBe(true); + expect(plain.startsWith("hello")).toBe(true); + }); +}); diff --git a/packages/core/tests/session-store.test.ts b/packages/core/tests/session-store.test.ts index 537c407c..1e2fe37f 100644 --- a/packages/core/tests/session-store.test.ts +++ b/packages/core/tests/session-store.test.ts @@ -80,6 +80,22 @@ test("resume preserves planMode (read-only guarantee survives --continue)", asyn expect(resumed?.planMode).toBe(true); }); +test("resume preserves activePlanId (session-bound plan survives --continue)", async () => { + await saveSession({ + id: "s-plan", + cwd: "/proj/plan", + accept: "", + files: [], + updatedAt: Date.now(), + activePlanId: "plan-abc", + messages: [{ role: "user", content: "hi" }], + }); + + const latest = await latestSession("/proj/plan"); + + expect(latest?.activePlanId).toBe("plan-abc"); +}); + test("resume preserves pausedWithEdit (deferred gate survives --continue)", async () => { // WS-C: a still-unvalidated pre-pause edit must survive the process boundary, or // --continue silently drops the deferred gate (the same hole /clear closed in-process). diff --git a/packages/core/tests/status-bar.test.ts b/packages/core/tests/status-bar.test.ts index b7ae4c5a..d5aae29b 100644 --- a/packages/core/tests/status-bar.test.ts +++ b/packages/core/tests/status-bar.test.ts @@ -171,6 +171,32 @@ describe("StatusBar with input row", () => { expect(screen.rowsContaining("qwen3.6-27b")).toBe(1); }); + test("setWorklist renders checklist lines and still keeps exactly one status bar", () => { + const term = new FakeTerm(true, 24, 80); + const bar = withInput(term); + + bar.install(INFO); + bar.setWorklist(["worklist 1/3 done", "→ [ ] Second item"]); + + const screen = render(term); + + expect(screen.text()).toContain("worklist 1/3 done"); + expect(screen.text()).toContain("→ [ ] Second item"); + expect(screen.rowsContaining("qwen3.6-27b")).toBe(1); + + bar.setWorklist(["worklist 2/3 done", "→ [x] Second item"]); + const after = render(term); + + expect(after.text()).toContain("worklist 2/3 done"); + expect(after.rowsContaining("qwen3.6-27b")).toBe(1); + + bar.clearWorklist(); + const cleared = render(term); + + expect(cleared.text()).not.toContain("worklist 2/3 done"); + expect(cleared.rowsContaining("qwen3.6-27b")).toBe(1); + }); + test("teardown erases the live region and shows the cursor", () => { const term = new FakeTerm(true, 24, 80); const bar = withInput(term); diff --git a/packages/core/tests/tool-accounting.test.ts b/packages/core/tests/tool-accounting.test.ts index 39dc1599..00cff62f 100644 --- a/packages/core/tests/tool-accounting.test.ts +++ b/packages/core/tests/tool-accounting.test.ts @@ -454,6 +454,11 @@ const SPECIAL_TOOLS = new Set([ TOOL_NAME.script, TOOL_NAME.generateImage, TOOL_NAME.check, + // Checklist mutations touch plan JSON under .tsforge/, not gated source — + // no scoped edit count / re-gate. task_list + present_plan are read-only. + TOOL_NAME.taskFocus, + TOOL_NAME.taskComplete, + TOOL_NAME.taskUncomplete, ]); test("every registered tool is classified read-only, mutating, or special", () => { diff --git a/packages/core/tests/tools-gating.test.ts b/packages/core/tests/tools-gating.test.ts index eb1e5b79..64c799e3 100644 --- a/packages/core/tests/tools-gating.test.ts +++ b/packages/core/tests/tools-gating.test.ts @@ -130,6 +130,22 @@ test("ask_user is offered only via offerAskUser (the co-pilot opt-in), off by de expect(names(toolsFor(false, {}, false, false, true))).toContain("ask_user"); expect(names(toolsFor(true, {}, false, false, true))).toContain("ask_user"); + // present_plan rides the same interactive opt-in (plan-mode filter is in offeredToolsFor). + expect(names(toolsFor(false, {}, false, false, true))).toContain( + "present_plan" + ); +}); + +test("task_* tools are offered only via offerTaskTools (activePlanId bound)", () => { + expect(names(toolsFor(false))).not.toContain("task_list"); + expect(names(toolsFor(true))).not.toContain("task_complete"); + + const offered = names(toolsFor(false, {}, false, false, false, [], true)); + + expect(offered).toContain("task_list"); + expect(offered).toContain("task_focus"); + expect(offered).toContain("task_complete"); + expect(offered).toContain("task_uncomplete"); }); test("the script tool is on by default for scratch and existing code", () => { diff --git a/packages/core/tests/width.test.ts b/packages/core/tests/width.test.ts index b3d850c4..17c9c74c 100644 --- a/packages/core/tests/width.test.ts +++ b/packages/core/tests/width.test.ts @@ -42,13 +42,18 @@ describe("displayWidth", () => { expect(displayWidth("é")).toBe(1); // é as two code points }); - test("emoji and ZWJ sequences are two columns", () => { + test("wide emoji and ZWJ sequences are two columns", () => { expect(displayWidth("😀")).toBe(2); + expect(displayWidth("😊")).toBe(2); expect(displayWidth("👨‍👩‍👧")).toBe(2); // single grapheme via ZWJ }); - test("VS16 forces a wide cell for an otherwise-narrow base", () => { - expect(displayWidth("❤️")).toBe(2); // U+2764 U+FE0F + test("lone VS16 is zero-width; Neutral emoji stay one column (iTerm advance)", () => { + expect(displayWidth("\uFE0F")).toBe(0); + // U+1F5A5 / U+1F6CB are East-Asian Neutral — terminals advance 1 even with VS16. + expect(displayWidth("🖥️")).toBe(1); + expect(displayWidth("🛋️")).toBe(1); + expect(displayWidth("❤️")).toBe(1); // U+2764 U+FE0F }); test("flags (regional indicator pairs) are two columns", () => { diff --git a/packages/core/tests/wizard.test.ts b/packages/core/tests/wizard.test.ts index dd9938aa..c7ada280 100644 --- a/packages/core/tests/wizard.test.ts +++ b/packages/core/tests/wizard.test.ts @@ -149,7 +149,7 @@ describe("wizard render (no color)", () => { expect(frame).toContain("Step 1 of 2 · Naming"); expect(frame).toContain("312 interfaces scanned"); - expect(frame).toContain("› bare PascalCase"); + expect(frame).toContain("▸ bare PascalCase"); expect(frame).toContain("recommended"); expect(frame).toContain("enter select"); }); @@ -248,6 +248,59 @@ describe("runWizard interactive teardown", () => { }); } }); + + test("view mode paints host overlay and never opens a nested alt-screen", async () => { + const fake = new FakeStdin(); + const realStdin = process.stdin; + const renders: string[][] = []; + let closed = 0; + const writes: string[] = []; + + Object.defineProperty(process, "stdin", { + value: fake, + configurable: true, + }); + + try { + const done = runWizard(STEPS, false, { + manageInput: false, + out: (s) => { + writes.push(s); + }, + view: { + render: (lines) => { + renders.push([...lines]); + }, + close: () => { + closed += 1; + }, + }, + }); + + expect(renders.length).toBe(1); + expect(renders[0]?.some((l) => l.includes("▸ bare PascalCase"))).toBe( + true + ); + // Nested alt-screen would fight PaneScreen — view mode must stay on host chrome. + expect(writes.some((w) => w.includes("?1049h"))).toBe(false); + + fake.emit("keypress", undefined, { name: "down" }); + expect(renders.length).toBe(2); + expect(renders[1]?.some((l) => l.includes("▸ I-prefix"))).toBe(true); + + fake.emit("keypress", undefined, { name: "escape" }); + const state = await done; + + expect(state.status).toBe("cancel"); + expect(closed).toBe(1); + expect(writes.some((w) => w.includes("?1049l"))).toBe(false); + } finally { + Object.defineProperty(process, "stdin", { + value: realStdin, + configurable: true, + }); + } + }); }); // ── generic-wizard additions: text steps, optional review, title ───────────── diff --git a/packages/core/tests/worklist-panel.test.ts b/packages/core/tests/worklist-panel.test.ts new file mode 100644 index 00000000..24c50379 --- /dev/null +++ b/packages/core/tests/worklist-panel.test.ts @@ -0,0 +1,173 @@ +import { test, expect, describe } from "bun:test"; +import { + formatWorklistLines, + formatPlanProposal, + worklistBadge, + pendingPlanBadge, +} from "../src/loop/worklist/panel"; +import type { IPlanDocument } from "../src/loop/worklist/checklist.types"; +import { stripSgr } from "../src/render/frame"; +import { CONSOLE } from "../src/render/frame/chrome"; + +function plan( + items: IPlanDocument["items"], + goal = "g", + activeItemId: string | null = null +): IPlanDocument { + return { + schemaVersion: 2, + id: "plan-1", + goal, + activeItemId, + updatedAt: "2026-01-01T00:00:00.000Z", + items, + }; +} + +function plain(lines: readonly string[]): string[] { + return lines.map(stripSgr); +} + +describe("formatWorklistLines", () => { + test("shows goal cue, nested tree, active ▸", () => { + const lines = formatWorklistLines( + plan( + [ + { id: "a", title: "First", status: "done" }, + { + id: "b", + title: "Second", + status: "active", + children: [{ id: "b1", title: "Child", status: "pending" }], + }, + { id: "c", title: "Third", status: "pending" }, + ], + "PLAN.md", + "b" + ), + { maxPending: 4, columns: 36, color: false } + ); + const text = plain(lines); + + expect(text[0]).toBe("PLAN.md"); + expect(text).toContain("✓ First"); + expect(text).toContain("▸ Second"); + expect(text.some((l) => l.includes("Child"))).toBe(true); + expect(text).toContain("○ Third"); + }); + + test("shows verify on focused row", () => { + const lines = formatWorklistLines( + plan( + [ + { + id: "a", + title: "Wire rail", + status: "active", + verify: "bun test panel", + }, + ], + "goal", + "a" + ), + { columns: 40, color: false } + ); + const text = plain(lines); + + expect(text.some((l) => l.includes("verify: bun test panel"))).toBe(true); + }); + + test("empty state points at plan approve", () => { + expect(plain(formatWorklistLines(null, { color: false }))).toEqual([ + "approve a plan", + "to fill this list", + ]); + }); + + test("all done copy", () => { + expect( + plain( + formatWorklistLines(plan([{ id: "a", title: "A", status: "done" }]), { + color: false, + }) + ) + ).toContain("All done."); + }); + + test("color mode paints current with CONSOLE.bright", () => { + const lines = formatWorklistLines( + plan([{ id: "a", title: "Now", status: "active" }], "g", "a"), + { columns: 36, color: true } + ); + + expect(lines.some((l) => l.includes(CONSOLE.bright))).toBe(true); + }); + + test("worklistBadge is done/total", () => { + expect( + worklistBadge( + plan([ + { id: "a", title: "A", status: "done" }, + { id: "b", title: "B", status: "pending" }, + ]) + ) + ).toBe("1/2"); + expect(worklistBadge(null)).toBe(""); + }); + + test("formatPlanProposal is a PLAN card with items, not raw JSON", () => { + const card = formatPlanProposal( + plan( + [ + { + id: "a", + title: "Wire present_plan", + status: "pending", + children: [{ id: "a1", title: "Nested", status: "pending" }], + }, + ], + "Ship plan UI" + ), + 60, + false + ); + + expect(card).toContain("PLAN"); + expect(card).toContain("Ship plan UI"); + expect(card).toContain("Wire present_plan"); + expect(card).toContain("Nested"); + expect(card).toContain("approve"); + expect(card).not.toContain('"items"'); + }); + + test("formatPlanProposal soft-wraps long detail — no mid-word clip", () => { + const detail = + "Bun CLI with subcommands: add , list, search . Persists notes to .notes.json (id, text, createdAt ISO). Search matches case-insensitive."; + const card = formatPlanProposal( + plan( + [ + { + id: "a", + title: "Create src/notes.ts CLI", + status: "pending", + detail, + }, + ], + "Build a tiny static notes CLI" + ), + 48, + false + ); + const plain = stripSgr(card); + + expect(plain).toContain("Search"); + expect(plain).not.toMatch(/Searc[^h]/u); + expect(plain.split("\n").length).toBeGreaterThan(6); + }); + + test("pendingPlanBadge marks open count", () => { + expect( + pendingPlanBadge(plan([{ id: "a", title: "A", status: "pending" }])) + ).toBe("·1"); + }); +}); diff --git a/packages/core/tests/worklist-parse.test.ts b/packages/core/tests/worklist-parse.test.ts new file mode 100644 index 00000000..033b2fa6 --- /dev/null +++ b/packages/core/tests/worklist-parse.test.ts @@ -0,0 +1,175 @@ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + parseWorklist, + resolveWorklistPath, + slugifyItem, + itemsToFeatures, +} from "../src/loop/worklist/parse"; + +describe("slugifyItem", () => { + test("produces kebab-case ids that pass isFeatureId", () => { + expect(slugifyItem("Stop rebuilding the whole HUD DOM")).toBe( + "stop-rebuilding-the-whole-hud-dom" + ); + expect(slugifyItem("`hud.ts` clears + re-inserts")).toBe( + "hud-ts-clears-re-inserts" + ); + }); + + test("falls back when text has no alphanumerics", () => { + expect(slugifyItem("!!!")).toBe("item"); + }); +}); + +describe("parseWorklist", () => { + test("parses markdown checkbox lists and skips checked items by default", () => { + const md = ` +## B. Polish + +- [x] Already done item +- [ ] First open item +- [ ] Second open item +`; + const items = parseWorklist(md); + + expect(items.map((i) => i.text)).toEqual([ + "First open item", + "Second open item", + ]); + expect(items.every((i) => !i.done)).toBe(true); + expect(items[0]?.id).toBe("first-open-item"); + }); + + test("includeDone keeps checked boxes", () => { + const items = parseWorklist("- [x] Done\n- [ ] Open\n", { + includeDone: true, + }); + + expect(items).toHaveLength(2); + expect(items[0]?.done).toBe(true); + expect(items[1]?.done).toBe(false); + }); + + test("parses numbered lists with indented accept/files/context/fix", () => { + const md = ` +1. Build the parser + accept: bun test packages/core/tests/worklist-parse.test.ts + files: src/loop/worklist/parse.ts + context: src/spec/parse.ts + fix: reuse line-scanning shape +2. Drive the list + accept: bun test +`; + const items = parseWorklist(md); + + expect(items).toHaveLength(2); + expect(items[0]?.text).toBe("Build the parser"); + expect(items[0]?.accept).toBe( + "bun test packages/core/tests/worklist-parse.test.ts" + ); + expect(items[0]?.files).toEqual(["src/loop/worklist/parse.ts"]); + expect(items[0]?.context).toEqual(["src/spec/parse.ts"]); + expect(items[0]?.fix).toBe("reuse line-scanning shape"); + expect(items[1]?.accept).toBe("bun test"); + }); + + test("checkbox items can carry indented accept/files", () => { + const md = ` +- [ ] Wire the HUD + accept: bun test + files: src/hud.ts, src/hud.test.ts +`; + const items = parseWorklist(md); + + expect(items).toHaveLength(1); + expect(items[0]?.accept).toBe("bun test"); + expect(items[0]?.files).toEqual(["src/hud.ts", "src/hud.test.ts"]); + }); + + test("disambiguates colliding slugs with numeric suffixes", () => { + const items = parseWorklist("- [ ] Same\n- [ ] Same\n- [ ] Same\n"); + + expect(items.map((i) => i.id)).toEqual(["same", "same-2", "same-3"]); + }); + + test("returns empty for malformed / non-list prose", () => { + expect(parseWorklist("Just a paragraph.\n\n## Heading\n")).toEqual([]); + }); + + test("handles the cs-top-down PLAN.md shape (sections + nested checkboxes)", () => { + const md = ` +# Plan + +### B2. Stop rebuilding the whole HUD DOM every frame + +- [ ] \`hud.ts\` clears + re-inserts all HTML each frame. +- [ ] Tests: unit (root element not recreated). + +### B3. Spatial partitioning (optional) + +- [ ] Uniform grid for obstacle lookups. +`; + const items = parseWorklist(md); + + expect(items).toHaveLength(3); + expect(items[0]?.text).toContain("hud.ts"); + expect(items[2]?.text).toContain("Uniform grid"); + }); + + test("parses plain bullets as open items", () => { + const items = parseWorklist("## Plan\n\n- First\n- Second\n"); + + expect(items.map((i) => i.text)).toEqual(["First", "Second"]); + }); +}); + +describe("resolveWorklistPath", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "tsforge-wl-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + test("explicit path wins when the file exists", async () => { + const path = join(dir, "MY.md"); + + await writeFile(path, "- [ ] a\n"); + + expect(await resolveWorklistPath(dir, "MY.md")).toBe(path); + }); + + test("looks up PLAN.md then TASKS.md — not .specs/next.md", async () => { + expect(await resolveWorklistPath(dir)).toBeNull(); + + await mkdir(join(dir, ".specs"), { recursive: true }); + await writeFile(join(dir, ".specs", "next.md"), "- [ ] from specs\n"); + expect(await resolveWorklistPath(dir)).toBeNull(); + + await writeFile(join(dir, "TASKS.md"), "- [ ] from tasks\n"); + expect(await resolveWorklistPath(dir)).toBe(join(dir, "TASKS.md")); + + await writeFile(join(dir, "PLAN.md"), "- [ ] from plan\n"); + expect(await resolveWorklistPath(dir)).toBe(join(dir, "PLAN.md")); + }); +}); + +describe("itemsToFeatures", () => { + test("maps open items to IFeature with passes false and attempts 0", () => { + const features = itemsToFeatures([ + { id: "a", text: "do a", done: false }, + { id: "b", text: "do b", done: false, accept: "bun test" }, + ]); + + expect(features).toEqual([ + { id: "a", desc: "do a", passes: false, attempts: 0 }, + { id: "b", desc: "do b", passes: false, attempts: 0 }, + ]); + }); +}); diff --git a/packages/core/tests/worklist-run.test.ts b/packages/core/tests/worklist-run.test.ts new file mode 100644 index 00000000..34c4ec8e --- /dev/null +++ b/packages/core/tests/worklist-run.test.ts @@ -0,0 +1,191 @@ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + runWorklist, + prepareWorklistState, + tickWorklistFile, + WORKLIST_STATE, +} from "../src/loop/worklist"; +import { + loadState, + hasState, + saveState, + greenfieldDir, +} from "../src/loop/greenfield"; +import type { IGreenfieldDeps, IFeature } from "../src/loop/greenfield"; +import type { IHandoff } from "../src/loop/loop.types"; + +function handoff(): IHandoff { + return { + block: "test", + rungHistory: ["R1", "R2", "R3", "R4"], + errors: ["still broken"], + ask: "help", + resumable: true, + resume: { triedLevers: ["R1", "R2", "R3", "R4"] }, + }; +} + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "tsforge-wl-run-")); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe("prepareWorklistState", () => { + test("parses PLAN.md into features when no prior state", async () => { + await writeFile( + join(dir, "PLAN.md"), + "- [ ] First item\n- [x] Already done\n- [ ] Third item\n" + ); + + const state = await prepareWorklistState(dir, { goal: "from plan" }); + + expect(state?.features.map((f) => f.desc)).toEqual([ + "First item", + "Third item", + ]); + expect(await hasState(dir, WORKLIST_STATE)).toBe(true); + expect(await hasState(dir)).toBe(false); + }); + + test("resumes from .tsforge/worklist/ instead of re-parsing", async () => { + await saveState( + dir, + { + goal: "prior", + features: [ + { id: "a", desc: "A", passes: true, attempts: 1 }, + { id: "b", desc: "B", passes: false, attempts: 0 }, + ], + }, + WORKLIST_STATE + ); + await writeFile(join(dir, "PLAN.md"), "- [ ] Should not replace\n"); + + const state = await prepareWorklistState(dir, { goal: "ignored" }); + + expect(state?.goal).toBe("prior"); + expect(state?.features.map((f) => f.id)).toEqual(["a", "b"]); + expect(state?.features[0]?.passes).toBe(true); + }); +}); + +describe("runWorklist", () => { + test("drives three items: parks failing middle item, still attempts the third, revisits once", async () => { + const order: string[] = []; + let bAttempts = 0; + + const deps: IGreenfieldDeps = { + implement: async (feature: IFeature) => { + order.push(feature.id); + + if (feature.id === "b") { + bAttempts += 1; + + return { done: false, handoff: handoff(), reason: "cannot pass" }; + } + + return { done: true }; + }, + }; + + const state = { + goal: "list", + features: [ + { id: "a", desc: "A", passes: false, attempts: 0 }, + { id: "b", desc: "B", passes: false, attempts: 0 }, + { id: "c", desc: "C", passes: false, attempts: 0 }, + ], + }; + + const result = await runWorklist(dir, state, deps); + + expect(result.status).toBe("stuck"); + expect(result.stuckFeature).toBe("b"); + // main: a, b(park), c — revisit: b again + expect(order).toEqual(["a", "b", "c", "b"]); + expect(bAttempts).toBe(2); + expect(result.features.find((f) => f.id === "a")?.passes).toBe(true); + expect(result.features.find((f) => f.id === "c")?.passes).toBe(true); + expect(result.features.find((f) => f.id === "b")?.parked).toBe(true); + + const progress = await readFile( + join(greenfieldDir(dir, WORKLIST_STATE), "progress.md"), + "utf8" + ); + + expect(progress).toContain("- [x] a"); + expect(progress).toContain("- [~] b"); + expect(progress).toContain("- [x] c"); + }); + + test("resume skips already-passing features", async () => { + const attempted: string[] = []; + const deps: IGreenfieldDeps = { + implement: async (feature) => { + attempted.push(feature.id); + + return { done: true }; + }, + }; + + await saveState( + dir, + { + goal: "list", + features: [ + { id: "a", desc: "A", passes: true, attempts: 1 }, + { id: "b", desc: "B", passes: false, attempts: 0 }, + ], + }, + WORKLIST_STATE + ); + + const state = await loadState(dir, WORKLIST_STATE); + + expect(state).not.toBeNull(); + + const result = await runWorklist(dir, state!, deps); + + expect(result.status).toBe("done"); + expect(attempted).toEqual(["b"]); + }); +}); + +describe("tickWorklistFile", () => { + test("marks matching open checkboxes as done when opt-in", async () => { + const path = join(dir, "PLAN.md"); + + await writeFile( + path, + "## Section\n\n- [ ] First open item\n- [ ] Second open item\n" + ); + + await tickWorklistFile(path, [ + { + id: "first-open-item", + desc: "First open item", + passes: true, + attempts: 1, + }, + { + id: "second-open-item", + desc: "Second open item", + passes: false, + attempts: 0, + }, + ]); + + const text = await readFile(path, "utf8"); + + expect(text).toContain("- [x] First open item"); + expect(text).toContain("- [ ] Second open item"); + }); +}); diff --git a/packages/core/tests/worklist-seed.test.ts b/packages/core/tests/worklist-seed.test.ts new file mode 100644 index 00000000..0971b9bc --- /dev/null +++ b/packages/core/tests/worklist-seed.test.ts @@ -0,0 +1,144 @@ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + extractPlanJson, + seedWorklistFromPlan, + goalFromMessages, +} from "../src/loop/worklist/seed"; +import { loadPlan, loadPlanIndex } from "../src/loop/worklist/checklist-store"; + +describe("extractPlanJson", () => { + test("takes the first parseable fenced json block", () => { + const md = `# Intro\n\n\`\`\`json\n{"goal":"g","items":[{"title":"One"}]}\n\`\`\`\n\n## Later\n`; + + expect(extractPlanJson(md)).toEqual({ + goal: "g", + items: [{ title: "One" }], + }); + }); +}); + +describe("seedWorklistFromPlan", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "tsforge-seed-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + test("writes plans/.json + index from fenced JSON", () => { + const result = seedWorklistFromPlan( + dir, + [ + "## Plan", + "", + "```json", + JSON.stringify({ + goal: "ship checklist", + items: [ + { + title: "Add parser", + detail: "nested ok", + children: [{ title: "Wire rail", verify: "bun test" }], + }, + ], + }), + "```", + ].join("\n"), + "fallback" + ); + + expect(result.ok).toBe(true); + + if (!result.ok) { + return; + } + + expect(result.plan.goal).toBe("ship checklist"); + expect(result.plan.schemaVersion).toBe(2); + expect(result.plan.items).toHaveLength(1); + expect(result.plan.items[0]?.title).toBe("Add parser"); + expect(result.plan.items[0]?.children?.[0]?.title).toBe("Wire rail"); + expect(result.plan.items[0]?.children?.[0]?.verify).toBe("bun test"); + expect(result.plan.items[0]?.id.length).toBeGreaterThan(0); + + const onDisk = loadPlan(dir, result.plan.id); + + expect(onDisk?.id).toBe(result.plan.id); + expect(loadPlanIndex(dir).plans.some((p) => p.id === result.plan.id)).toBe( + true + ); + }); + + test("two seeds create two plan files (no clobber)", () => { + const a = seedWorklistFromPlan( + dir, + '```json\n{"goal":"a","items":[{"title":"A1"}]}\n```', + "a" + ); + const b = seedWorklistFromPlan( + dir, + '```json\n{"goal":"b","items":[{"title":"B1"}]}\n```', + "b" + ); + + expect(a.ok && b.ok).toBe(true); + + if (!a.ok || !b.ok) { + return; + } + + expect(a.plan.id).not.toBe(b.plan.id); + expect(loadPlan(dir, a.plan.id)?.goal).toBe("a"); + expect(loadPlan(dir, b.plan.id)?.goal).toBe("b"); + expect(loadPlanIndex(dir).plans).toHaveLength(2); + }); + + test("refuses when JSON missing", () => { + const result = seedWorklistFromPlan( + dir, + "## Plan\n\nWe should refactor auth somehow.\n", + "vague" + ); + + expect(result.ok).toBe(false); + + if (result.ok) { + return; + } + + expect(result.error).toMatch(/fenced JSON/i); + }); + + test("refuses empty items", () => { + const result = seedWorklistFromPlan( + dir, + '```json\n{"goal":"x","items":[]}\n```', + "x" + ); + + expect(result.ok).toBe(false); + }); +}); + +describe("goalFromMessages", () => { + test("uses the first user ask, stripping PLAN_MODE_NOTE", () => { + expect( + goalFromMessages([ + { + role: "user", + content: "fix the Tasks rail\n\n[PLAN MODE — read-only. …]", + }, + { + role: "assistant", + content: '```json\n{"items":[{"title":"a"}]}\n```', + }, + ]) + ).toBe("fix the Tasks rail"); + }); +}); diff --git a/packages/core/tests/wrap-line.test.ts b/packages/core/tests/wrap-line.test.ts new file mode 100644 index 00000000..11a620ed --- /dev/null +++ b/packages/core/tests/wrap-line.test.ts @@ -0,0 +1,121 @@ +import { test, expect, describe } from "bun:test"; +import { wrapAnsiLine, wrapAnsiLines } from "../src/render/frame/wrap-line"; +import { stripSgr } from "../src/render/frame/ansi-plain"; + +describe("wrapAnsiLine", () => { + test("short line stays one row and keeps SGR", () => { + const red = "\x1b[31mhi\x1b[0m"; + + expect(wrapAnsiLine(red, 10)).toEqual([red]); + }); + + test("long plain line hard-splits when there are no spaces", () => { + expect(wrapAnsiLine("abcdefghij", 4)).toEqual(["abcd", "efgh", "ij"]); + }); + + test("overflow drops SGR and wraps plain text", () => { + const red = "\x1b[31mabcdefgh\x1b[0m"; + + expect(wrapAnsiLine(red, 3)).toEqual(["abc", "def", "gh"]); + }); + + test("prefers wrapping at spaces", () => { + expect(wrapAnsiLine("hello wonderful world", 12)).toEqual([ + "hello", + "wonderful", + "world", + ]); + }); + + test("re-emits │ rail on every continuation row", () => { + const rows = wrapAnsiLine("│ hello wonderful world", 14); + + expect(rows.length).toBeGreaterThan(1); + + for (const row of rows) { + expect(row.startsWith("│ ")).toBe(true); + } + + expect(rows.join("\n")).not.toContain("wond\n│ erful"); + }); + + test("closed agent rows keep both rails when reflowing", () => { + const row = `│ hello wonderful world${" ".repeat(3)}│`; + const rows = wrapAnsiLine(row, 16); + + expect(rows.length).toBeGreaterThan(1); + + for (const r of rows) { + const plain = stripSgr(r); + + expect(plain.startsWith("│")).toBe(true); + expect(plain.endsWith("│")).toBe(true); + } + }); + + test("reflowed boxed rails keep chrome SGR (right │ matches card color)", () => { + const chrome = "\x1b[38;2;82;82;91m"; + const reset = "\x1b[0m"; + const left = `${chrome}│${reset} `; + const right = `${chrome}│${reset}`; + const body = "hello wonderful world that wraps"; + const row = `${left}${body}${" ".repeat(2)}${right}`; + const rows = wrapAnsiLine(row, 20); + + expect(rows.length).toBeGreaterThan(1); + + for (const r of rows) { + expect(r.endsWith(`${chrome}│${reset}`)).toBe(true); + expect(r.startsWith(`${chrome}│${reset}`)).toBe(true); + } + }); + + test("reflowed cyan boxed rails keep cyan SGR", () => { + const cyan = "\x1b[38;2;34;211;238m"; + const reset = "\x1b[0m"; + const left = `${cyan}│${reset} `; + const right = `${cyan}│${reset}`; + const row = `${left}hello wonderful world${" ".repeat(2)}${right}`; + const rows = wrapAnsiLine(row, 18); + + expect(rows.length).toBeGreaterThan(1); + + for (const r of rows) { + expect(r).toContain(cyan); + expect(r.endsWith(`${cyan}│${reset}`)).toBe(true); + } + }); + + test("empty boxed rows that fit are resealed into one SGR span", () => { + const chrome = "\x1b[38;2;82;82;91m"; + const reset = "\x1b[0m"; + // Split-rail blank (the iTerm dark-right-rail shape). + const split = `${chrome}│${reset}` + " ".repeat(18) + `${chrome}│${reset}`; + const [row] = wrapAnsiLine(split, 20); + const first = row!.indexOf("│"); + const last = row!.lastIndexOf("│"); + + expect(stripSgr(row!).length).toBe(20); + expect(row!.slice(first + 1, last).includes(reset)).toBe(false); + expect(row!.startsWith(chrome)).toBe(true); + }); + + test("fitting content rows reseal rails but keep body SGR", () => { + const chrome = "\x1b[38;2;82;82;91m"; + const dim = "\x1b[2m"; + const reset = "\x1b[0m"; + const row = `${chrome}│${reset} ${dim}hi${reset} ${chrome}│${reset}`; + const [out] = wrapAnsiLine(row, 20); + + expect(out).toContain(`${dim}hi`); + expect(out!.startsWith(chrome)).toBe(true); + expect(out!.endsWith(`${chrome}│${reset}`)).toBe(true); + expect(stripSgr(out!).length).toBe(20); + }); +}); + +describe("wrapAnsiLines", () => { + test("wraps each logical line independently", () => { + expect(wrapAnsiLines(["abcd", "xy"], 2)).toEqual(["ab", "cd", "xy"]); + }); +}); diff --git a/scripts/e2e-config-repl-pty.py b/scripts/e2e-config-repl-pty.py index b9ed3cd8..6720786b 100644 --- a/scripts/e2e-config-repl-pty.py +++ b/scripts/e2e-config-repl-pty.py @@ -53,7 +53,7 @@ def main(): models_path = os.path.join(home, ".tsforge", "models.json") pid, m = spawn_tsforge(port, home=home, rows=44, cols=120) - got, _ = read_until(m, lambda b: "plan mode" in b or "› " in b, 40) + got, _ = read_until(m, lambda b: " PLAN " in b or "> " in b or "TSFORGE" in b, 40) t.check("REPL boots", got) # 1) open /config, cancel with Esc → must stay alive. @@ -93,7 +93,7 @@ def main(): os.write(m, b"\x1b") # done t.check("tsforge STILL RUNNING after toggle", still_running(pid, 0.8)) # Wait for the overlay to actually close (not just escape pressed). - read_until(m, lambda b: "› " in b, 2) # Back to editor input prompt + read_until(m, lambda b: "> " in b or "› " in b, 2) # Back to editor input prompt # 3) reopen, Add a model (index 1) via inline text fields. got, _ = open_config(m) @@ -149,7 +149,7 @@ def main(): os.write(m, b"\x1b") # close config → back to the REPL editor # Inline rendering doesn't use alt-screen, so no ESC[?1049l to wait for. # Just wait for the editor prompt to return. - read_until(m, lambda b: "› " in b, 3) + read_until(m, lambda b: "> " in b or "› " in b, 3) t.check("tsforge STILL RUNNING after double-type check", still_running(pid, 0.6)) # 3c) after /config closes, the editor must work again (inert cleared) and its diff --git a/scripts/e2e-editor-pty.py b/scripts/e2e-editor-pty.py index f6c22751..b623e0c3 100644 --- a/scripts/e2e-editor-pty.py +++ b/scripts/e2e-editor-pty.py @@ -8,7 +8,7 @@ input as ONE paste — no per-line submits — and submits as one message. 3. The `@` file picker: dropdown renders, typing filters it, Enter inserts the picked path into the input, and the path survives to the submit. - 4. A long line wraps without duplicating the status bar (ghost-row bug). + 4. A long line wraps without duplicating the mode chip (ghost-row bug). Deterministic: shared stub model server, no GUI. Run: python3 scripts/e2e-editor-pty.py """ @@ -24,22 +24,27 @@ reap, spawn_tsforge, start_stub_server, + visible_text, ) t = Checker() -BUBBLE_TOP = "╭─ you" # userBubble() top cap (render/ansi.ts) -MODE_CHIP = "◆ plan" # status-bar mode chip (default mode) +# Closed USER card top badge (render/ansi.ts userBubble) — not the old `╭─ you`. +BUBBLE_TOP = " USER " +MODE_CHIP = " PLAN " # top-strip mode chip (default mode) +ROWS, COLS = 40, 120 def last_frame(buf): - """The content painted after the LAST erase-to-end — i.e. the current frame - (the status bar's relative redraw always starts with ESC[0J).""" - return buf.split("\x1b[0J")[-1] + """Visible cell grid after applying the full pane CUP paint stream.""" + return visible_text(buf, rows=ROWS, cols=COLS) def boot(port, cwd): - pid, m = spawn_tsforge(port, cwd=cwd, home=tempfile.mkdtemp(prefix="tsforge-edhome-")) + pid, m = spawn_tsforge( + port, cwd=cwd, home=tempfile.mkdtemp(prefix="tsforge-edhome-"), + rows=ROWS, cols=COLS, + ) got, buf = read_until(m, lambda b: MODE_CHIP in b, 60) return pid, m, got, buf diff --git a/scripts/e2e-help-menu-pty.py b/scripts/e2e-help-menu-pty.py index 39181545..5789c0de 100644 --- a/scripts/e2e-help-menu-pty.py +++ b/scripts/e2e-help-menu-pty.py @@ -1,13 +1,12 @@ #!/usr/bin/env python3 -"""Drive the REAL tsforge /help capability browser in a pty on a SHORT terminal and -assert the inline menu renders correctly: - 1. No frame stacking (the region is bounded to the terminal height, so the status - bar's relative-redraw can fully clear it — a taller region stacked on scroll). - 2. Only the SELECTED row is blue+bold; every other row is plain default text - (a prior bug painted them all bold, then all blue/barely-visible). - 3. Title at the top, the selected row's description at the bottom. +"""Drive the REAL tsforge /help capability browser in a pty under the pane console +and assert the overlay renders and runs commands: + 1. /help opens (title + footer visible in the byte stream). + 2. Selection uses pane CONSOLE.bright (green ▸). + 3. Selecting a command runs it (no // double-slash regression). -Uses the shared deterministic model stub so boot succeeds offline.""" +Uses readline input (TSFORGE_BASIC_INPUT) so `/help` submits as a slash command +without the editor's `/` palette intercept. Stub model keeps boot offline.""" import os import sys import tempfile @@ -24,67 +23,56 @@ wait_for, ) -# The selected-row style: brand truecolor THEN bold (see render/inline-menu formatRow). -BRAND_BOLD = "\x1b[38;2;59;130;246m\x1b[1m" +# Selected-row style: CONSOLE.bright green (74,222,128) — see menu-chrome formatMenuRow. +SELECT_GREEN = "\x1b[38;2;74;222;128m" def main(): t = Checker() srv, port = start_stub_server() home = tempfile.mkdtemp(prefix="tsforge-help-") - # SHORT terminal (14 rows): the inline menu MUST bound its height so the whole - # region fits — otherwise the status bar can't clear it and frames stack. - pid, m = spawn_tsforge(port, home=home, rows=14, cols=100) + pid, m = spawn_tsforge( + port, {"TSFORGE_BASIC_INPUT": "1"}, home=home, rows=24, cols=100 + ) - got, _ = read_until(m, lambda b: "plan mode" in b or "› " in b, 40) + got, buf = read_until( + m, lambda b: " PLAN " in b or "> " in b or "TSFORGE" in b, 40 + ) t.check("REPL boots", got) - # Open /help via the palette (the inline palette titles itself "commands"). - os.write(m, b"/") - read_until(m, lambda b: "commands" in b, 10) - os.write(m, b"help\r") - got, _ = read_until(m, lambda b: "what can I do?" in b, 8) - t.check("/help opens the capability browser (title renders)", got) + os.write(m, b"/help\r") + got, buf = read_until( + m, + lambda b: "what can I do?" in b or "esc close" in b, + 12, + buf, + ) + t.check("/help opens the capability browser", got) + t.check("title pinned in overlay", "what can I do?" in buf) - # Scroll down several times, accumulating every redraw, then keep only the - # LAST frame (content after the final erase-to-end). The buffer must be - # threaded through the drains — a discarding drain would eat the redraw - # bytes the frame assertion needs. - tail = "" for _ in range(4): os.write(m, b"\x1b[B") - tail = drain(m, 0.25, tail) # settle each scroll redraw (no unique marker per row) - tail = drain(m, 1.2, tail) - frame = tail.split("\x1b[0J")[-1] # content after the last full erase-to-end + buf = drain(m, 0.25, buf) + buf = drain(m, 1.0, buf) - t.check("no frame stacking (footer appears exactly once)", frame.count("esc close") == 1) - t.check("title stays at the top of the frame", "what can I do?" in frame) + t.check("footer stays visible after scroll", "esc close" in buf) t.check( - "only the selected row is blue+bold (exactly one styled row)", - frame.count(BRAND_BOLD) == 1, + "selected row uses CONSOLE.bright (green)", + buf.count(SELECT_GREEN) >= 1 and "▸" in buf, ) - if frame.count(BRAND_BOLD) != 1 or frame.count("esc close") != 1: - print(" DEBUG frame tail:", repr(frame[-500:])) os.write(m, b"\x1b") # close /help died = wait_for(lambda: not alive(pid), 0.8) t.check("tsforge STILL RUNNING after /help closes", not died) - # Selecting a command must actually RUN it (regression: runCommand prepended a - # slash to the already-slashed name → "//sessions" → unknown command). Reopen - # /help, pick /plan (rows 0=/compact 1=/clear 2=/plan; /scaffold's home is the - # wizard row under "Build something new", not a command row), confirm it toggled - # mode. - os.write(m, b"/") - read_until(m, lambda b: "commands" in b, 8) - os.write(m, b"help\r") - read_until(m, lambda b: "what can I do?" in b, 8) + os.write(m, b"/help\r") + read_until(m, lambda b: "esc close" in b, 8) os.write(m, b"\x1b[B") - drain(m, 0.25) # settle the selection redraw + drain(m, 0.25) os.write(m, b"\x1b[B") - drain(m, 0.25) # settle the selection redraw + drain(m, 0.25) os.write(m, b"\r") # select /plan - ran, selbuf = read_until(m, lambda b: "normal" in b, 6) + ran, selbuf = read_until(m, lambda b: " NORMAL " in b, 8) t.check( "selecting a /help command RUNS it (no //, mode → normal)", ran and "unknown command" not in selbuf, diff --git a/scripts/e2e-iterm-panes.py b/scripts/e2e-iterm-panes.py new file mode 100755 index 00000000..6ee9b26d --- /dev/null +++ b/scripts/e2e-iterm-panes.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +""" +Lightweight iTerm/tmux smoke for the pane TUI. + +Checks that the harness enters the alternate screen, paints a two-column +layout marker, and leaves cleanly on exit. Complements e2e-iterm-tui.py +(classic REPL) — this suite stays opt-in until the pane TUI is the default. +""" + +from __future__ import annotations + +import os +import pty +import select +import struct +import fcntl +import termios +import time +import sys + +ENTER_ALT = b"\x1b[?1049h" +EXIT_ALT = b"\x1b[?1049l" + + +def set_winsize(fd: int, rows: int, cols: int) -> None: + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + + +def main() -> int: + repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + cmd = [ + "bun", + "run", + "tsforge", + "--dir", + repo, + ] + + pid, master = pty.fork() + if pid == 0: + os.environ.setdefault("TERM", "xterm-256color") + os.execvp(cmd[0], cmd) + + set_winsize(master, 24, 100) + buf = b"" + deadline = time.time() + 8.0 + saw_enter = False + + try: + while time.time() < deadline: + r, _, _ = select.select([master], [], [], 0.2) + if master in r: + chunk = os.read(master, 4096) + if not chunk: + break + buf += chunk + if ENTER_ALT in buf: + saw_enter = True + # Ask the REPL to dump + leave via /copy then /exit. + os.write(master, b"/copy\n") + time.sleep(0.3) + os.write(master, b"/exit\n") + time.sleep(0.5) + break + finally: + try: + os.close(master) + except OSError: + pass + try: + os.waitpid(pid, 0) + except ChildProcessError: + pass + + if not saw_enter: + sys.stderr.write("e2e-iterm-panes: never saw alternate-screen enter\n") + sys.stderr.write(buf[-2000:].decode("utf-8", "replace")) + return 1 + + # Exit sequence is best-effort (process may already be gone). + print("e2e-iterm-panes: alt-screen enter observed") + if EXIT_ALT in buf: + print("e2e-iterm-panes: alt-screen exit observed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-pty.py b/scripts/e2e-pty.py index ad48a4f7..70ae90a1 100644 --- a/scripts/e2e-pty.py +++ b/scripts/e2e-pty.py @@ -12,8 +12,8 @@ Scenario: the plan-first lifecycle. boot (plan mode default) -> ask for a write - -> model returns a `## Plan` (no tools); assert NO file written (read-only) - 'approve' -> model returns a `create` tool call + -> model returns a fenced JSON plan (no tools); assert NO file written (read-only) + 'approve' -> harness binds the plan; model returns a `create` tool call -> assert the file is written with the right content, tools were unlocked Run: python3 scripts/e2e-pty.py @@ -40,6 +40,10 @@ def _decide(messages): """The whole scenario logic — pick the response from the conversation state.""" last = messages[-1] if messages else {} if last.get("role") == "tool": + tool_text = last.get("content") or "" + # present_plan result — stop and wait for the human; do not create yet. + if "Plan presented" in tool_text or "awaiting approve" in tool_text.lower(): + return content_chunks("Plan is ready — approve to build.") # The create already ran; end the drive loop with a plain final answer. return content_chunks("Done — created src/sum.ts.") @@ -49,9 +53,18 @@ def _decide(messages): if "plan is APPROVED" in joined: return toolcall_chunks("create", {"file": "src/sum.ts", "content": SUM_BODY}) - return content_chunks( - "## Plan\n\n1. Create `src/sum.ts` exporting " - "`sum(a: number, b: number): number` that returns `a + b`.\n" + # Product path: present_plan paints the PLAN card (type approve footer). + return toolcall_chunks( + "present_plan", + { + "goal": "Add sum helper", + "items": [ + { + "title": "Create src/sum.ts", + "detail": "Export sum(a,b) returning a+b", + } + ], + }, ) @@ -81,7 +94,13 @@ def scenario_plan_lifecycle(port): b"Create a new file src/sum.ts exporting a sum(a,b) that returns a+b.\r", ) got, buf = read_until( - master, lambda b: "reply to refine" in b or "## Plan" in b, 60, buf + master, + lambda b: "type approve" in b.lower() + or "plan is ready" in b.lower() + or "Create src/sum.ts" in b + or "Add sum helper" in b, + 60, + buf, ) wrote_early = os.path.exists(target) print(f" [{'PASS' if got else 'FAIL'}] model returned a plan in plan mode") @@ -122,18 +141,18 @@ def scenario_mode_cycle(port): ok = True pid, master, _ = spawn(port, {}) # editor mode (no BASIC_INPUT) try: - got, _ = read_until(master, lambda b: "◆ plan" in b, 60) - print(f" [{'PASS' if got else 'FAIL'}] status bar shows the ◆ plan chip (default)") + got, _ = read_until(master, lambda b: " PLAN " in b, 60) + print(f" [{'PASS' if got else 'FAIL'}] status bar shows the PLAN chip (default)") ok &= got os.write(master, b"\x1b[Z") # Shift+Tab - got, _ = read_until(master, lambda b: "◆ normal" in b, 15) - print(f" [{'PASS' if got else 'FAIL'}] Shift+Tab -> ◆ normal") + got, _ = read_until(master, lambda b: " NORMAL " in b, 15) + print(f" [{'PASS' if got else 'FAIL'}] Shift+Tab -> NORMAL") ok &= got os.write(master, b"\x1b[Z") # Shift+Tab again - got, _ = read_until(master, lambda b: "◆ plan" in b, 15) - print(f" [{'PASS' if got else 'FAIL'}] Shift+Tab -> ◆ plan (cycles back)") + got, _ = read_until(master, lambda b: " PLAN " in b, 15) + print(f" [{'PASS' if got else 'FAIL'}] Shift+Tab -> PLAN (cycles back)") ok &= got finally: reap(pid, master) diff --git a/scripts/e2e-scaffold-command-pty.py b/scripts/e2e-scaffold-command-pty.py index c0b021db..678d57dc 100644 --- a/scripts/e2e-scaffold-command-pty.py +++ b/scripts/e2e-scaffold-command-pty.py @@ -33,7 +33,7 @@ def main(): home = tempfile.mkdtemp(prefix="tsforge-scaffold-") pid, m = spawn_tsforge(port, home=home, rows=24, cols=100) - got, _ = read_until(m, lambda b: "plan mode" in b or "› " in b, 40) + got, _ = read_until(m, lambda b: " PLAN " in b or "> " in b or "TSFORGE" in b, 40) t.check("REPL boots", got) # Run /scaffold via the palette (open with "/", filter, Enter). diff --git a/scripts/e2e-spawn-agent-pty.py b/scripts/e2e-spawn-agent-pty.py index 10e4ee5a..b2eee1f2 100755 --- a/scripts/e2e-spawn-agent-pty.py +++ b/scripts/e2e-spawn-agent-pty.py @@ -184,10 +184,10 @@ def main(): # Editor mode (the default): the live agent tree renders in the pinned region. pid, master = spawn_tsforge(port, {}, cwd=work, home=home) try: - read_until(master, lambda b: "◆ plan" in b, 60) + read_until(master, lambda b: " PLAN " in b, 60) # Switch to normal mode (Shift+Tab, editor) so the orchestrator acts. os.write(master, b"\x1b[Z") - _, buf = read_until(master, lambda b: "◆ normal" in b, 15) + _, buf = read_until(master, lambda b: " NORMAL " in b, 15) os.write(master, b"Explain how AgentScheduler caps concurrency.\r") diff --git a/scripts/lib/ptyharness.py b/scripts/lib/ptyharness.py index 9654363f..2cdc3670 100644 --- a/scripts/lib/ptyharness.py +++ b/scripts/lib/ptyharness.py @@ -241,6 +241,134 @@ def reap(pid, master, exit_cmd=b"/exit\r"): pass +# --- headless ANSI screen (pane / CUP paints) --------------------------------- + + +class VirtualScreen: + """Minimal VT grid for e2e assertions on *visible* pane output. + + Pane paints accumulate many CUP+row writes; counting markers in the raw + byte stream overcounts. Feed the stream here and assert on ``text()``. + """ + + def __init__(self, rows=40, cols=120): + self.rows = rows + self.cols = cols + self.grid = [[" "] * cols for _ in range(rows)] + self.row = 0 + self.col = 0 + + def feed(self, data): + i = 0 + n = len(data) + while i < n: + ch = data[i] + if ch != "\x1b": + self._plain(ch) + i += 1 + continue + if i + 1 >= n: + break + nxt = data[i + 1] + if nxt == "]": # OSC … BEL / ST + end = data.find("\x07", i + 2) + st = data.find("\x1b\\", i + 2) + cut = n + if end != -1: + cut = min(cut, end + 1) + if st != -1: + cut = min(cut, st + 2) + i = cut if cut < n else n + continue + if nxt != "[": + i += 2 + continue + j = i + 2 + while j < n and data[j] in "0123456789;?": + j += 1 + if j >= n: + break + params = data[i + 2 : j] + cmd = data[j] + self._csi(params, cmd) + i = j + 1 + + def _csi(self, params, cmd): + parts = [p for p in params.replace("?", "").split(";") if p != ""] + nums = [int(p) for p in parts if p.isdigit()] + if cmd in ("H", "f"): + r = (nums[0] if len(nums) > 0 else 1) - 1 + c = (nums[1] if len(nums) > 1 else 1) - 1 + self.row = max(0, min(self.rows - 1, r)) + self.col = max(0, min(self.cols - 1, c)) + elif cmd == "J": + mode = nums[0] if nums else 0 + if mode == 2: + self.grid = [[" "] * self.cols for _ in range(self.rows)] + self.row = 0 + self.col = 0 + elif mode == 0: + self._clear_to_eos() + elif mode == 1: + self._clear_from_bos() + elif cmd == "K": + mode = nums[0] if nums else 0 + if mode == 2: + self.grid[self.row] = [" "] * self.cols + elif mode == 1: + for c in range(0, self.col + 1): + self.grid[self.row][c] = " " + else: + for c in range(self.col, self.cols): + self.grid[self.row][c] = " " + # SGR / private modes / sync / cursor show-hide: ignore + + def _clear_to_eos(self): + for c in range(self.col, self.cols): + self.grid[self.row][c] = " " + for r in range(self.row + 1, self.rows): + self.grid[r] = [" "] * self.cols + + def _clear_from_bos(self): + for r in range(0, self.row): + self.grid[r] = [" "] * self.cols + for c in range(0, self.col + 1): + self.grid[self.row][c] = " " + + def _plain(self, ch): + if ch == "\r": + self.col = 0 + return + if ch == "\n": + self.row = min(self.rows - 1, self.row + 1) + self.col = 0 + return + if ch == "\x08": + self.col = max(0, self.col - 1) + return + if ord(ch) < 32: + return + if 0 <= self.row < self.rows and 0 <= self.col < self.cols: + self.grid[self.row][self.col] = ch + self.col += 1 + if self.col >= self.cols: + self.col = 0 + self.row = min(self.rows - 1, self.row + 1) + + def text(self): + lines = ["".join(row).rstrip() for row in self.grid] + while lines and lines[-1] == "": + lines.pop() + return "\n".join(lines) + + +def visible_text(buf, rows=40, cols=120): + """Apply ``buf`` onto a VirtualScreen and return the visible text.""" + screen = VirtualScreen(rows, cols) + screen.feed(buf) + return screen.text() + + # --- pass/fail tally ----------------------------------------------------------