From 137bab9299322b5d282970ef63e904a214b65a01 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 8 Aug 2026 23:38:48 +0200 Subject: [PATCH 1/8] feat: pane console as the interactive TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship the multi-pane agent console as the only interactive UI, with role-card chrome, growing input, and worklist driving — WIP draft so the branch is safe. --- packages/core/ARCHITECTURE.md | 39 +- packages/core/src/cli.ts | 121 ++ packages/core/src/cli/args.ts | 26 +- packages/core/src/cli/banner.ts | 124 +- packages/core/src/cli/capability-menu.ts | 3 + packages/core/src/cli/commands.ts | 11 + packages/core/src/cli/config-menu.ts | 12 +- packages/core/src/cli/repl-recipe.ts | 3 + packages/core/src/cli/repl-scaffold.ts | 23 +- packages/core/src/cli/repl-work.ts | 318 +++++ packages/core/src/cli/repl.ts | 776 ++++++++--- packages/core/src/cli/worklist-deps.ts | 67 + .../src/loop/greenfield/greenfield.types.ts | 2 + packages/core/src/loop/greenfield/run.ts | 29 +- packages/core/src/loop/greenfield/state.ts | 65 +- packages/core/src/loop/index.ts | 13 + packages/core/src/loop/worklist/index.ts | 17 + packages/core/src/loop/worklist/panel.ts | 88 ++ packages/core/src/loop/worklist/parse.ts | 277 ++++ packages/core/src/loop/worklist/run.ts | 133 ++ .../core/src/loop/worklist/worklist.types.ts | 25 + packages/core/src/render/agent-rail.ts | 267 +++- packages/core/src/render/ansi.ts | 228 +++- packages/core/src/render/command-menu.ts | 9 +- packages/core/src/render/file-menu.ts | 4 +- packages/core/src/render/frame/ansi-plain.ts | 60 + packages/core/src/render/frame/chrome.ts | 399 ++++++ packages/core/src/render/frame/codes.ts | 48 + .../core/src/render/frame/cursor-state.ts | 30 + packages/core/src/render/frame/fit-line.ts | 23 + packages/core/src/render/frame/focus.ts | 156 +++ packages/core/src/render/frame/frame.types.ts | 33 + packages/core/src/render/frame/grid.ts | 115 ++ packages/core/src/render/frame/index.ts | 116 ++ packages/core/src/render/frame/input-box.ts | 155 +++ packages/core/src/render/frame/layout.ts | 213 +++ packages/core/src/render/frame/opaque-bg.ts | 17 + packages/core/src/render/frame/outer-frame.ts | 150 +++ packages/core/src/render/frame/pane-keys.ts | 160 +++ packages/core/src/render/frame/pane-screen.ts | 1056 +++++++++++++++ packages/core/src/render/frame/scrollback.ts | 374 ++++++ packages/core/src/render/frame/scrollbar.ts | 95 ++ packages/core/src/render/frame/wrap-line.ts | 257 ++++ packages/core/src/render/index.ts | 24 + packages/core/src/render/inline-menu.ts | 20 +- packages/core/src/render/status-bar.ts | 43 +- packages/core/src/render/style.ts | 21 + packages/core/src/render/width.ts | 143 +- packages/core/src/render/wizard.ts | 34 +- packages/core/src/render/wizard.types.ts | 7 + packages/core/src/setup/run-setup.ts | 6 + packages/core/tests/agent-rail.test.ts | 74 +- packages/core/tests/banner.test.ts | 29 + packages/core/tests/chrome.test.ts | 214 +++ packages/core/tests/cli.test.ts | 47 + packages/core/tests/cursor-state.test.ts | 23 + packages/core/tests/fit-line.test.ts | 26 + packages/core/tests/focus.test.ts | 61 + packages/core/tests/frame-tui.test.ts | 1166 +++++++++++++++++ packages/core/tests/greenfield.test.ts | 17 + packages/core/tests/input-box.test.ts | 91 ++ packages/core/tests/message-render.test.ts | 147 ++- packages/core/tests/opaque-bg.test.ts | 31 + packages/core/tests/outer-frame.test.ts | 106 ++ packages/core/tests/pane-keys.test.ts | 82 ++ packages/core/tests/scrollbar.test.ts | 122 ++ packages/core/tests/status-bar.test.ts | 26 + packages/core/tests/width.test.ts | 11 +- packages/core/tests/wizard.test.ts | 53 + packages/core/tests/worklist-panel.test.ts | 72 + packages/core/tests/worklist-parse.test.ts | 169 +++ packages/core/tests/worklist-run.test.ts | 191 +++ packages/core/tests/wrap-line.test.ts | 122 ++ scripts/e2e-iterm-panes.py | 88 ++ 74 files changed, 8919 insertions(+), 484 deletions(-) create mode 100644 packages/core/src/cli/repl-work.ts create mode 100644 packages/core/src/cli/worklist-deps.ts create mode 100644 packages/core/src/loop/worklist/index.ts create mode 100644 packages/core/src/loop/worklist/panel.ts create mode 100644 packages/core/src/loop/worklist/parse.ts create mode 100644 packages/core/src/loop/worklist/run.ts create mode 100644 packages/core/src/loop/worklist/worklist.types.ts create mode 100644 packages/core/src/render/frame/ansi-plain.ts create mode 100644 packages/core/src/render/frame/chrome.ts create mode 100644 packages/core/src/render/frame/codes.ts create mode 100644 packages/core/src/render/frame/cursor-state.ts create mode 100644 packages/core/src/render/frame/fit-line.ts create mode 100644 packages/core/src/render/frame/focus.ts create mode 100644 packages/core/src/render/frame/frame.types.ts create mode 100644 packages/core/src/render/frame/grid.ts create mode 100644 packages/core/src/render/frame/index.ts create mode 100644 packages/core/src/render/frame/input-box.ts create mode 100644 packages/core/src/render/frame/layout.ts create mode 100644 packages/core/src/render/frame/opaque-bg.ts create mode 100644 packages/core/src/render/frame/outer-frame.ts create mode 100644 packages/core/src/render/frame/pane-keys.ts create mode 100644 packages/core/src/render/frame/pane-screen.ts create mode 100644 packages/core/src/render/frame/scrollback.ts create mode 100644 packages/core/src/render/frame/scrollbar.ts create mode 100644 packages/core/src/render/frame/wrap-line.ts create mode 100644 packages/core/tests/chrome.test.ts create mode 100644 packages/core/tests/cursor-state.test.ts create mode 100644 packages/core/tests/fit-line.test.ts create mode 100644 packages/core/tests/focus.test.ts create mode 100644 packages/core/tests/frame-tui.test.ts create mode 100644 packages/core/tests/input-box.test.ts create mode 100644 packages/core/tests/opaque-bg.test.ts create mode 100644 packages/core/tests/outer-frame.test.ts create mode 100644 packages/core/tests/pane-keys.test.ts create mode 100644 packages/core/tests/scrollbar.test.ts create mode 100644 packages/core/tests/worklist-panel.test.ts create mode 100644 packages/core/tests/worklist-parse.test.ts create mode 100644 packages/core/tests/worklist-run.test.ts create mode 100644 packages/core/tests/wrap-line.test.ts create mode 100755 scripts/e2e-iterm-panes.py diff --git a/packages/core/ARCHITECTURE.md b/packages/core/ARCHITECTURE.md index fd6c5378..9041cda0 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**, **570 files**, **102124 lines**, **136 cross-subsystem edges**. This page is the exhaustive record: every subsystem, every cross-subsystem edge, and @@ -13,17 +13,17 @@ 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 | 110 | 30935 | 7 | 21 | | `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 | +| `cli` | Argument parsing, the interactive REPL, and per-mode wiring | core | 22 | 7240 | 2 | 19 | +| `render` | Terminal UI — status bar, menus, wizards, markdown, diffs, spinners | core | 35 | 6321 | 6 | 5 | | `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 | | `scaffold` | Stands up a new project from an archetype and configures its gate | optional | 15 | 2526 | 3 | 2 | +| `(root)` | CLI entry, model registry, session persistence — the loose files in src/ | core | 6 | 2436 | 5 | 15 | | `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 | | `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 | @@ -39,7 +39,7 @@ inventory, see the hand-drawn map on [Internals](/internals/). | `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 | -| `validate` | Runs the gate command and parses tool output into structured errors | core | 7 | 475 | 7 | 2 | +| `validate` | Runs the gate command and parses tool output into structured errors | core | 7 | 475 | 8 | 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 | | `constitution` ⚠️ | Baseline system-role text and the reference ESLint constitution | optional | 1 | 267 | 0 | 0 | @@ -56,9 +56,9 @@ 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:22` → `./cli/worklist-deps` | `cli/banner.ts:6` → `../session-store` | | `(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` | +| `(root)` ↔ `loop` | `cli.ts:21` → `./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` | @@ -91,20 +91,21 @@ Async functions returning an exit code, declared under the CLI — the commands. | Function | Declared | | --- | --- | -| `agentsMode` | `cli.ts:346` | -| `greenfieldMode` | `cli.ts:643` | +| `agentsMode` | `cli.ts:356` | +| `greenfieldMode` | `cli.ts:653` | | `harnessDiagnoseMode` | `cli/harness-diagnose-mode.ts:211` | | `harnessReviewMode` | `cli/harness-review-mode.ts:684` | -| `main` | `cli.ts:754` | -| `mapMode` | `cli.ts:481` | -| `recipesMode` | `cli.ts:500` | -| `repl` | `cli/repl.ts:552` | -| `reviewMode` | `cli.ts:181` | -| `runOnce` | `cli.ts:93` | +| `main` | `cli.ts:871` | +| `mapMode` | `cli.ts:491` | +| `recipesMode` | `cli.ts:510` | +| `repl` | `cli/repl.ts:557` | +| `reviewMode` | `cli.ts:191` | +| `runOnce` | `cli.ts:103` | | `runTraceCommand` | `cli/repl-commands.ts:109` | -| `scaffoldMode` | `cli.ts:725` | -| `setupMode` | `cli.ts:489` | -| `traceMode` | `cli.ts:551` | +| `scaffoldMode` | `cli.ts:842` | +| `setupMode` | `cli.ts:499` | +| `traceMode` | `cli.ts:561` | +| `worklistMode` | `cli.ts:732` | ## Imports that leave `src/` diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 06b6fccf..3caa623a 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -8,9 +8,19 @@ import { runGreenfield, prepareState, planFeatures, + hasState, + prepareWorklistState, + runWorklist, + acceptMapOf, + parseWorklist, + resolveWorklistPath, + tickWorklistFile, + WORKLIST_STATE, type IGreenfieldDeps, type Reporter, } from "./loop"; +import { createWorklistDeps } from "./cli/worklist-deps"; +import { readFile } from "node:fs/promises"; import { modelAgent, AgentRunner, type IAgentResult } from "./agent"; import { AgentScheduler } from "./agent/agent-scheduler"; import { loadAgentSpecs, findAgentSpec } from "./config/agent-specs"; @@ -714,6 +724,113 @@ async function greenfieldMode(args: ICliArgs): Promise { return result.status === "done" ? 0 : 1; } +/** + * `tsforge --work [PLAN.md] --accept ""`: drive a human-written checklist + * to completion (or park leftovers after one revisit). Resumes `.tsforge/worklist/` + * when present. + */ +async function worklistMode(args: ICliArgs): Promise { + if (args.accept.length === 0) { + process.stdout.write( + "worklist needs a build gate — pass --accept '' or set `gate` in the recipe\n" + ); + + return 1; + } + + const pathHint = args.task.length > 0 ? args.task : undefined; + let sourcePath = + pathHint !== undefined + ? await resolveWorklistPath(args.dir, pathHint) + : await resolveWorklistPath(args.dir); + + if (!(await hasState(args.dir, WORKLIST_STATE)) && sourcePath === null) { + process.stdout.write( + "no worklist found — pass a path (tsforge --work PLAN.md) or add PLAN.md / TASKS.md\n" + ); + + return 1; + } + + const state = await prepareWorklistState(args.dir, { + goal: "worklist", + ...(sourcePath !== null ? { path: sourcePath } : {}), + }); + + if (state === null) { + process.stdout.write("worklist is empty — nothing to build\n"); + + return 1; + } + + // On resume the source path may still be discoverable for --tick / accepts. + sourcePath ??= await resolveWorklistPath(args.dir, pathHint); + + let accepts = new Map(); + + if (sourcePath !== null) { + try { + accepts = acceptMapOf( + parseWorklist(await readFile(sourcePath, "utf8"), { + includeDone: true, + }) + ); + } catch { + accepts = new Map(); + } + } + + const roleName = (specific: string): string => + specific.length > 0 ? specific : args.model; + const work = makeProvider( + (await resolveModelByName(roleName(args.workModel))).entry + ); + const evaluator = makeProvider( + (await resolveModelByName(roleName(args.evaluatorModel))).entry + ); + const report = makeReporter(resolveLogPath("worklist", args.log), "worklist"); + const thinkingTokenBudget = + args.thinkingBudget > 0 + ? args.thinkingBudget + : envNumber("TSFORGE_THINKING_BUDGET"); + + const deps = createWorklistDeps({ + cwd: args.dir, + accept: args.accept, + accepts, + scope: scopeOf(args), + work, + evaluator, + report, + ...(thinkingTokenBudget === undefined ? {} : { thinkingTokenBudget }), + ...(args.maxTurns > 0 ? { maxTurns: args.maxTurns } : {}), + }); + + const result = await runWorklist(args.dir, state, deps, { onEvent: report }); + + if (args.tick && sourcePath !== null) { + await tickWorklistFile(sourcePath, result.features); + } + + const done = result.features.filter((f) => f.passes).length; + const statusMsg = + result.status === "done" + ? "✓ all worklist items verified" + : result.status === "needs-infra" + ? `✗ infrastructure unavailable: ${result.infra ?? "?"}` + : `✗ stuck on '${result.stuckFeature ?? "?"}'`; + + process.stdout.write(`\n${statusMsg} (${done}/${result.features.length})\n`); + + await runNotify( + args.dir, + args.notify, + `worklist ${result.status} ${done}/${result.features.length}` + ); + + return result.status === "done" ? 0 : 1; +} + /** * `tsforge scaffold …` — greenfield wizard that stands up boringstack (or its * Astro static site). Delegates the remaining argv to the scaffold command's own @@ -855,6 +972,10 @@ export async function main(): Promise { return greenfieldMode(args); } + if (args.work) { + return worklistMode(args); + } + // A positional task with a scope + gate ⇒ one-shot; otherwise interactive. return isOneShot(args) ? runOnce(args) : repl(args); } diff --git a/packages/core/src/cli/args.ts b/packages/core/src/cli/args.ts index 2262a747..ae303c37 100644 --- a/packages/core/src/cli/args.ts +++ b/packages/core/src/cli/args.ts @@ -54,6 +54,11 @@ export interface ICliArgs { /** Run the greenfield feature-checklist outer loop (`--greenfield`, or a recipe * with `mode: "greenfield"`). `task` carries the one-line build goal. */ greenfield: boolean; + /** Run a human-written worklist (`--work`). `task` is an optional list path; + * when empty, looks up PLAN.md → TASKS.md → .specs/next.md. */ + work: boolean; + /** Opt-in rewrite of the human checklist file as items pass (`--tick`). */ + tick: boolean; /** Shell command to run on completion of an unattended run (`--notify `), * with the outcome in $TSFORGE_STATUS. "" = no notification. */ notify: string; @@ -113,6 +118,8 @@ const BOOL_FLAGS: Record< | "withReview" | "scout" | "greenfield" + | "work" + | "tick" | "setupYes" | "version" | "help" @@ -129,6 +136,8 @@ const BOOL_FLAGS: Record< "--with-review": "withReview", "--scout": "scout", "--greenfield": "greenfield", + "--work": "work", + "--tick": "tick", "--yes": "setupYes", "--version": "version", "-V": "version", @@ -152,7 +161,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" + ); } /** @@ -217,6 +232,8 @@ export function cliUsage(): string { " --policy-mode plan|default|acceptEdits|ci|dontAsk|bypassPermissions", ` --profile strictness: ${PROFILE_IDS.join("|")}`, " --notify run a command when an unattended run finishes", + " --work [path] drive a checklist (PLAN.md / TASKS.md / path)", + " --tick rewrite the human checklist as items pass", " --version, -V print the version and exit", " --help, -h this help", "", @@ -249,6 +266,8 @@ export function parseArgs(argv: readonly string[]): ICliArgs { withReview: false, scout: false, greenfield: false, + work: false, + tick: false, notify: "", base: "", map: false, @@ -277,6 +296,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) { 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..4baa493c 100644 --- a/packages/core/src/cli/capability-menu.ts +++ b/packages/core/src/cli/capability-menu.ts @@ -15,6 +15,8 @@ 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; } /** @@ -69,6 +71,7 @@ export function runCapabilityMenu(deps: ICapabilityMenuDeps): Promise { title: "tsforge — what can I do?", render: deps.render, close: deps.close, + columns: deps.columns, }).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..2eb783cc 100644 --- a/packages/core/src/cli/commands.ts +++ b/packages/core/src/cli/commands.ts @@ -33,6 +33,17 @@ export const COMMANDS: readonly ICommandSpec[] = [ summary: "toggle plan mode (on by default: explore → clarify → plan; 'approve' implements)", }, + { + name: "/work", + arg: "[file|goal]", + summary: + "drive a checklist (PLAN.md / file) to completion, or plan one from a goal", + }, + { + name: "/copy", + summary: + "leave the pane TUI (if active) and dump the transcript for selection", + }, { name: "/gate", arg: "", diff --git a/packages/core/src/cli/config-menu.ts b/packages/core/src/cli/config-menu.ts index dc0ad53b..154ceff4 100644 --- a/packages/core/src/cli/config-menu.ts +++ b/packages/core/src/cli/config-menu.ts @@ -80,6 +80,8 @@ export interface IConfigDeps { readonly setEnv: (name: string, value: string | undefined) => void; /** The inline menu view (statusBar overlay + close). */ readonly view?: IConfigMenuView; + /** Overlay width. Prefer main-pane inner cols when the pane console is live. */ + readonly columns?: number; } const NON_EMPTY = (label: string) => (v: string) => @@ -324,7 +326,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) { @@ -341,7 +348,7 @@ export function runConfigMenu(deps: IConfigDeps): Promise { `${paint(editState.setting.label, STYLE.bold, deps.color)} · field ${editState.fieldIndex + 1} of ${total}`, "─".repeat(columns), field.label, - ` ${shown}${paint("▏", STYLE.brand, deps.color)}`, + ` ${shown}${paint("▏", STYLE.cyan, deps.color)}`, ...(error === null ? [] : ["", paint(error, STYLE.yellow, deps.color)]), "", paint("type enter next esc cancel", STYLE.dim, deps.color), @@ -423,6 +430,7 @@ export function runConfigMenu(deps: IConfigDeps): Promise { close: () => { view.close(); }, + columns, }).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..dd2175d7 100644 --- a/packages/core/src/cli/repl-recipe.ts +++ b/packages/core/src/cli/repl-recipe.ts @@ -12,6 +12,8 @@ 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; } /** @@ -46,6 +48,7 @@ export async function openRecipePicker(deps: IReplRecipeDeps): Promise { title: "recipes", render: deps.render, close: deps.close, + columns: deps.columns, }); if (selected !== null) { diff --git a/packages/core/src/cli/repl-scaffold.ts b/packages/core/src/cli/repl-scaffold.ts index 572001aa..5b2e2d14 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,8 @@ export interface IReplScaffoldDeps { readonly suspend: () => void; readonly resume: () => void; readonly out: (s: string) => void; + /** Pane / status overlay — when set, scaffold wizards skip nested alt-screen. */ + readonly view?: IWizardView; } /** Free-text step: the folder name for the new project (created under cwd). */ @@ -169,12 +171,19 @@ 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 }), + }; + + // 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 +208,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-work.ts b/packages/core/src/cli/repl-work.ts new file mode 100644 index 00000000..d05f34e5 --- /dev/null +++ b/packages/core/src/cli/repl-work.ts @@ -0,0 +1,318 @@ +/** + * `/work` REPL flow: resume or parse a checklist, optionally plan from a goal, + * then drive `runWorklist` with a fresh task per item. + */ +import { access, readFile } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; +import type { createInterface } from "node:readline/promises"; +import type { OpenAICompatibleProvider } from "../inference"; +import type { IModelEntry } from "../models-config"; +import { resolveCapabilityModel, resolveModelByName } from "../models-config"; +import { + hasState, + loadState, + planFeatures, + renderProgress, + type IGreenfieldState, +} from "../loop/greenfield"; +import { + acceptMapOf, + parseWorklist, + prepareWorklistState, + resolveWorklistPath, + runWorklist, + tickWorklistFile, + WORKLIST_STATE, +} from "../loop/worklist"; +import type { IWorklistItem } from "../loop/worklist"; +import type { Reporter } from "../loop"; +import { makeProvider, envNumber } from "./model-setup"; +import { makeReporter } from "./logging"; +import { scopeOf, type ICliArgs } from "./args"; +import { createWorklistDeps } from "./worklist-deps"; + +type Rl = ReturnType | null; + +export interface IRunWorkCommandOpts { + args: ICliArgs; + arg: string; + echo: (s: string) => void; + rl: Rl; + workProvider: OpenAICompatibleProvider; + activeModelEntry: IModelEntry; + /** Session gate command (may be empty). */ + gate: string; + /** Opt-in tick of the human file. */ + tick?: boolean; + logFile: string; + id: string; + /** Push worklist slot lines into the live region (Phase 2). */ + onProgress?: (state: IGreenfieldState) => void; +} + +async function pathExists(path: string): Promise { + try { + await access(path); + + return true; + } catch { + return false; + } +} + +async function resolveArgPath( + cwd: string, + arg: string +): Promise { + if (arg.length === 0) { + return resolveWorklistPath(cwd); + } + + const candidate = isAbsolute(arg) ? arg : join(cwd, arg); + + if (await pathExists(candidate)) { + return candidate; + } + + return null; +} + +async function approvePlan( + echo: (s: string) => void, + rl: Rl, + checklist: string +): Promise<"approve" | "cancel"> { + echo(`\nProposed worklist:\n${checklist}\n`); + echo("Approve this list? (approve/cancel)\n"); + + if (rl === null) { + echo("(non-interactive — cancelling)\n"); + + return "cancel"; + } + + const answer = (await rl.question("> ")).trim().toLowerCase(); + + return answer === "approve" || answer === "approved" || answer === "go" + ? "approve" + : "cancel"; +} + +/** + * Plan a worklist from a free-text goal and ask for approval. + * Persistence is left to the caller. + */ +async function planFromGoal( + opts: IRunWorkCommandOpts, + goal: string +): Promise { + const { echo, rl, activeModelEntry } = opts; + + echo("▸ planning a worklist from your goal...\n"); + + const plannerResolved = await resolveCapabilityModel("planner"); + const planner = makeProvider(plannerResolved?.entry ?? activeModelEntry); + const planned = await planFeatures(planner, goal); + + if (planned === null || planned.features.length === 0) { + echo("planner produced no items — nothing to run\n"); + + return null; + } + + const preview = renderProgress({ + goal, + features: planned.features, + }); + + if ((await approvePlan(echo, rl, preview)) !== "approve") { + echo("worklist cancelled\n"); + + return null; + } + + return planned.features.map((f) => ({ + id: f.id, + text: f.desc, + done: false, + })); +} + +/** Load accepts from a source markdown file (best-effort on resume). */ +async function acceptsFromFile( + path: string | null +): Promise> { + if (path === null) { + return new Map(); + } + + try { + const items = parseWorklist(await readFile(path, "utf8"), { + includeDone: true, + }); + + return acceptMapOf(items); + } catch { + return new Map(); + } +} + +interface IResolvedWorklist { + state: IGreenfieldState; + sourcePath: string | null; + accepts: Map; +} + +async function resolveWorklistStart( + opts: IRunWorkCommandOpts, + asPath: string | null, + isGoal: boolean +): Promise { + const cwd = opts.args.dir; + + if (await hasState(cwd, WORKLIST_STATE)) { + const state = await prepareWorklistState(cwd, { goal: "worklist" }); + + return state === null + ? null + : { state, sourcePath: asPath, accepts: new Map() }; + } + + if (!isGoal) { + const state = await prepareWorklistState(cwd, { + goal: "worklist", + ...(asPath !== null ? { path: asPath } : {}), + }); + + return state === null + ? null + : { + state, + sourcePath: asPath ?? (await resolveWorklistPath(cwd)), + accepts: new Map(), + }; + } + + const items = await planFromGoal(opts, opts.arg); + + if (items === null) { + return null; + } + + const state = await prepareWorklistState(cwd, { + goal: opts.arg, + items, + }); + + return state === null + ? null + : { state, sourcePath: null, accepts: acceptMapOf(items) }; +} + +function stuckMessage(result: Awaited>): string { + if (result.status === "done") { + return "✓ all worklist items verified"; + } + + if (result.status === "needs-infra") { + return `✗ infrastructure unavailable: ${result.infra ?? "?"}`; + } + + const parkedIds = result.features + .filter((f) => f.parked === true) + .map((f) => f.id); + const parked = + parkedIds.length > 0 ? parkedIds.join(", ") : (result.stuckFeature ?? "?"); + + return `✗ stuck — parked: ${parked}`; +} + +/** Execute `/work [file|goal]`. */ +export async function runWorkCommand(opts: IRunWorkCommandOpts): Promise { + const { args, arg, echo, workProvider, gate, logFile, id } = opts; + const cwd = args.dir; + const asPath = await resolveArgPath(cwd, arg); + const isGoal = arg.length > 0 && asPath === null; + const resolved = await resolveWorklistStart(opts, asPath, isGoal); + + if (resolved === null) { + echo( + "no worklist found — add PLAN.md / TASKS.md, pass a file, or `/work `\n" + ); + + return; + } + + const { state, sourcePath } = resolved; + let { accepts } = resolved; + + if (accepts.size === 0) { + accepts = await acceptsFromFile(sourcePath); + } + + if ((gate.length === 0 || gate === "true") && accepts.size === 0) { + echo( + "worklist needs a gate — `/gate ''` or per-item `accept:` in the list\n" + ); + + return; + } + + echo( + `▸ worklist: ${state.features.filter((f) => f.passes).length}/${state.features.length} done — driving remaining items\n` + ); + + const evaluatorName = + opts.args.evaluatorModel.length > 0 + ? opts.args.evaluatorModel + : opts.args.model; + const evaluator = makeProvider( + evaluatorName.length > 0 + ? (await resolveModelByName(evaluatorName)).entry + : opts.activeModelEntry + ); + const baseReport = makeReporter(logFile, id, `${id}-work`); + const thinkingTokenBudget = envNumber("TSFORGE_THINKING_BUDGET"); + + opts.onProgress?.(state); + + const report: Reporter = (event) => { + baseReport(event); + + if (opts.onProgress === undefined) { + return; + } + + void loadState(cwd, WORKLIST_STATE).then((latest) => { + if (latest !== null) { + opts.onProgress?.(latest); + } + }); + }; + + const deps = createWorklistDeps({ + cwd, + accept: gate, + accepts, + scope: scopeOf(args), + work: workProvider, + evaluator, + report, + ...(thinkingTokenBudget === undefined ? {} : { thinkingTokenBudget }), + ...(args.maxTurns > 0 ? { maxTurns: args.maxTurns } : {}), + }); + + const result = await runWorklist(cwd, state, deps, { onEvent: report }); + + opts.onProgress?.({ ...state, features: result.features }); + + if (opts.tick === true && sourcePath !== null) { + await tickWorklistFile(sourcePath, result.features); + } + + const done = result.features.filter((f) => f.passes).length; + + echo( + `\n${stuckMessage(result)} (${done}/${result.features.length}) — see .tsforge/${WORKLIST_STATE}/progress.md\n` + ); +} diff --git a/packages/core/src/cli/repl.ts b/packages/core/src/cli/repl.ts index 92f48f4b..85490742 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)`. */ @@ -71,15 +71,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 +121,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 +130,8 @@ import { runReviewCommand, runTraceCommand, } from "./repl-commands"; +import { runWorkCommand } from "./repl-work"; +import { formatWorklistLines, worklistBadge } from "../loop/worklist"; /** A unique-enough id for a new session (time + a little randomness). */ function newSessionId(): string { @@ -371,6 +376,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(); @@ -564,7 +589,6 @@ export async function repl(args: ICliArgs): Promise { gateLabel: initialGateLabel, logFile, resumed, - files, activeModelEntry, autoGate, } = await initReplSession(args); @@ -620,26 +644,27 @@ 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 === true && process.stdout.isTTY === true; + const rejectPane = paneConsoleRejectReason({ + stdinTty: process.stdin.isTTY === true, + stdoutTty: process.stdout.isTTY === true, + 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,7 +725,7 @@ 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); @@ -761,9 +786,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 +797,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 +848,7 @@ export async function repl(args: ICliArgs): Promise { return text.trim().length > 0 ? text : null; } finally { if (hinting) { - statusBar.clearEditorOverlay(); + liveChrome?.clearEditorOverlay(); } } }; @@ -821,12 +867,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 +946,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 } @@ -996,7 +1040,18 @@ export async function repl(args: ICliArgs): Promise { const planned = last?.role === "assistant" && /^##\s*plan\b/im.test(last.content); - echo(`\n${planHint(planned)}\n`); + // Only nudge approve when a real plan was proposed — casual turns in + // plan mode already show ◆plan in the top strip; repeating the PLAN + // footer after every "sup" is noise. + if (planned) { + const cols = panesLive() + ? paneScreen.mainInnerCols() + : process.stdout.columns > 0 + ? process.stdout.columns + : 80; + + echo(`\n${planHint(true, cols)}\n`); + } return; } @@ -1027,6 +1082,9 @@ 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; + let handleWork: (arg: string) => Promise = () => Promise.resolve(); // 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,6 +1100,14 @@ export async function repl(args: ICliArgs): Promise { await handleHelp(); break; + case "copy": + handleCopy(); + break; + + case "work": + await handleWork(arg); + break; + case "clear": { // Rebuild the session with the current state (config is not reused; // repl's /clear creates a fresh Session.create call) @@ -1090,7 +1156,7 @@ export async function repl(args: ICliArgs): Promise { awaitingUserAnswer = false; await persist(); clearScreen(); // wipe the visible terminal + scrollback, not just the state - process.stdout.write("conversation cleared\n"); + echo("conversation cleared\n"); break; } @@ -1155,16 +1221,39 @@ 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(); + }, + }, + }); + } finally { + editorControl?.setInputInert(false); + editorControl?.resume(); + editorControl?.getBuffer().setText(""); + } + break; } @@ -1271,8 +1360,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 +1376,162 @@ 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; + }; + + /** 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"); + } + }; + + handleWork = async (workArg: string): Promise => { + await runWorkCommand({ + args, + arg: workArg, + echo: (s) => { + process.stdout.write(s); + }, + rl, + workProvider: provider, + activeModelEntry, + gate: session.gate, + tick: args.tick, + logFile, + id, + onProgress: (workState) => { + const lines = formatWorklistLines(workState); + + if (panesLive()) { + paneScreen.setPanel(lines); + paneScreen.setWorklistBadge(worklistBadge(workState)); + syncPaneChrome(); + } + }, + }); + + if (panesLive()) { + paneScreen.clearPanel(); + paneScreen.setWorklistBadge(""); + syncPaneChrome(); + } + }; + + /** 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 +1584,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 +1604,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 +1633,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 +1702,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 +1718,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 +1790,13 @@ 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(), }); } finally { editorControl?.setInputInert(false); @@ -1565,9 +1804,7 @@ export async function repl(args: ICliArgs): Promise { editorControl?.getBuffer().setText(""); } - if (statusBar.active) { - statusBar.update(statusInfo()); - } + refreshStatus(); await persist(); }; @@ -1581,17 +1818,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 +1851,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 +1872,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 +1896,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 +1941,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 +1949,51 @@ export async function repl(args: ICliArgs): Promise { resizeTimer = setTimeout(() => { resizing = false; resizeTimer = null; - statusBar.resize(statusInfo()); + paneScreen.resize(process.stdout.rows, process.stdout.columns); + 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). + // Restore the terminal even on an unexpected exit (leave is idempotent). process.on("exit", () => { - statusBar.teardown(); + 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 +2032,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 +2060,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 +2085,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. @@ -1886,11 +2144,12 @@ export async function repl(args: ICliArgs): Promise { : openRecipePicker({ cwd: args.dir, render: (lines) => { - statusBar.setOverlay(lines, statusInfo()); + chrome.setOverlay(lines); }, close: () => { - statusBar.clearOverlay(statusInfo()); + chrome.clearOverlay(); }, + columns: transcriptCols(), out: (s) => process.stdout.write(s), runRecipe: (recipe) => { if (recipe.gate !== undefined) { @@ -1908,11 +2167,12 @@ export async function repl(args: ICliArgs): Promise { }, }), render: (lines) => { - statusBar.setOverlay(lines, statusInfo()); + chrome.setOverlay(lines); }, close: () => { - statusBar.clearOverlay(statusInfo()); + chrome.clearOverlay(); }, + columns: transcriptCols(), }; }; @@ -1936,9 +2196,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 +2218,21 @@ 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(); + }, + }, }); }; - // 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 +2256,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 +2277,12 @@ 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(), }; try { @@ -2061,12 +2330,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 +2363,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 +2397,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 +2476,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,30 +2575,30 @@ 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; @@ -2296,6 +2626,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 +2650,56 @@ 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 }); + + if (updateNotice !== null) { + panes.appendMain(`${updateNotice}\n`); + } + + if (resumed !== null) { + const speaker = modelInfo(provider.config).model; + + for (const message of resumed.messages) { + panes.appendMain( + renderMessage(message, { color: true, speaker }) + ); + } + } + + // Empty landing otherwise — discovery lives in the input placeholder. + }; + + if (interactiveTty) { + if (paneScreen.enter() !== true) { + 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 +2708,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/cli/worklist-deps.ts b/packages/core/src/cli/worklist-deps.ts new file mode 100644 index 00000000..64d658f9 --- /dev/null +++ b/packages/core/src/cli/worklist-deps.ts @@ -0,0 +1,67 @@ +import { composeGate } from "../gate/gate-runner"; +import { validate } from "../validate"; +import type { OpenAICompatibleProvider } from "../inference"; +import { judgeStage } from "../loop/boringstack/gate-stages"; +import { runTask } from "../loop"; +import { RUN_STATUS } from "../loop/loop.constants"; +import type { Reporter, IFeature, IGreenfieldDeps } from "../loop"; + +export interface IWorklistDepsOptions { + cwd: string; + /** Session / CLI default gate. */ + accept: string; + /** Per-feature accept overrides (feature id → command). */ + accepts?: ReadonlyMap; + scope: string[]; + work: OpenAICompatibleProvider; + evaluator: OpenAICompatibleProvider; + report: Reporter; + maxTurns?: number; + thinkingTokenBudget?: number; +} + +/** + * Fresh `runTask` per worklist item — same shape as CLI `greenfieldDeps`, so a + * long list does not share one drifting transcript across items. + */ +export function createWorklistDeps( + opts: IWorklistDepsOptions +): IGreenfieldDeps { + return { + implement: async (feature: IFeature) => { + const accept = + opts.accepts?.get(feature.id) ?? + (opts.accept.length > 0 ? opts.accept : "true"); + + const base = { + id: feature.id, + intent: feature.desc, + accept, + files: opts.scope, + context: [], + }; + + const gate = composeGate([ + { + run: (cwd, gateOpts) => + validate(base, cwd, undefined, gateOpts ?? {}), + }, + judgeStage(opts.evaluator, opts.cwd, feature), + ]); + + const result = await runTask(base, opts.cwd, opts.work, { + onEvent: opts.report, + gate, + ...(opts.thinkingTokenBudget === undefined + ? {} + : { thinkingTokenBudget: opts.thinkingTokenBudget }), + ...(opts.maxTurns === undefined ? {} : { maxTurns: opts.maxTurns }), + }); + + return { + done: result.status === RUN_STATUS.done, + ...(result.handoff !== undefined ? { handoff: result.handoff } : {}), + }; + }, + }; +} 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..d019d015 100644 --- a/packages/core/src/loop/index.ts +++ b/packages/core/src/loop/index.ts @@ -20,12 +20,25 @@ export { judgeFeature, parseFeatureVerdict, loadState, + hasState, saveState, writeSpec, writeProgress, renderProgress, greenfieldDir, } from "./greenfield"; +export { + parseWorklist, + resolveWorklistPath, + itemsToFeatures, + acceptMapOf, + WORKLIST_STATE, + prepareWorklistState, + runWorklist, + tickWorklistFile, + formatWorklistLines, +} from "./worklist"; +export type { IWorklistItem, IPrepareWorklistOptions } from "./worklist"; export type { IFeature, IGreenfieldState, diff --git a/packages/core/src/loop/worklist/index.ts b/packages/core/src/loop/worklist/index.ts new file mode 100644 index 00000000..23fdf956 --- /dev/null +++ b/packages/core/src/loop/worklist/index.ts @@ -0,0 +1,17 @@ +export { + parseWorklist, + resolveWorklistPath, + slugifyItem, + itemsToFeatures, + acceptMapOf, +} from "./parse"; +export { + WORKLIST_STATE, + prepareWorklistState, + runWorklist, + tickWorklistFile, +} from "./run"; +export type { IPrepareWorklistOptions } from "./run"; +export { formatWorklistLines, worklistBadge } from "./panel"; +export type { IFormatWorklistLinesOptions } from "./panel"; +export type { IWorklistItem, IParseWorklistOptions } from "./worklist.types"; diff --git a/packages/core/src/loop/worklist/panel.ts b/packages/core/src/loop/worklist/panel.ts new file mode 100644 index 00000000..4a122603 --- /dev/null +++ b/packages/core/src/loop/worklist/panel.ts @@ -0,0 +1,88 @@ +import type { IFeature, IGreenfieldState } from "../greenfield"; + +export interface IFormatWorklistLinesOptions { + /** How many pending (not-yet-current) items to preview. Default 3. */ + maxPending?: number; + /** Highlight this line index when the panel is focused (0 = header). */ + selectedIndex?: number; + /** When true, prefix the selected row with `▸ `. */ + showSelection?: boolean; +} + +function boxOf(feature: IFeature, current: boolean): string { + if (feature.passes) { + return "[x]"; + } + + if (feature.parked === true) { + return "[~]"; + } + + if (current) { + return "[>]"; + } + + return "[ ]"; +} + +/** Compact badge for the top status strip, e.g. `3/7`. */ +export function worklistBadge(state: IGreenfieldState): string { + const total = state.features.length; + + if (total === 0) { + return ""; + } + + const done = state.features.filter((f) => f.passes).length; + + return `${done}/${total}`; +} + +/** + * Compact live-region / panel lines for the worklist — counts and checkmarks + * from gate state only (never model narration). + */ +export function formatWorklistLines( + state: IGreenfieldState, + opts: IFormatWorklistLinesOptions = {} +): string[] { + const maxPending = opts.maxPending ?? 3; + const total = state.features.length; + + if (total === 0) { + return ["worklist", "/work to start"]; + } + + const done = state.features.filter((f) => f.passes).length; + const current = state.features.find((f) => !f.passes && !(f.parked ?? false)); + const pending = state.features.filter( + (f) => !f.passes && !(f.parked ?? false) && f.id !== current?.id + ); + const parked = state.features.filter((f) => (f.parked ?? false) && !f.passes); + + const lines: string[] = [`worklist ${done}/${total}`]; + + if (current !== undefined) { + lines.push(`${boxOf(current, true)} ${current.desc}`); + } else if (done === total) { + lines.push("All done."); + } else if (parked.length > 0) { + lines.push(`Parked ${parked.length} — revisit`); + } + + for (const feature of pending.slice(0, maxPending)) { + lines.push(`${boxOf(feature, false)} ${feature.desc}`); + } + + if (pending.length > maxPending) { + lines.push(`… +${pending.length - maxPending} more`); + } + + if (opts.showSelection === true && opts.selectedIndex !== undefined) { + const idx = opts.selectedIndex; + + return lines.map((line, i) => (i === idx ? `▸ ${line}` : ` ${line}`)); + } + + 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..3b573434 --- /dev/null +++ b/packages/core/src/loop/worklist/parse.ts @@ -0,0 +1,277 @@ +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"; + +const DEFAULT_LOOKUP = ["PLAN.md", "TASKS.md", ".specs/next.md"] as const; + +const CHECKBOX_RE = /^(\s*)[-*]\s+\[([ xX])\]\s+(.+)$/; +const NUMBERED_RE = /^(\d+)\.\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; + } + + 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 → .specs/next.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/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/render/agent-rail.ts b/packages/core/src/render/agent-rail.ts index f5e73efc..56c29c82 100644 --- a/packages/core/src/render/agent-rail.ts +++ b/packages/core/src/render/agent-rail.ts @@ -1,35 +1,49 @@ -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; +} + +/** 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 +62,231 @@ 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 } => { + 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) { + if (rest.length > 0) { + rest += cluster; + } else { + head += cluster; + } + + if (cluster === "m") { + esc = false; + } + + continue; + } + + if (cluster === "\x1b") { + esc = true; + + if (rest.length > 0) { + rest += cluster; + } else { + head += cluster; + } + + 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 }; + }; + + 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; + }; + 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 (cluster === "\n") { + out = flushWord(out, wrapAt); + pendingSpace = false; + if (atStart) { if (seen) { - out += `${rail}\n`; // interior blank line keeps the rail + // 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. + if (rightRail.length > 0) { + const inner = + Math.max(0, displayWidth(stripSgr(rail)) - 1) + wrapAt; + + out += `${paint(`│${" ".repeat(inner)}│`, emptyRowFg, true)}\n`; + } else { + out = ensureRail(out); + out = closeLine(out, wrapAt); + } + + atStart = true; } - // else: swallow the leading blank (no gap under the card cap) } else { - out += "\n"; + out = closeLine(out, wrapAt); atStart = true; } - col = 0; + lineCol = 0; 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; + if (cluster === " ") { + out = flushWord(out, wrapAt); + pendingSpace = true; + + continue; } - out += ch; - col += displayWidth(ch); + 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); + + // 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..a6febcc4 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,196 @@ 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"); +} + +/** @deprecated Use {@link roleCardCols}. */ +function agentCols(columns?: number): number { + return roleCardCols(columns); +} + +/** One closed agent row: `│ content… │` padded to `cols`. */ +export function agentCardRow( + content: string, + color: boolean, + columns: number +): string { + const cols = agentCols(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 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); +/** Empty closed row — vertical breathing room under the top rule / above the bottom. */ +export function agentCardPadRow(color: boolean, columns?: number): string { + const cols = agentCols(columns); + const inner = Math.max(1, cols - 2); + + return paint(`│${" ".repeat(inner)}│`, STYLE.chrome, color); } -/** The rounded bottom cap that closes an AGENT card. */ -export function agentCardBottom(color: boolean): string { - return paint("╰", STYLE.brandLight, 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)); } -/** The left-rail prefix (`│ `) painted for every row inside an AGENT card. */ +/** Closed AGENT card bottom rule. */ +export function agentCardBottom(color: boolean, columns?: number): string { + const cols = agentCols(columns); + + return paint(`└${"─".repeat(Math.max(0, cols - 2))}┘`, STYLE.chrome, color); +} + +/** 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, agentCols(columns) - ROLE_BOX_CHROME_COLS); } /** Rail-prefix AND soft-wrap a settled agent body (the `--continue` replay @@ -217,10 +371,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 = agentCols(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 +412,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..b080520d 100644 --- a/packages/core/src/render/command-menu.ts +++ b/packages/core/src/render/command-menu.ts @@ -46,6 +46,8 @@ 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; } /** @@ -79,7 +81,12 @@ export function pickCommand(view: IPaletteView): Promise { selected = clampIndex(selected, items.length); - const columns = process.stdout.columns > 0 ? process.stdout.columns : 80; + const columns = + view.columns !== undefined && view.columns > 0 + ? view.columns + : process.stdout.columns > 0 + ? process.stdout.columns + : 80; const 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). diff --git a/packages/core/src/render/file-menu.ts b/packages/core/src/render/file-menu.ts index 4cf632aa..eed5b955 100644 --- a/packages/core/src/render/file-menu.ts +++ b/packages/core/src/render/file-menu.ts @@ -99,10 +99,10 @@ export function formatCompletionRows( return items.map((path, i) => { const active = i === selected; - const gutter = active ? paint("›", STYLE.brand, color) : " "; + const gutter = active ? paint("›", STYLE.cyan, color) : " "; const text = truncatePath(path, Math.max(0, columns - 2)); - return `${gutter} ${paint(text, active ? STYLE.brand : STYLE.dim, color)}`; + return `${gutter} ${paint(text, active ? STYLE.cyan : 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..2ea9e97a --- /dev/null +++ b/packages/core/src/render/frame/chrome.ts @@ -0,0 +1,399 @@ +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; +} + +/** @deprecated Folded into {@link formatConsoleTitle}. */ +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; +} + +/** @deprecated Rail count lives in the top strip as `#n/m`. */ +export function formatRailHeader(opts: IRailHeaderOpts): string { + const color = opts.color ?? true; + const label = + opts.total > 0 + ? paint(`#${String(opts.done)}/${String(opts.total)}`, CONSOLE.bright, color) + : paint("#0/0", CONSOLE.muted, color); + + return insetX(label, opts.cols); +} + +/** 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..d1071fbe --- /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 panel (0 = header). */ + 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..de55d5ae --- /dev/null +++ b/packages/core/src/render/frame/index.ts @@ -0,0 +1,116 @@ +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, +} from "./outer-frame"; +export type { IOuterInsets, IOuterFrameOpts } from "./outer-frame"; +export { + computeLayout, + canUsePaneTui, + clampInputInnerRows, + inputBandRows, + PANE_MIN_ROWS, + PANE_SPLIT_MIN_COLS, + PANEL_WIDTH, + 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, + 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..6e06a2b2 --- /dev/null +++ b/packages/core/src/render/frame/input-box.ts @@ -0,0 +1,155 @@ +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..5f4da80f --- /dev/null +++ b/packages/core/src/render/frame/layout.ts @@ -0,0 +1,213 @@ +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; + +/** Side panel width when split. */ +export const PANEL_WIDTH = 28; + +/** + * 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 + +/** @deprecated 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; +/** @deprecated Box has no air pad above. */ +export const INPUT_PAD_TOP_ROWS = 0; +/** @deprecated Prefer BOTTOM_PAD_ROWS (below the whole input band). */ +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; + +/** @deprecated Alias of INPUT_BOX_TOP_ROWS. */ +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; + /** + * @deprecated 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; +} + +function resolveInputBandRows(opts: IComputeLayoutOpts): 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 split = + wantPanel && + opts.cols >= PANE_SPLIT_MIN_COLS && + opts.cols - PANEL_WIDTH >= 24; + // 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 - PANEL_WIDTH - 1; // 1-col gutter + + return { + top, + main: { row: topRows, col: 0, rows: bodyRows, cols: mainCols }, + panel: { + row: topRows, + col: mainCols + 1, + rows: spineRows, + cols: PANEL_WIDTH, + }, + 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..1dcd6f5e --- /dev/null +++ b/packages/core/src/render/frame/outer-frame.ts @@ -0,0 +1,150 @@ +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) + ); + } + + 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); +} + +/** Stamp one content row into a full-width framed terminal line. */ +export function frameContentRow( + contentLine: string, + termCols: number, + color = true +): string { + const { originCol, contentCols } = outerInsets(OUTER_CHROME * 2 + 1, termCols); + const rule = isFullBleedRule(contentLine); + const left = paint(rule ? "├" : "│", STYLE.chrome, color); + const right = paint(rule ? "┤" : "│", 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 ?? "┴"; + let mid: string; + + if ( + splitCol !== undefined && + splitCol >= 0 && + splitCol < contentCols && + contentCols > 0 + ) { + mid = + "─".repeat(splitCol) + + glyph + + "─".repeat(Math.max(0, contentCols - splitCol - 1)); + } else { + mid = "─".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..aa285385 --- /dev/null +++ b/packages/core/src/render/frame/pane-screen.ts @@ -0,0 +1,1056 @@ +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, + insetInnerCols, + insetX, +} 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, + BODY_HEADER_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 { STYLE, 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 = "│"; +const EMPTY_PANEL_LINES = ["—", "/work"] 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 = ""; + 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(); + } + + resize(rows: number, cols: number): void { + const nextRows = Math.max(1, rows); + const nextCols = Math.max(1, cols); + const geomChanged = nextRows !== this.rows || nextCols !== this.cols; + + 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; + } + + this.paint(); + } + + appendMain(text: string): void { + this.scrollback.append(text); + + if (this.entered) { + 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 === "/work to start" || + head === "/work" + ) { + return false; + } + + // Live header is `worklist N/M` (or TASK RAIL 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; + } + + this.lastBadge = badge; + this.worklistBadge = badge; + + 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 ?? ""; + + if (this.entered) { + this.prevLines = null; + this.paint(); + } + } + + setStatus(info: IStatusInfo): 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") + : ""; + + if ( + nextTop === this.lastTopLine && + this.prevLines !== null && + this.flashHeaderPaints === 0 + ) { + return; + } + + // Status ticks are the moment stray relative writes (absolute CSI, etc.) + // most often land in empty main rows. Differential paint would skip those + // rows forever — force a full frame so ghosts cannot stack above the input. + this.prevLines = null; + 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(); + } + + /** 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 panelSource = this.panelPaintLines(); + const panelStart = layout.main.rows; + 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, + insetX(panelSource[panelStart + i] ?? "", layout.panel.cols) + ) + : mainLine; + const line = frameContentRow(content, this.cols); + 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); + } + } + + 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; + } + + private clampPanelOffset(): void { + const max = Math.max(0, this.panelSourceLen() - this.bodyViewportRows); + + 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); + } + + 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 bodyHeader = Math.min(BODY_HEADER_ROWS, layout.main.rows); + const bodyBudget = Math.max(0, layout.main.rows - bodyHeader); + const bodyGap = bodyBudget >= BODY_GAP_ROWS + 2 ? BODY_GAP_ROWS : 0; + const chromeAll = [...this.agentTreeLines, ...this.overlayLines]; + const scrollBudget = Math.max(0, bodyBudget - 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, + bodyHeader, + bodyGap, + mainRows, + chromeRows, + chrome: chromeAll.slice(chromeAll.length - 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 }; + }; + bodyHeader: 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 panelSource = this.panelPaintLines(); + // Same ink as horizontal hairlines — dim SGR reads as a different grey. + const gutter = paint( + GUTTER, + 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 + opts.bodyHeader; + + 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("", layout.panel.cols) : null + ); + } + + const bodyStart = gapStart + opts.bodyGap; + const mainCols = layout.panel !== null ? layout.main.cols : contentCols; + // Grok-style overflow track in the right inset pad (no wrap-width steal). + const scrollbar = formatScrollbarColumn( + this.scrollback.metrics(), + opts.mainRows, + true + ); + + for (let r = 0; r < opts.mainRows; r += 1) { + const idx = bodyStart + r; + let main = insetX(mainVisible[r] ?? "", mainCols); + // Track cells are blank (same as the inset pad) — only stamp the thumb. + const thumb = scrollbar?.[r]; + + if (thumb !== undefined && thumb !== " ") { + main = overlayScrollbarCol(main, mainCols, thumb); + } + + // Each slot is hard-clamped to its column budget — content must never + // overwrite the panel gutter or bleed past the main pane. + screen[idx] = + layout.panel !== null + ? paintSplitRow( + main, + gutter, + insetX(panelSource[r] ?? "", layout.panel.cols) + ) + : main; + } + + // Overlay / agent-tree chrome shares the main column — never full-bleed + // across the panel gutter (menus used to punch through the side rail). + for (let i = 0; i < opts.chromeRows; i += 1) { + const r = opts.mainRows + i; + const idx = bodyStart + r; + + screen[idx] = + layout.panel !== null + ? paintSplitRow( + insetX(opts.chrome[i] ?? "", layout.main.cols), + gutter, + insetX(panelSource[r] ?? "", layout.panel.cols) + ) + : insetX(opts.chrome[i] ?? "", contentCols); + } + + // Input + bottom air keep the panel gutter spine (┬ → │ → bottom). + const inputMainCols = layout.panel !== null ? layout.main.cols : 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] = + layout.panel !== null + ? paintSplitRow( + mainLine, + gutter, + insetX(panelSource[spineBase + i] ?? "", layout.panel.cols) + ) + : mainLine; + } + + for (let i = 0; i < layout.footer.rows; i += 1) { + const idx = layout.footer.row + i; + const spineIdx = spineBase + layout.input.rows + i; + + screen[idx] = + layout.panel !== null + ? paintSplitRow( + fitAnsiLine("", layout.main.cols), + gutter, + insetX(panelSource[spineIdx] ?? "", layout.panel.cols) + ) + : fitAnsiLine("", contentCols); + } + + for (let r = 0; r < contentRows; r += 1) { + screen[r] ??= fitAnsiLine("", contentCols); + } + + return screen; + } + + 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 + ); + } + + private panelPaintLines(): string[] { + const raw = this.panelBodyLines(); + const view = Math.max(1, this.bodyViewportRows); + const slice = raw.slice(this.panelOffset, this.panelOffset + view); + + if (!this.focus.panelFocused) { + return slice.map((l) => paint(l, STYLE.dim, true)); + } + + return slice.map((line, i) => { + const abs = this.panelOffset + i; + + return abs === this.focus.selection + ? paint(`▸ ${stripSgr(line)}`, CONSOLE.bright, true) + : ` ${line}`; + }); + } + + /** Rail body lines — skip the legacy `worklist N/M` header (TASK RAIL 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}`; +} + +/** + * 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..72a7a884 --- /dev/null +++ b/packages/core/src/render/frame/scrollback.ts @@ -0,0 +1,374 @@ +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[] { + if (this.cachedWrapped === null) { + 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..2b387bc5 --- /dev/null +++ b/packages/core/src/render/frame/wrap-line.ts @@ -0,0 +1,257 @@ +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 = /^(│ |│ |▌ |▌ |\| |\| )/; + +/** + * 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, + }; +} + +/** 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); + } + + 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); + } + + cur = rest; + } + + 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..2ebcb2e2 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, @@ -36,3 +45,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..6a178021 100644 --- a/packages/core/src/render/inline-menu.ts +++ b/packages/core/src/render/inline-menu.ts @@ -36,9 +36,9 @@ function clip(text: string, max: number): string { } /** 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. */ + * line (cyan + bold — matches console interactive accent); 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, @@ -65,7 +65,7 @@ function formatRow( const raw = `${active ? "›" : " "} ${body}`; - return active ? paint(raw, `${STYLE.brand}${STYLE.bold}`, color) : raw; + return active ? paint(raw, `${STYLE.cyan}${STYLE.bold}`, color) : raw; } /** Menu row data — flat list, no groups (cursor index == row index). */ @@ -112,8 +112,7 @@ export function formatMenuRows( 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). + // Title: bold header at the TOP (default ink — only the selected row is cyan). lines.push(paint(clip(title, width), STYLE.bold, color)); if (rows.length === 0) { @@ -177,6 +176,8 @@ 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; } /** @@ -199,7 +200,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); 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..cddf322e 100644 --- a/packages/core/src/render/wizard.ts +++ b/packages/core/src/render/wizard.ts @@ -6,6 +6,7 @@ import type { IWizardOption, IWizardState, IWizardStep, + IWizardView, } from "./wizard.types"; const ESC = String.fromCharCode(27); @@ -442,8 +443,8 @@ function optionRow( marker: string, color: boolean ): string { - const gutter = active ? paint("›", STYLE.brand, color) : " "; - const label = paint(opt.label, active ? STYLE.brand : STYLE.bold, color); + const gutter = active ? paint("›", STYLE.cyan, color) : " "; + const label = paint(opt.label, active ? STYLE.cyan : STYLE.bold, color); const rec = opt.recommended === true ? ` ${paint("recommended", STYLE.dim, color)}` @@ -500,7 +501,7 @@ function textFieldRows( : step.mask === true ? "•".repeat(raw.length) : raw; - const field = `${shown}${paint("▏", STYLE.brand, color)}`; + const field = `${shown}${paint("▏", STYLE.cyan, color)}`; const error = step.validate === undefined ? null : step.validate(raw); const errorLine = error === null ? [] : ["", paint(error, STYLE.yellow, color)]; @@ -699,6 +700,10 @@ 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; } /** @@ -734,6 +739,7 @@ export function runWizard( return new Promise((resolve) => { let state = initWizard(steps); + const view = opts.view; emitKeypressEvents(stdin); @@ -763,9 +769,13 @@ 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); + + if (view !== undefined) { + view.render(frame.split("\n")); + } else { + out(`${CLEAR_HOME}${frame}`); + } }; const finish = (): void => { @@ -783,7 +793,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 +855,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/setup/run-setup.ts b/packages/core/src/setup/run-setup.ts index 4f910e28..1f83be16 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,9 @@ 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 / status bar). When set, the wizard skips + * its nested alt-screen and paints through the REPL chrome instead. */ + readonly view?: IWizardView; } const SAFETY_NOTE = @@ -113,6 +117,8 @@ 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 }), 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..bf4862e2 100644 --- a/packages/core/tests/agent-rail.test.ts +++ b/packages/core/tests/agent-rail.test.ts @@ -18,6 +18,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 +66,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(/\x1b\[[0-9;]*m/g, ""); + + 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(/\x1b\[[0-9;]*m/g, ""); + + // 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/chrome.test.ts b/packages/core/tests/chrome.test.ts new file mode 100644 index 00000000..667ffcaa --- /dev/null +++ b/packages/core/tests/chrome.test.ts @@ -0,0 +1,214 @@ +import { test, expect, describe } from "bun:test"; +import { + formatHints, + formatTopStatus, + formatConsoleTopbar, + formatConsoleTitle, + hairline, + insetX, + CHROME_PAD_X, + CHROME_PAD_Y, +} from "../src/render/frame/chrome"; +import { STYLE } from "../src/render/style"; + +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("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..a0be32a1 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"; @@ -493,6 +495,51 @@ test("cliUsage documents the print-and-exit flags it is reached by", () => { 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/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..883e6ab0 --- /dev/null +++ b/packages/core/tests/frame-tui.test.ts @@ -0,0 +1,1166 @@ +import { test, expect, describe } from "bun:test"; +import { + blankFrame, + writeRect, + freezeFrame, + diffFrames, + cloneFrame, + Scrollback, + computeLayout, + canUsePaneTui, + PaneScreen, + ENTER_ALT, + EXIT_ALT, + BEGIN_SYNC, + END_SYNC, + CLEAR_SCREEN, + PANE_MIN_ROWS, + BOTTOM_CHROME_ROWS, + INPUT_BAND_ROWS, + CHROME_PAD_X, + inputCursorCol, + outerInsets, + OUTER_MARGIN, + TOP_PAD_ROWS, + BOTTOM_PAD_ROWS, +} from "../src/render/frame"; +import { formatStatusBarLine } from "../src/render/status-bar"; +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.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("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, + }); + + let 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("│"); + expect(screen.text()).toContain("/work"); + 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("╮"); + + expect(topRight).toBeGreaterThan(0); + expect(midPlain[topRight]).toBe("│"); + expect(botPlain[topRight]).toBe("╯"); + + // Hairline ┬ → gutter │ → outer ┴ closes the panel spine. + const rule = screen.row(title + 2); + const gutterIdx = rule.indexOf("┬"); + const outerBot = screen.row(outerBottomRow(24)); + + expect(gutterIdx).toBeGreaterThan(0); + 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(["/work"]); + + 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 — never a content "W". + expect(row[gutterCol]).toBe("│"); + // 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); + + if (!row.includes("─") && !row.includes("/help")) { + continue; + } + + sawOverlay = true; + expect(row[gutterCol]).toBe("│"); + // Panel side still shows worklist content, not overlay dashes. + const panelSlice = row.slice(gutterCol + 1); + expect(panelSlice.includes("─".repeat(10))).toBe(false); + } + + expect(sawOverlay).toBe(true); + expect(screen.text()).toContain("item-a"); + }); + + 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 full-repaint clears ghost metrics rows above the input", () => { + const term = new FakeTerm(); + const panes = new PaneScreen(term, 24, 100); + + panes.enter(); + panes.appendMain("chat line\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", + }); + + // Stray absolute writes into empty main rows (relative + // redraw fighting the alt screen) — differential paint used to leave them. + for (let i = 0; i < 6; i += 1) { + const line = formatStatusBarLine( + { + model: "deepseek", + contextTokens: 0, + contextWindow: 100, + turns: 1, + elapsedMs: 1100, + status: "working", + scope: "entire workspace", + mode: "plan", + tokensPerSecond: 47, + activity: `⠋ thinking · ${String(i)}s`, + }, + 100, + true + ); + + term.write(`\x1b[${String(12 + i)};1H${line}`); + } + + panes.setStatus({ + model: "deepseek", + contextTokens: 0, + contextWindow: 100, + turns: 1, + elapsedMs: 1100, + status: "responded", + scope: "entire workspace", + mode: "plan", + tokensPerSecond: 44, + }); + + const screen = new VirtualScreen(24, 100); + + screen.feed(term.text()); + const thinking = screen.text().match(/thinking/g) ?? []; + + expect(thinking.length).toBe(0); + expect(screen.row(expectedPromptRow(24))).toContain("describe a task"); + expect(screen.row(titleRow(24))).toContain("✓"); + expect(screen.text()).toContain("chat line"); + }); + + 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("/work"); + }); + + 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/message-render.test.ts b/packages/core/tests/message-render.test.ts index 9c95d166..ff8e36f6 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(/│ 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,10 +44,15 @@ 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(/│ line one\s+│/); + expect(out).toMatch(/│ line two\s+│/); + expect(out).not.toContain("╭"); + expect(out).not.toContain("╰"); }); test("system and tool messages render nothing", () => { @@ -42,6 +61,46 @@ describe("renderMessage — hybrid bubbles", () => { }); }); +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", () => { test("wraps long content so no row exceeds the terminal width", () => { const long = @@ -53,16 +112,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(/│ 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(/│ 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 +183,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/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..e4f40c47 --- /dev/null +++ b/packages/core/tests/outer-frame.test.ts @@ -0,0 +1,106 @@ +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("│"); + }); +}); 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/scrollbar.test.ts b/packages/core/tests/scrollbar.test.ts new file mode 100644 index 00000000..dc96dc21 --- /dev/null +++ b/packages/core/tests/scrollbar.test.ts @@ -0,0 +1,122 @@ +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!.some((c) => c === " ")).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/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/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..19f4e0fd 100644 --- a/packages/core/tests/wizard.test.ts +++ b/packages/core/tests/wizard.test.ts @@ -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..4d4a91e4 --- /dev/null +++ b/packages/core/tests/worklist-panel.test.ts @@ -0,0 +1,72 @@ +import { test, expect, describe } from "bun:test"; +import { formatWorklistLines, worklistBadge } from "../src/loop/worklist/panel"; +import type { IGreenfieldState } from "../src/loop/greenfield"; + +function state(features: IGreenfieldState["features"]): IGreenfieldState { + return { goal: "g", features }; +} + +describe("formatWorklistLines", () => { + test("shows count, current item with [>], and pending", () => { + const lines = formatWorklistLines( + state([ + { id: "a", desc: "First", passes: true, attempts: 1 }, + { id: "b", desc: "Second", passes: false, attempts: 0 }, + { id: "c", desc: "Third", passes: false, attempts: 0 }, + { id: "d", desc: "Fourth", passes: false, attempts: 0 }, + ]), + { maxPending: 2 } + ); + + expect(lines[0]).toBe("worklist 1/4"); + expect(lines[1]).toBe("[>] Second"); + expect(lines[2]).toBe("[ ] Third"); + expect(lines[3]).toBe("[ ] Fourth"); + }); + + test("empty state points at /work", () => { + expect(formatWorklistLines(state([]))).toEqual([ + "worklist", + "/work to start", + ]); + }); + + test("all done and parked-only copy", () => { + expect( + formatWorklistLines( + state([{ id: "a", desc: "A", passes: true, attempts: 1 }]) + )[1] + ).toBe("All done."); + + const parked = formatWorklistLines( + state([ + { id: "a", desc: "A", passes: true, attempts: 1 }, + { id: "b", desc: "B", passes: false, attempts: 2, parked: true }, + ]) + ); + + expect(parked[1]).toBe("Parked 1 — revisit"); + }); + + test("selection prefix when focused", () => { + const lines = formatWorklistLines( + state([{ id: "a", desc: "A", passes: false, attempts: 0 }]), + { showSelection: true, selectedIndex: 1 } + ); + + expect(lines[1]?.startsWith("▸ ")).toBe(true); + expect(lines[0]?.startsWith(" ")).toBe(true); + }); + + test("worklistBadge is done/total", () => { + expect( + worklistBadge( + state([ + { id: "a", desc: "A", passes: true, attempts: 1 }, + { id: "b", desc: "B", passes: false, attempts: 0 }, + ]) + ) + ).toBe("1/2"); + expect(worklistBadge(state([]))).toBe(""); + }); +}); diff --git a/packages/core/tests/worklist-parse.test.ts b/packages/core/tests/worklist-parse.test.ts new file mode 100644 index 00000000..532bcdb3 --- /dev/null +++ b/packages/core/tests/worklist-parse.test.ts @@ -0,0 +1,169 @@ +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"); + }); +}); + +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, then .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)).toBe(join(dir, ".specs", "next.md")); + + 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/wrap-line.test.ts b/packages/core/tests/wrap-line.test.ts new file mode 100644 index 00000000..5629169e --- /dev/null +++ b/packages/core/tests/wrap-line.test.ts @@ -0,0 +1,122 @@ +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-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()) From 4b0380d6233f23d8ae35c8ce56cbacb087bc07dd Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 8 Aug 2026 23:52:49 +0200 Subject: [PATCH 2/8] feat(tui): sticky Tasks rail title with joined borders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin a Tasks header and cyan done/total in the side panel, and join the under-rule with ├/┤ so the title cell no longer floats. --- packages/core/src/render/frame/chrome.ts | 32 +++++-- packages/core/src/render/frame/focus.ts | 2 +- packages/core/src/render/frame/index.ts | 10 ++- packages/core/src/render/frame/outer-frame.ts | 52 +++++++++-- packages/core/src/render/frame/pane-screen.ts | 87 +++++++++++++++---- packages/core/tests/chrome.test.ts | 43 +++++++++ packages/core/tests/frame-tui.test.ts | 37 +++++--- packages/core/tests/outer-frame.test.ts | 27 ++++++ 8 files changed, 250 insertions(+), 40 deletions(-) diff --git a/packages/core/src/render/frame/chrome.ts b/packages/core/src/render/frame/chrome.ts index 2ea9e97a..6dcbbd03 100644 --- a/packages/core/src/render/frame/chrome.ts +++ b/packages/core/src/render/frame/chrome.ts @@ -221,17 +221,39 @@ export interface IRailHeaderOpts { readonly total: number; readonly cols: number; readonly color?: boolean; + /** Left label — default `Tasks`. */ + readonly title?: string; } -/** @deprecated Rail count lives in the top strip as `#n/m`. */ +/** 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 label = + const label = (opts.title ?? "Tasks").trim() || "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(`#${String(opts.done)}/${String(opts.total)}`, CONSOLE.bright, color) - : paint("#0/0", CONSOLE.muted, color); + ? 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); +} - return insetX(label, opts.cols); +/** 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). */ diff --git a/packages/core/src/render/frame/focus.ts b/packages/core/src/render/frame/focus.ts index d1071fbe..290e6fb5 100644 --- a/packages/core/src/render/frame/focus.ts +++ b/packages/core/src/render/frame/focus.ts @@ -14,7 +14,7 @@ export type FocusAction = "changed" | "ignored"; export class PaneFocus { panel: PanelVis = "hidden"; active: ActiveSurface = "prompt"; - /** Selected row index within the worklist panel (0 = header). */ + /** Selected row index within the worklist body (0 = first item; title is sticky). */ selection = 0; get promptFocused(): boolean { diff --git a/packages/core/src/render/frame/index.ts b/packages/core/src/render/frame/index.ts index de55d5ae..e49d1c9b 100644 --- a/packages/core/src/render/frame/index.ts +++ b/packages/core/src/render/frame/index.ts @@ -48,8 +48,13 @@ export { wrapOuterFrame, frameContentRow, isFullBleedRule, + isPanelRuleRow, +} from "./outer-frame"; +export type { + IOuterInsets, + IOuterFrameOpts, + IFrameContentRowOpts, } from "./outer-frame"; -export type { IOuterInsets, IOuterFrameOpts } from "./outer-frame"; export { computeLayout, canUsePaneTui, @@ -83,6 +88,9 @@ export { formatConsoleTitle, formatMainHeader, formatRailHeader, + formatRailTitleRule, + formatRailTitleBlock, + RAIL_TITLE_ROWS, hairline, insetX, insetInnerCols, diff --git a/packages/core/src/render/frame/outer-frame.ts b/packages/core/src/render/frame/outer-frame.ts index 1dcd6f5e..62b7b3a9 100644 --- a/packages/core/src/render/frame/outer-frame.ts +++ b/packages/core/src/render/frame/outer-frame.ts @@ -75,7 +75,10 @@ export function wrapOuterFrame( } screen.push( - frameContentRow(content[r - originRow] ?? "", termCols, color) + frameContentRow(content[r - originRow] ?? "", termCols, { + color, + splitCol: options.splitCol, + }) ); } @@ -97,16 +100,55 @@ export function isFullBleedRule(contentLine: string): boolean { 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, - color = true + 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 rule = isFullBleedRule(contentLine); - const left = paint(rule ? "├" : "│", STYLE.chrome, color); - const right = paint(rule ? "┤" : "│", STYLE.chrome, color); + 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) + diff --git a/packages/core/src/render/frame/pane-screen.ts b/packages/core/src/render/frame/pane-screen.ts index aa285385..62bc85b6 100644 --- a/packages/core/src/render/frame/pane-screen.ts +++ b/packages/core/src/render/frame/pane-screen.ts @@ -20,8 +20,10 @@ import { CHROME_PAD_X, CONSOLE, formatConsoleTopbar, + formatRailTitleBlock, insetInnerCols, insetX, + RAIL_TITLE_ROWS, } from "./chrome"; import { CursorState } from "./cursor-state"; import { fitAnsiLine } from "./fit-line"; @@ -290,7 +292,7 @@ export class PaneScreen { return false; } - // Live header is `worklist N/M` (or TASK RAIL counts via badge). + // Live header is `worklist N/M` (Tasks title reads counts via badge). return /^worklist\s+\d+\/\d+/.test(head) || /^\d+\/\d+$/.test(head); } @@ -571,8 +573,13 @@ export class PaneScreen { 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.bodyViewportRows); + const max = Math.max(0, this.panelSourceLen() - this.panelBodyViewRows()); if (this.panelOffset > max) { this.panelOffset = max; @@ -735,7 +742,9 @@ export class PaneScreen { ); const screen: string[] = new Array(contentRows); const mainVisible = this.scrollback.visible(); - const panelSource = this.panelPaintLines(); + const panelCols = layout.panel?.cols ?? 0; + const panelSource = + layout.panel !== null ? this.panelPaintLines(panelCols) : []; // Same ink as horizontal hairlines — dim SGR reads as a different grey. const gutter = paint( GUTTER, @@ -779,15 +788,25 @@ export class PaneScreen { main = overlayScrollbarCol(main, mainCols, thumb); } + const panelCell = + layout.panel !== null + ? fitPanelCell(panelSource[r] ?? "", layout.panel.cols) + : null; + // Under-rule under Tasks: `├` joins the gutter spine to the panel ─. + const splitGutter = + panelCell !== null && isRailUnderRule(panelCell) + ? paint( + "├", + this.focus.panelFocused ? CONSOLE.bright : CONSOLE.rule, + true + ) + : gutter; + // Each slot is hard-clamped to its column budget — content must never // overwrite the panel gutter or bleed past the main pane. screen[idx] = layout.panel !== null - ? paintSplitRow( - main, - gutter, - insetX(panelSource[r] ?? "", layout.panel.cols) - ) + ? paintSplitRow(main, splitGutter, panelCell) : main; } @@ -802,7 +821,7 @@ export class PaneScreen { ? paintSplitRow( insetX(opts.chrome[i] ?? "", layout.main.cols), gutter, - insetX(panelSource[r] ?? "", layout.panel.cols) + fitPanelCell(panelSource[r] ?? "", layout.panel.cols) ) : insetX(opts.chrome[i] ?? "", contentCols); } @@ -822,7 +841,7 @@ export class PaneScreen { ? paintSplitRow( mainLine, gutter, - insetX(panelSource[spineBase + i] ?? "", layout.panel.cols) + fitPanelCell(panelSource[spineBase + i] ?? "", layout.panel.cols) ) : mainLine; } @@ -836,7 +855,7 @@ export class PaneScreen { ? paintSplitRow( fitAnsiLine("", layout.main.cols), gutter, - insetX(panelSource[spineIdx] ?? "", layout.panel.cols) + fitPanelCell(panelSource[spineIdx] ?? "", layout.panel.cols) ) : fitAnsiLine("", contentCols); } @@ -909,25 +928,38 @@ export class PaneScreen { ); } - private panelPaintLines(): string[] { + /** + * 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 = Math.max(1, this.bodyViewportRows); + const view = this.panelBodyViewRows(); const slice = raw.slice(this.panelOffset, this.panelOffset + view); if (!this.focus.panelFocused) { - return slice.map((l) => paint(l, STYLE.dim, true)); + return [...title, ...slice.map((l) => paint(l, STYLE.dim, true))]; } - return slice.map((line, i) => { + const body = slice.map((line, i) => { const abs = this.panelOffset + i; return abs === this.focus.selection ? paint(`▸ ${stripSgr(line)}`, CONSOLE.bright, true) : ` ${line}`; }); + + return [...title, ...body]; } - /** Rail body lines — skip the legacy `worklist N/M` header (TASK RAIL owns it). */ + /** 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]; @@ -1028,6 +1060,29 @@ function paintSplitRow( 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); +} + /** * 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 diff --git a/packages/core/tests/chrome.test.ts b/packages/core/tests/chrome.test.ts index 667ffcaa..1c3e2588 100644 --- a/packages/core/tests/chrome.test.ts +++ b/packages/core/tests/chrome.test.ts @@ -4,12 +4,16 @@ import { 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", () => { @@ -128,6 +132,45 @@ describe("formatConsoleTopbar", () => { }); }); +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 }); diff --git a/packages/core/tests/frame-tui.test.ts b/packages/core/tests/frame-tui.test.ts index 883e6ab0..fdfcbab5 100644 --- a/packages/core/tests/frame-tui.test.ts +++ b/packages/core/tests/frame-tui.test.ts @@ -506,6 +506,21 @@ describe("PaneScreen", () => { 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("/work"); expect(screen.row(promptBoxTop(24))).toContain("╭"); expect(screen.row(expectedPromptRow(24))).toContain(">"); @@ -517,17 +532,11 @@ describe("PaneScreen", () => { 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("╯"); - - // Hairline ┬ → gutter │ → outer ┴ closes the panel spine. - const rule = screen.row(title + 2); - const gutterIdx = rule.indexOf("┬"); - const outerBot = screen.row(outerBottomRow(24)); - - expect(gutterIdx).toBeGreaterThan(0); expect(rule.slice(gutterIdx + 1).includes("─")).toBe(true); expect(midPlain[gutterIdx]).toBe("│"); expect(outerBot[gutterIdx]).toBe("┴"); @@ -721,8 +730,8 @@ describe("PaneScreen", () => { } sawContentRow = true; - // Panel gutter column stays a gutter glyph — never a content "W". - expect(row[gutterCol]).toBe("│"); + // Panel gutter column stays a gutter glyph (│ spine or ├ under-rule) — never "W". + expect(["│", "├"]).toContain(row[gutterCol]); // First panel cell is not overflowing main content. expect(row[gutterCol + 1]).not.toBe("W"); } @@ -777,19 +786,23 @@ describe("PaneScreen", () => { for (let r = mainTop; r <= mainBot; r += 1) { const row = screen.row(r); + const mainSlice = row.slice(0, gutterCol); - if (!row.includes("─") && !row.includes("/help")) { + // 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("│"); - // Panel side still shows worklist content, not overlay dashes. + // 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(10))).toBe(false); + + expect(panelSlice.includes("─".repeat(40))).toBe(false); } expect(sawOverlay).toBe(true); + expect(screen.text()).toContain("Tasks"); expect(screen.text()).toContain("item-a"); }); diff --git a/packages/core/tests/outer-frame.test.ts b/packages/core/tests/outer-frame.test.ts index e4f40c47..2f4d575e 100644 --- a/packages/core/tests/outer-frame.test.ts +++ b/packages/core/tests/outer-frame.test.ts @@ -103,4 +103,31 @@ describe("wrapOuterFrame", () => { // 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( + /^─+$/ + ); + }); }); From 9e5c17e8334f51579ebb6d6227a75e1863d9eb95 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sun, 9 Aug 2026 00:35:30 +0200 Subject: [PATCH 3/8] perf(tui): cut pane hot-path full-frame paints Status ticks and follow-mode stream chunks keep prevLines warm; resize settles in one paint. Pin tall overlay titles and refresh arch/e2e for the new chrome. --- packages/core/ARCHITECTURE.md | 14 +- packages/core/src/cli/args.ts | 33 +- packages/core/src/cli/repl.ts | 51 +- packages/core/src/render/agent-rail.ts | 196 ++++---- packages/core/src/render/ansi.ts | 23 +- packages/core/src/render/frame/chrome.ts | 11 +- packages/core/src/render/frame/input-box.ts | 13 +- packages/core/src/render/frame/layout.ts | 25 +- packages/core/src/render/frame/outer-frame.ts | 21 +- packages/core/src/render/frame/pane-screen.ts | 454 +++++++++++++----- packages/core/src/render/frame/scrollback.ts | 4 +- packages/core/src/render/frame/wrap-line.ts | 43 +- packages/core/tests/agent-rail.test.ts | 6 +- packages/core/tests/chrome.test.ts | 6 +- packages/core/tests/frame-tui.test.ts | 149 ++++-- packages/core/tests/message-render.test.ts | 17 +- packages/core/tests/outer-frame.test.ts | 14 +- packages/core/tests/scrollbar.test.ts | 12 +- packages/core/tests/wrap-line.test.ts | 3 +- scripts/e2e-config-repl-pty.py | 6 +- scripts/e2e-editor-pty.py | 19 +- scripts/e2e-help-menu-pty.py | 76 ++- scripts/e2e-pty.py | 19 +- scripts/e2e-scaffold-command-pty.py | 2 +- scripts/e2e-spawn-agent-pty.py | 4 +- scripts/lib/ptyharness.py | 128 +++++ 26 files changed, 921 insertions(+), 428 deletions(-) diff --git a/packages/core/ARCHITECTURE.md b/packages/core/ARCHITECTURE.md index 9041cda0..ade0932a 100644 --- a/packages/core/ARCHITECTURE.md +++ b/packages/core/ARCHITECTURE.md @@ -2,7 +2,7 @@ -Derived from `packages/core/src`: **30 subsystems**, **570 files**, **102124 lines**, **136 cross-subsystem edges**. +Derived from `packages/core/src`: **30 subsystems**, **574 files**, **104773 lines**, **136 cross-subsystem edges**. This page is the exhaustive record: every subsystem, every cross-subsystem edge, and @@ -15,8 +15,8 @@ inventory, see the hand-drawn map on [Internals](/internals/). | --- | --- | --- | --- | --- | --- | --- | | `loop` | The drive-to-green engine: turns, tools, gate settling, steering, adapters | core | 110 | 30935 | 7 | 21 | | `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 | 22 | 7240 | 2 | 19 | -| `render` | Terminal UI — status bar, menus, wizards, markdown, diffs, spinners | core | 35 | 6321 | 6 | 5 | +| `render` | Terminal UI — status bar, menus, wizards, markdown, diffs, spinners | core | 39 | 8925 | 6 | 5 | +| `cli` | Argument parsing, the interactive REPL, and per-mode wiring | core | 22 | 7279 | 2 | 19 | | `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 | @@ -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 | 545 | 2 | 5 | | `validate` | Runs the gate command and parses tool output into structured errors | core | 7 | 475 | 8 | 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,14 +56,14 @@ buries the ones someone can actually go and break. | Pair | One way | The other | | --- | --- | --- | -| `(root)` ↔ `cli` | `cli.ts:22` → `./cli/worklist-deps` | `cli/banner.ts:6` → `../session-store` | +| `(root)` ↔ `cli` | `cli.ts:22` → `./cli/worklist-deps` | `cli/config-menu.ts:8` → `../models-config` | | `(root)` ↔ `inference` | `classify.ts:1` → `./inference` | `inference/image-gen.ts:4` → `../models-config` | | `(root)` ↔ `loop` | `cli.ts:21` → `./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` | @@ -98,7 +98,7 @@ Async functions returning an exit code, declared under the CLI — the commands. | `main` | `cli.ts:871` | | `mapMode` | `cli.ts:491` | | `recipesMode` | `cli.ts:510` | -| `repl` | `cli/repl.ts:557` | +| `repl` | `cli/repl.ts:577` | | `reviewMode` | `cli.ts:191` | | `runOnce` | `cli.ts:103` | | `runTraceCommand` | `cli/repl-commands.ts:109` | diff --git a/packages/core/src/cli/args.ts b/packages/core/src/cli/args.ts index ae303c37..d112c8a1 100644 --- a/packages/core/src/cli/args.ts +++ b/packages/core/src/cli/args.ts @@ -323,37 +323,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/repl.ts b/packages/core/src/cli/repl.ts index 85490742..07931e08 100644 --- a/packages/core/src/cli/repl.ts +++ b/packages/core/src/cli/repl.ts @@ -573,6 +573,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 @@ -647,11 +665,10 @@ export async function repl(args: ICliArgs): Promise { // 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 === true && process.stdout.isTTY === true; + const interactiveTty = process.stdin.isTTY && process.stdout.isTTY; const rejectPane = paneConsoleRejectReason({ - stdinTty: process.stdin.isTTY === true, - stdoutTty: process.stdout.isTTY === true, + stdinTty: process.stdin.isTTY, + stdoutTty: process.stdout.isTTY, rows: process.stdout.rows > 0 ? process.stdout.rows : 0, }); @@ -729,19 +746,7 @@ export async function repl(args: ICliArgs): Promise { 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. @@ -1949,7 +1954,11 @@ export async function repl(args: ICliArgs): Promise { resizeTimer = setTimeout(() => { resizing = false; resizeTimer = null; - paneScreen.resize(process.stdout.rows, process.stdout.columns); + // 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. @@ -2669,9 +2678,7 @@ export async function repl(args: ICliArgs): Promise { const speaker = modelInfo(provider.config).model; for (const message of resumed.messages) { - panes.appendMain( - renderMessage(message, { color: true, speaker }) - ); + panes.appendMain(renderMessage(message, { color: true, speaker })); } } @@ -2679,7 +2686,7 @@ export async function repl(args: ICliArgs): Promise { }; if (interactiveTty) { - if (paneScreen.enter() !== true) { + if (!paneScreen.enter()) { const reason = paneConsoleRejectReason({ stdinTty: true, stdoutTty: true, diff --git a/packages/core/src/render/agent-rail.ts b/packages/core/src/render/agent-rail.ts index 56c29c82..1cd9f198 100644 --- a/packages/core/src/render/agent-rail.ts +++ b/packages/core/src/render/agent-rail.ts @@ -12,6 +12,76 @@ export interface IAgentRail { 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)) { @@ -97,67 +167,8 @@ export function makeAgentRail( const takeHard = ( 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) { - if (rest.length > 0) { - rest += cluster; - } else { - head += cluster; - } - - if (cluster === "m") { - esc = false; - } - - continue; - } - - if (cluster === "\x1b") { - esc = true; - - if (rest.length > 0) { - rest += cluster; - } else { - head += cluster; - } - - 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 }; - }; + ): { head: string; headCol: number; rest: string; restCol: number } => + takeHardBreak(word, wordCol, wrapAt); const flushWord = (out: string, wrapAt: number): string => { if (word.length === 0) { @@ -209,6 +220,41 @@ export function makeAgentRail( 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()); @@ -229,33 +275,7 @@ export function makeAgentRail( } if (cluster === "\n") { - out = 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. - if (rightRail.length > 0) { - const inner = - Math.max(0, displayWidth(stripSgr(rail)) - 1) + wrapAt; - - out += `${paint(`│${" ".repeat(inner)}│`, emptyRowFg, true)}\n`; - } else { - out = ensureRail(out); - out = closeLine(out, wrapAt); - } - - atStart = true; - } - } else { - out = closeLine(out, wrapAt); - atStart = true; - } - - lineCol = 0; - + out = onNewline(out, wrapAt); continue; } diff --git a/packages/core/src/render/ansi.ts b/packages/core/src/render/ansi.ts index a6febcc4..1b3b3e91 100644 --- a/packages/core/src/render/ansi.ts +++ b/packages/core/src/render/ansi.ts @@ -294,18 +294,13 @@ export function userBubble( return [top, padRow, body, padRow, bottom].join("\n"); } -/** @deprecated Use {@link roleCardCols}. */ -function agentCols(columns?: number): number { - return roleCardCols(columns); -} - /** One closed agent row: `│ content… │` padded to `cols`. */ export function agentCardRow( content: string, color: boolean, columns: number ): string { - const cols = agentCols(columns); + const cols = roleCardCols(columns); const inner = Math.max(1, cols - 2); if (stripSgr(content).length === 0) { @@ -317,7 +312,9 @@ export function agentCardRow( const maxText = Math.max(1, inner - ROLE_INNER_PAD * 2); const plain = stripSgr(content); const text = - displayWidth(plain) <= maxText ? content : sliceToWidth(plain, maxText).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))); @@ -326,7 +323,7 @@ export function agentCardRow( /** Empty closed row — vertical breathing room under the top rule / above the bottom. */ export function agentCardPadRow(color: boolean, columns?: number): string { - const cols = agentCols(columns); + const cols = roleCardCols(columns); const inner = Math.max(1, cols - 2); return paint(`│${" ".repeat(inner)}│`, STYLE.chrome, color); @@ -338,12 +335,14 @@ export function agentCardTop(color: boolean, columns?: number): string { 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)); + return ( + badge + roleHairline(cols, STYLE.chrome, color, "┐", roleBadgeCols(badge)) + ); } /** Closed AGENT card bottom rule. */ export function agentCardBottom(color: boolean, columns?: number): string { - const cols = agentCols(columns); + const cols = roleCardCols(columns); return paint(`└${"─".repeat(Math.max(0, cols - 2))}┘`, STYLE.chrome, color); } @@ -360,7 +359,7 @@ export function agentRight(color: boolean): string { /** Content budget inside `│ … │` (left gutter + right rail). */ export function agentRailInnerCols(columns: number): number { - return Math.max(20, agentCols(columns) - ROLE_BOX_CHROME_COLS); + return Math.max(20, roleCardCols(columns) - ROLE_BOX_CHROME_COLS); } /** Rail-prefix AND soft-wrap a settled agent body (the `--continue` replay @@ -371,7 +370,7 @@ export function agentCardBody( color: boolean, columns?: number ): string { - const cols = agentCols(columns); + const cols = roleCardCols(columns); const rail = makeAgentRail( agentBar(color), () => agentRailInnerCols(cols), diff --git a/packages/core/src/render/frame/chrome.ts b/packages/core/src/render/frame/chrome.ts index 6dcbbd03..c466833d 100644 --- a/packages/core/src/render/frame/chrome.ts +++ b/packages/core/src/render/frame/chrome.ts @@ -151,7 +151,11 @@ export function formatConsoleTitle(opts: IConsoleTitleOpts): string { } return insetX( - splitBar(leftBits.join(sep), rightBits.join(sep), insetInnerCols(opts.cols)), + splitBar( + leftBits.join(sep), + rightBits.join(sep), + insetInnerCols(opts.cols) + ), opts.cols ); } @@ -206,7 +210,7 @@ export interface IMainHeaderOpts { readonly streaming?: boolean; } -/** @deprecated Folded into {@link formatConsoleTitle}. */ +/** Folded into {@link formatConsoleTitle}; kept for older call sites. */ export function formatMainHeader(opts: IMainHeaderOpts): string { return formatConsoleTitle({ info: opts.info, @@ -235,7 +239,8 @@ export const RAIL_TITLE_ROWS = 2; */ export function formatRailHeader(opts: IRailHeaderOpts): string { const color = opts.color ?? true; - const label = (opts.title ?? "Tasks").trim() || "Tasks"; + 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 = diff --git a/packages/core/src/render/frame/input-box.ts b/packages/core/src/render/frame/input-box.ts index 6e06a2b2..74d8c33d 100644 --- a/packages/core/src/render/frame/input-box.ts +++ b/packages/core/src/render/frame/input-box.ts @@ -61,9 +61,7 @@ export function formatInputBox(opts: IInputBoxOpts): { const color = opts.color ?? true; const label = opts.label ?? ""; const rawLines = - opts.draftLines !== undefined - ? [...opts.draftLines] - : [opts.draft ?? ""]; + 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; @@ -82,7 +80,9 @@ export function formatInputBox(opts: IInputBoxOpts): { ...mid, formatInputBoxBottom(cols, label, color), ], - cursorCol: inputCursorCol(empty ? 0 : displayWidth(stripSgr(lines[0] ?? ""))), + cursorCol: inputCursorCol( + empty ? 0 : displayWidth(stripSgr(lines[0] ?? "")) + ), }; } @@ -111,7 +111,10 @@ export function formatInputBoxMid( 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 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; diff --git a/packages/core/src/render/frame/layout.ts b/packages/core/src/render/frame/layout.ts index 5f4da80f..47ae2a08 100644 --- a/packages/core/src/render/frame/layout.ts +++ b/packages/core/src/render/frame/layout.ts @@ -34,7 +34,7 @@ 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 -/** @deprecated Body headers folded into the top strip — always 0. */ +/** 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; @@ -50,14 +50,17 @@ export const INPUT_INNER_ROWS = 1; export const INPUT_INNER_ROWS_MAX = 6; /** Bottom border (`╰─╯`). */ export const INPUT_BOX_BOTTOM_ROWS = 1; -/** @deprecated Box has no air pad above. */ +/** Box has no air pad above (kept for older call sites). */ export const INPUT_PAD_TOP_ROWS = 0; -/** @deprecated Prefer BOTTOM_PAD_ROWS (below the whole input band). */ +/** 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)); + const inner = Math.max( + INPUT_INNER_ROWS, + Math.min(INPUT_INNER_ROWS_MAX, innerRows) + ); return INPUT_BOX_TOP_ROWS + inner + INPUT_BOX_BOTTOM_ROWS; } @@ -83,7 +86,7 @@ export function clampInputInnerRows(innerRows: number): number { */ export const BOTTOM_PAD_ROWS = 0; -/** @deprecated Alias of INPUT_BOX_TOP_ROWS. */ +/** 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`. */ @@ -112,7 +115,7 @@ export interface IComputeLayoutOpts { */ readonly inputInnerRows?: number; /** - * @deprecated Prefer `inputInnerRows`. When set without `inputInnerRows`, + * Prefer `inputInnerRows`. When set without `inputInnerRows`, * treated as total band height (borders included) for older call sites. */ readonly inputRows?: number; @@ -123,7 +126,17 @@ export interface IComputeLayoutOpts { 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)); } diff --git a/packages/core/src/render/frame/outer-frame.ts b/packages/core/src/render/frame/outer-frame.ts index 62b7b3a9..46daae76 100644 --- a/packages/core/src/render/frame/outer-frame.ts +++ b/packages/core/src/render/frame/outer-frame.ts @@ -144,7 +144,10 @@ export function frameContentRow( const opts = typeof colorOrOpts === "boolean" ? { color: colorOrOpts } : colorOrOpts; const color = opts.color !== false; - const { originCol, contentCols } = outerInsets(OUTER_CHROME * 2 + 1, termCols); + 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); @@ -170,21 +173,15 @@ function frameHorizEdge( ): string { const splitCol = junction?.splitCol; const glyph = junction?.junction ?? "┴"; - let mid: string; - - if ( + const mid = splitCol !== undefined && splitCol >= 0 && splitCol < contentCols && contentCols > 0 - ) { - mid = - "─".repeat(splitCol) + - glyph + - "─".repeat(Math.max(0, contentCols - splitCol - 1)); - } else { - mid = "─".repeat(Math.max(0, contentCols)); - } + ? "─".repeat(splitCol) + + glyph + + "─".repeat(Math.max(0, contentCols - splitCol - 1)) + : "─".repeat(Math.max(0, contentCols)); const bar = paint(`${left}${mid}${right}`, STYLE.chrome, color); diff --git a/packages/core/src/render/frame/pane-screen.ts b/packages/core/src/render/frame/pane-screen.ts index 62bc85b6..4acb9530 100644 --- a/packages/core/src/render/frame/pane-screen.ts +++ b/packages/core/src/render/frame/pane-screen.ts @@ -39,7 +39,6 @@ import { withOpaqueBg } from "./opaque-bg"; import { PaneFocus } from "./focus"; import { BODY_GAP_ROWS, - BODY_HEADER_ROWS, canUsePaneTui, clampInputInnerRows, computeLayout, @@ -52,15 +51,8 @@ import { handleFocusKey, handleMouseKey, handleScrollKey } from "./pane-keys"; import type { PaneKeyResult } from "./pane-keys"; import { STYLE, paint } from "../style"; import { displayWidth } from "../width"; -import { - formatScrollbarColumn, - overlayScrollbarCol, -} from "./scrollbar"; -import { - frameContentRow, - outerInsets, - wrapOuterFrame, -} from "./outer-frame"; +import { formatScrollbarColumn, overlayScrollbarCol } from "./scrollbar"; +import { frameContentRow, outerInsets, wrapOuterFrame } from "./outer-frame"; export interface IPaneScreenTerminal { readonly isTTY?: boolean; @@ -97,6 +89,8 @@ export class PaneScreen { 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(); @@ -195,10 +189,20 @@ export class PaneScreen { this.paint(); } - resize(rows: number, cols: number): void { + /** + * 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; @@ -230,15 +234,33 @@ export class PaneScreen { this.lastWrapCols = 0; } - this.paint(); + if (shouldPaint) { + this.paint(); + } } appendMain(text: string): void { this.scrollback.append(text); - if (this.entered) { - this.paint(); + 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 { @@ -328,9 +350,16 @@ export class PaneScreen { 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(); } @@ -347,6 +376,7 @@ export class PaneScreen { setHeader(opts: { cwd: string; sessionId?: string }): void { this.cwd = opts.cwd; this.sessionId = opts.sessionId ?? ""; + this.lastTopShape = ""; if (this.entered) { this.prevLines = null; @@ -354,7 +384,12 @@ export class PaneScreen { } } - setStatus(info: IStatusInfo): void { + /** + * 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) { @@ -366,19 +401,26 @@ export class PaneScreen { 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; } - // Status ticks are the moment stray relative writes (absolute CSI, etc.) - // most often land in empty main rows. Differential paint would skip those - // rows forever — force a full frame so ghosts cannot stack above the input. - this.prevLines = null; + if (nextShape !== this.lastTopShape) { + this.lastTopShape = nextShape; + this.prevLines = null; + } + + if (opts?.paint === false) { + return; + } + this.paint(); } @@ -455,6 +497,25 @@ export class PaneScreen { 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); @@ -473,8 +534,11 @@ export class PaneScreen { this.focus.panelFocused ? CONSOLE.bright : CONSOLE.rule, true ); - const panelSource = this.panelPaintLines(); + 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) { @@ -487,10 +551,10 @@ export class PaneScreen { ? paintSplitRow( mainLine, gutter, - insetX(panelSource[panelStart + i] ?? "", layout.panel.cols) + fitPanelCell(panelSource[panelStart + i] ?? "", panelCols) ) : mainLine; - const line = frameContentRow(content, this.cols); + const line = frameContentRow(content, this.cols, { splitCol }); const stamped = withOpaqueBg(line, CONSOLE.bg); if (screen[row] !== stamped) { @@ -502,10 +566,8 @@ export class PaneScreen { } } - const cursorRow = - insets.originRow + layout.input.row + band.cursor.row + 1; - const cursorCol = - insets.originCol + layout.input.col + band.cursor.col + 1; + 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); @@ -517,6 +579,120 @@ export class PaneScreen { } } + /** + * 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(); } @@ -612,12 +788,13 @@ export class PaneScreen { this.flashHeaderPaints -= 1; } - const bodyHeader = Math.min(BODY_HEADER_ROWS, layout.main.rows); - const bodyBudget = Math.max(0, layout.main.rows - bodyHeader); - const bodyGap = bodyBudget >= BODY_GAP_ROWS + 2 ? BODY_GAP_ROWS : 0; + const bodyGap = layout.main.rows >= BODY_GAP_ROWS + 2 ? BODY_GAP_ROWS : 0; const chromeAll = [...this.agentTreeLines, ...this.overlayLines]; - const scrollBudget = Math.max(0, bodyBudget - bodyGap); - const chromeRows = Math.min(chromeAll.length, Math.max(0, scrollBudget - 1)); + 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); @@ -629,11 +806,12 @@ export class PaneScreen { layout, topLines, inputBand, - bodyHeader, bodyGap, mainRows, chromeRows, - chrome: chromeAll.slice(chromeAll.length - 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, { @@ -728,7 +906,6 @@ export class PaneScreen { lines: string[]; cursor: { row: number; col: number }; }; - bodyHeader: number; bodyGap: number; mainRows: number; chromeRows: number; @@ -736,21 +913,22 @@ export class PaneScreen { }): string[] { const { layout } = opts; const contentCols = layout.top.cols; - const contentRows = Math.max( - 1, - layout.footer.row + layout.footer.rows - ); + 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) : []; - // Same ink as horizontal hairlines — dim SGR reads as a different grey. 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( @@ -759,19 +937,18 @@ export class PaneScreen { ); } - const gapStart = layout.main.row + opts.bodyHeader; + 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("", layout.panel.cols) : null + layout.panel !== null ? fitAnsiLine("", panelCols) : null ); } const bodyStart = gapStart + opts.bodyGap; const mainCols = layout.panel !== null ? layout.main.cols : contentCols; - // Grok-style overflow track in the right inset pad (no wrap-width steal). const scrollbar = formatScrollbarColumn( this.scrollback.metrics(), opts.mainRows, @@ -779,55 +956,75 @@ export class PaneScreen { ); for (let r = 0; r < opts.mainRows; r += 1) { - const idx = bodyStart + r; - let main = insetX(mainVisible[r] ?? "", mainCols); - // Track cells are blank (same as the inset pad) — only stamp the thumb. - const thumb = scrollbar?.[r]; + screen[bodyStart + r] = buildMainSplitContent({ + mainText: mainVisible[r] ?? "", + mainCols, + thumb: scrollbar?.[r], + gutter, + ruleGutter, + panelCell: + layout.panel !== null + ? fitPanelCell(panelSource[r] ?? "", panelCols) + : null, + }); + } - if (thumb !== undefined && thumb !== " ") { - main = overlayScrollbarCol(main, mainCols, thumb); - } + this.fillSplitChrome(screen, { + layout, + gutter, + panelSource, + panelCols, + contentCols, + bodyStart, + mainRows: opts.mainRows, + chromeRows: opts.chromeRows, + chrome: opts.chrome, + inputBand: opts.inputBand, + }); - const panelCell = - layout.panel !== null - ? fitPanelCell(panelSource[r] ?? "", layout.panel.cols) - : null; - // Under-rule under Tasks: `├` joins the gutter spine to the panel ─. - const splitGutter = - panelCell !== null && isRailUnderRule(panelCell) - ? paint( - "├", - this.focus.panelFocused ? CONSOLE.bright : CONSOLE.rule, - true - ) - : gutter; + for (let r = 0; r < contentRows; r += 1) { + screen[r] ??= fitAnsiLine("", contentCols); + } - // Each slot is hard-clamped to its column budget — content must never - // overwrite the panel gutter or bleed past the main pane. - screen[idx] = - layout.panel !== null - ? paintSplitRow(main, splitGutter, panelCell) - : main; + 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; - // Overlay / agent-tree chrome shares the main column — never full-bleed - // across the panel gutter (menus used to punch through the side rail). for (let i = 0; i < opts.chromeRows; i += 1) { const r = opts.mainRows + i; - const idx = bodyStart + r; + const main = insetX( + opts.chrome[i] ?? "", + hasPanel ? layout.main.cols : opts.contentCols + ); - screen[idx] = - layout.panel !== null - ? paintSplitRow( - insetX(opts.chrome[i] ?? "", layout.main.cols), - gutter, - fitPanelCell(panelSource[r] ?? "", layout.panel.cols) - ) - : insetX(opts.chrome[i] ?? "", contentCols); + screen[opts.bodyStart + r] = hasPanel + ? paintSplitRow( + main, + opts.gutter, + fitPanelCell(opts.panelSource[r] ?? "", opts.panelCols) + ) + : main; } - // Input + bottom air keep the panel gutter spine (┬ → │ → bottom). - const inputMainCols = layout.panel !== null ? layout.main.cols : contentCols; + const inputMainCols = hasPanel ? layout.main.cols : opts.contentCols; const spineBase = opts.mainRows + opts.chromeRows; for (let i = 0; i < layout.input.rows; i += 1) { @@ -836,35 +1033,26 @@ export class PaneScreen { inputMainCols ); - screen[layout.input.row + i] = - layout.panel !== null - ? paintSplitRow( - mainLine, - gutter, - fitPanelCell(panelSource[spineBase + i] ?? "", layout.panel.cols) - ) - : mainLine; + 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 idx = layout.footer.row + i; const spineIdx = spineBase + layout.input.rows + i; - screen[idx] = - layout.panel !== null - ? paintSplitRow( - fitAnsiLine("", layout.main.cols), - gutter, - fitPanelCell(panelSource[spineIdx] ?? "", layout.panel.cols) - ) - : fitAnsiLine("", contentCols); - } - - for (let r = 0; r < contentRows; r += 1) { - screen[r] ??= fitAnsiLine("", contentCols); + screen[layout.footer.row + i] = hasPanel + ? paintSplitRow( + fitAnsiLine("", layout.main.cols), + opts.gutter, + fitPanelCell(opts.panelSource[spineIdx] ?? "", opts.panelCols) + ) + : fitAnsiLine("", opts.contentCols); } - - return screen; } private flushScreen( @@ -917,10 +1105,8 @@ export class PaneScreen { 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; + 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( @@ -1030,7 +1216,8 @@ export class PaneScreen { painted.push(fitAnsiLine("", boxCols)); } - const midRow = bandRows >= 3 ? 1 + cursorRow : Math.min(cursorRow, bandRows - 1); + 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) => @@ -1083,6 +1270,55 @@ function isRailUnderRule(panelCell: string): boolean { 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 diff --git a/packages/core/src/render/frame/scrollback.ts b/packages/core/src/render/frame/scrollback.ts index 72a7a884..c944509a 100644 --- a/packages/core/src/render/frame/scrollback.ts +++ b/packages/core/src/render/frame/scrollback.ts @@ -186,9 +186,7 @@ export class Scrollback { } private wrapped(): string[] { - if (this.cachedWrapped === null) { - this.cachedWrapped = wrapAnsiLines(this.allLines(), this.wrapCols); - } + this.cachedWrapped ??= wrapAnsiLines(this.allLines(), this.wrapCols); return this.cachedWrapped; } diff --git a/packages/core/src/render/frame/wrap-line.ts b/packages/core/src/render/frame/wrap-line.ts index 2b387bc5..c972c42d 100644 --- a/packages/core/src/render/frame/wrap-line.ts +++ b/packages/core/src/render/frame/wrap-line.ts @@ -3,7 +3,7 @@ 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 = /^(│ |│ |▌ |▌ |\| |\| )/; +const HANG_PREFIX = /^(│ {2}|│ |▌ {2}|▌ |\| {2}|\| )/; /** * Wrap a (possibly ANSI) line to `cols` columns. @@ -160,9 +160,7 @@ function extractBoxedBodyAnsi(line: string, leftPlainLen: number): string { } /** Detect a closed card row: `│ … │`. */ -function parseBoxedRow( - plain: string -): { +function parseBoxedRow(plain: string): { left: string; right: string; leftCols: number; @@ -191,6 +189,26 @@ function parseBoxedRow( }; } +/** 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) { @@ -214,22 +232,7 @@ function wrapPlainWords(text: string, width: number): string[] { out.push(cur); } - 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); - } - - cur = rest; + cur = breakWideToken(word, width, out); } out.push(cur); diff --git a/packages/core/tests/agent-rail.test.ts b/packages/core/tests/agent-rail.test.ts index bf4862e2..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. */ @@ -114,7 +116,7 @@ describe("makeAgentRail — streaming semantics", () => { .replace(/\n$/, "") .split("\n") .find((row) => { - const plain = row.replace(/\x1b\[[0-9;]*m/g, ""); + const plain = row.replace(SGR_STRIP, ""); return /^│\s+│$/.test(plain); }); @@ -142,7 +144,7 @@ describe("makeAgentRail — streaming semantics", () => { const rows = out.replace(/\n$/, "").split("\n"); for (const row of rows) { - const plain = row.replace(/\x1b\[[0-9;]*m/g, ""); + const plain = row.replace(SGR_STRIP, ""); // Blank rows are a single chrome SGR span — measure the visible cells. expect(displayWidth(plain)).toBe(cardCols); diff --git a/packages/core/tests/chrome.test.ts b/packages/core/tests/chrome.test.ts index 1c3e2588..a99364aa 100644 --- a/packages/core/tests/chrome.test.ts +++ b/packages/core/tests/chrome.test.ts @@ -173,7 +173,11 @@ describe("formatRailHeader", () => { describe("hairline", () => { test("inserts junction glyphs at the split", () => { - const rule = hairline(10, "─", { splitCol: 4, junction: "┬", color: false }); + const rule = hairline(10, "─", { + splitCol: 4, + junction: "┬", + color: false, + }); expect(rule).toBe("────┬─────"); expect(hairline(8, "─", { splitCol: 3, junction: "┴", color: false })).toBe( diff --git a/packages/core/tests/frame-tui.test.ts b/packages/core/tests/frame-tui.test.ts index fdfcbab5..bb7a44b1 100644 --- a/packages/core/tests/frame-tui.test.ts +++ b/packages/core/tests/frame-tui.test.ts @@ -24,7 +24,6 @@ import { TOP_PAD_ROWS, BOTTOM_PAD_ROWS, } from "../src/render/frame"; -import { formatStatusBarLine } from "../src/render/status-bar"; import { VirtualScreen } from "./helpers/virtual-screen"; function findPromptRow(feed: string, rows: number, cols: number): number { @@ -34,10 +33,7 @@ function findPromptRow(feed: string, rows: number, cols: number): number { // 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(">") - ) { + if (screen.row(r).includes("╭") && screen.row(r + 1).includes(">")) { return r + 1; } } @@ -387,7 +383,7 @@ describe("PaneScreen", () => { cursorCol: 5, }); - let screen = new VirtualScreen(24, 100); + const screen = new VirtualScreen(24, 100); screen.feed(term.text()); const grownPrompt = findPromptRow(term.text(), 24, 100); @@ -731,7 +727,9 @@ describe("PaneScreen", () => { sawContentRow = true; // Panel gutter column stays a gutter glyph (│ spine or ├ under-rule) — never "W". - expect(["│", "├"]).toContain(row[gutterCol]); + const gutter = row[gutterCol] ?? ""; + + expect(gutter === "│" || gutter === "├").toBe(true); // First panel cell is not overflowing main content. expect(row[gutterCol + 1]).not.toBe("W"); } @@ -806,6 +804,29 @@ describe("PaneScreen", () => { expect(screen.text()).toContain("item-a"); }); + 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("setStatus paints live chips on the dense top strip", () => { const term = new FakeTerm(); const panes = new PaneScreen(term, 24, 100); @@ -894,12 +915,12 @@ describe("PaneScreen", () => { expect(screen.row(titleRow(24))).toContain("✓"); }); - test("setStatus full-repaint clears ghost metrics rows above the input", () => { + 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\n"); + panes.appendMain("chat line unique-body-marker\n"); panes.setStatus({ model: "deepseek", contextTokens: 0, @@ -913,50 +934,94 @@ describe("PaneScreen", () => { activity: "⠋ thinking · 0s", }); - // Stray absolute writes into empty main rows (relative - // redraw fighting the alt screen) — differential paint used to leave them. - for (let i = 0; i < 6; i += 1) { - const line = formatStatusBarLine( - { - model: "deepseek", - contextTokens: 0, - contextWindow: 100, - turns: 1, - elapsedMs: 1100, - status: "working", - scope: "entire workspace", - mode: "plan", - tokensPerSecond: 47, - activity: `⠋ thinking · ${String(i)}s`, - }, - 100, - true - ); - - term.write(`\x1b[${String(12 + i)};1H${line}`); + 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: 1, - elapsedMs: 1100, - status: "responded", - scope: "entire workspace", + turns: 0, + elapsedMs: 0, + status: "ready", + scope: "repo", mode: "plan", - tokensPerSecond: 44, }); + panes.appendMain("seed\n"); + term.writes = []; - const screen = new VirtualScreen(24, 100); + for (let i = 0; i < 20; i += 1) { + panes.appendMain(`stream-chunk-${String(i)}\n`); + } - screen.feed(term.text()); - const thinking = screen.text().match(/thinking/g) ?? []; + const out = term.writes.join(""); - expect(thinking.length).toBe(0); - expect(screen.row(expectedPromptRow(24))).toContain("describe a task"); - expect(screen.row(titleRow(24))).toContain("✓"); - expect(screen.text()).toContain("chat line"); + 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", () => { diff --git a/packages/core/tests/message-render.test.ts b/packages/core/tests/message-render.test.ts index ff8e36f6..cacddc63 100644 --- a/packages/core/tests/message-render.test.ts +++ b/packages/core/tests/message-render.test.ts @@ -30,7 +30,7 @@ describe("renderMessage — hybrid bubbles", () => { expect(out).toContain("┐"); expect(out).toContain("└"); expect(out).toContain("┘"); - expect(out).toMatch(/│ hey there\s+│/); + expect(out).toMatch(/│ {2}hey there\s+│/); expect(out).not.toContain("▌"); expect(out).not.toContain("╭"); expect(out).not.toContain("╰"); @@ -49,8 +49,8 @@ describe("renderMessage — hybrid bubbles", () => { expect(out).toContain("└"); expect(out).toContain("┘"); expect(out).not.toContain("some-model"); - expect(out).toMatch(/│ line one\s+│/); - expect(out).toMatch(/│ line two\s+│/); + expect(out).toMatch(/│ {2}line one\s+│/); + expect(out).toMatch(/│ {2}line two\s+│/); expect(out).not.toContain("╭"); expect(out).not.toContain("╰"); }); @@ -64,7 +64,8 @@ describe("renderMessage — hybrid bubbles", () => { 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 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)); @@ -89,7 +90,9 @@ describe("role card alignment", () => { expect(agentRow.endsWith("│")).toBe(true); const userBottom = - stripAnsi(userBubble("hi", false, cols)).split("\n").at(-1) ?? ""; + stripAnsi(userBubble("hi", false, cols)) + .split("\n") + .at(-1) ?? ""; expect(userBottom.startsWith("└")).toBe(true); expect(userBottom.endsWith("┘")).toBe(true); @@ -118,7 +121,7 @@ describe("userBubble", () => { expect(out).toContain("[48;2;34;211;238m"); expect(out).toContain("[38;2;34;211;238m"); - expect(stripAnsi(out)).toMatch(/│ hi\s+│/); + expect(stripAnsi(out)).toMatch(/│ {2}hi\s+│/); expect(stripAnsi(out)).not.toContain("▌"); }); @@ -130,7 +133,7 @@ describe("userBubble", () => { expect(rows[1]?.startsWith("│")).toBe(true); expect(rows[1]?.endsWith("│")).toBe(true); expect(/^│\s+│$/.test(rows[1] ?? "")).toBe(true); - expect(rows[2]).toMatch(/│ hi\s+│/); + expect(rows[2]).toMatch(/│ {2}hi\s+│/); expect(rows.at(-1)?.startsWith("└")).toBe(true); expect(rows.at(-1)?.endsWith("┘")).toBe(true); }); diff --git a/packages/core/tests/outer-frame.test.ts b/packages/core/tests/outer-frame.test.ts index 2f4d575e..e764af18 100644 --- a/packages/core/tests/outer-frame.test.ts +++ b/packages/core/tests/outer-frame.test.ts @@ -87,7 +87,9 @@ describe("wrapOuterFrame", () => { const { contentRows, contentCols } = outerInsets(termRows, termCols); const split = 6; const rule = - "─".repeat(split) + "┬" + "─".repeat(Math.max(0, contentCols - split - 1)); + "─".repeat(split) + + "┬" + + "─".repeat(Math.max(0, contentCols - split - 1)); const content = Array.from({ length: contentRows }, (_, i) => i === 1 ? rule : "".padEnd(contentCols, " ") ); @@ -113,7 +115,9 @@ describe("wrapOuterFrame", () => { ); const split = 6; const panelRule = - " ".repeat(split) + "├" + "─".repeat(Math.max(0, contentCols - split - 1)); + " ".repeat(split) + + "├" + + "─".repeat(Math.max(0, contentCols - split - 1)); const content = Array.from({ length: contentRows }, (_, i) => i === 1 ? panelRule : "".padEnd(contentCols, " ") ); @@ -126,8 +130,8 @@ describe("wrapOuterFrame", () => { 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( - /^─+$/ - ); + expect( + plain.slice(originCol + split + 1, termCols - OUTER_MARGIN - 1) + ).toMatch(/^─+$/); }); }); diff --git a/packages/core/tests/scrollbar.test.ts b/packages/core/tests/scrollbar.test.ts index dc96dc21..aa0556ff 100644 --- a/packages/core/tests/scrollbar.test.ts +++ b/packages/core/tests/scrollbar.test.ts @@ -24,9 +24,9 @@ describe("needsScrollbar", () => { expect( needsScrollbar(metrics({ total: 10, viewport: 10, offset: 0 })) ).toBe(false); - expect( - needsScrollbar(metrics({ total: 5, viewport: 10, offset: 0 })) - ).toBe(false); + expect(needsScrollbar(metrics({ total: 5, viewport: 10, offset: 0 }))).toBe( + false + ); }); test("shown when content overflows", () => { @@ -79,7 +79,9 @@ describe("thumbWindow", () => { 40 ); - expect(large!.end - large!.start).toBeGreaterThan(small!.end - small!.start); + expect(large!.end - large!.start).toBeGreaterThan( + small!.end - small!.start + ); }); }); @@ -94,7 +96,7 @@ describe("formatScrollbarColumn", () => { expect(col).not.toBeNull(); expect(col!.length).toBe(10); expect(col![0]).toBe("█"); - expect(col!.some((c) => c === " ")).toBe(true); + expect(col!.includes(" ")).toBe(true); expect(col!.filter((c) => c === "█").length).toBeGreaterThan(0); }); diff --git a/packages/core/tests/wrap-line.test.ts b/packages/core/tests/wrap-line.test.ts index 5629169e..11a620ed 100644 --- a/packages/core/tests/wrap-line.test.ts +++ b/packages/core/tests/wrap-line.test.ts @@ -90,8 +90,7 @@ describe("wrapAnsiLine", () => { 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 split = `${chrome}│${reset}` + " ".repeat(18) + `${chrome}│${reset}`; const [row] = wrapAnsiLine(split, 20); const first = row!.indexOf("│"); const last = row!.lastIndexOf("│"); 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..1483ef6f 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 styling still uses brand+bold after scroll. + 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 @@ -32,59 +31,48 @@ 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 brand+bold styling", + buf.count(BRAND_BOLD) >= 1, ) - 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-pty.py b/scripts/e2e-pty.py index ad48a4f7..e4e055c7 100644 --- a/scripts/e2e-pty.py +++ b/scripts/e2e-pty.py @@ -81,7 +81,12 @@ 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: "REPLY TO REFINE" in b + or "reply to refine" in b.lower() + or "## Plan" 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 +127,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 ---------------------------------------------------------- From a0b3e4c1a33b97fd0bb54edb18e7b8497c067d88 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sun, 9 Aug 2026 01:07:10 +0200 Subject: [PATCH 4/8] feat(tui): polish Tasks rail and shared menu chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen the worklist adaptively and paint CONSOLE checklist glyphs with inset-matched wrap; share ▸ menu chrome across overlays and keep /work approve working under the pane editor. --- packages/core/ARCHITECTURE.md | 15 +- packages/core/src/cli/capability-menu.ts | 3 + packages/core/src/cli/config-menu.ts | 62 ++++- packages/core/src/cli/repl-recipe.ts | 3 + packages/core/src/cli/repl-scaffold.ts | 10 +- packages/core/src/cli/repl-work.ts | 109 ++++++-- packages/core/src/cli/repl.ts | 117 ++++++-- packages/core/src/loop/worklist/panel.ts | 222 +++++++++++++--- packages/core/src/render/command-menu.ts | 9 +- packages/core/src/render/file-menu.ts | 21 +- packages/core/src/render/frame/index.ts | 2 + packages/core/src/render/frame/layout.ts | 31 ++- packages/core/src/render/frame/pane-screen.ts | 56 +++- packages/core/src/render/index.ts | 11 + packages/core/src/render/inline-menu.ts | 137 ++++------ packages/core/src/render/menu-chrome.ts | 186 +++++++++++++ packages/core/src/render/wizard.ts | 249 +++++++++++++----- packages/core/src/setup/run-setup.ts | 12 +- packages/core/tests/file-menu.test.ts | 2 +- packages/core/tests/frame-tui.test.ts | 97 +++++++ packages/core/tests/menu-chrome.test.ts | 93 +++++++ packages/core/tests/overlay-e2e.test.ts | 10 +- packages/core/tests/repl-work.test.ts | 37 +++ packages/core/tests/wizard.test.ts | 6 +- packages/core/tests/worklist-panel.test.ts | 129 ++++++--- scripts/e2e-help-menu-pty.py | 10 +- 26 files changed, 1328 insertions(+), 311 deletions(-) create mode 100644 packages/core/src/render/menu-chrome.ts create mode 100644 packages/core/tests/menu-chrome.test.ts create mode 100644 packages/core/tests/repl-work.test.ts diff --git a/packages/core/ARCHITECTURE.md b/packages/core/ARCHITECTURE.md index ade0932a..eacd1982 100644 --- a/packages/core/ARCHITECTURE.md +++ b/packages/core/ARCHITECTURE.md @@ -2,7 +2,7 @@ -Derived from `packages/core/src`: **30 subsystems**, **574 files**, **104773 lines**, **136 cross-subsystem edges**. +Derived from `packages/core/src`: **30 subsystems**, **575 files**, **105613 lines**, **137 cross-subsystem edges**. This page is the exhaustive record: every subsystem, every cross-subsystem edge, and @@ -13,10 +13,10 @@ 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 | 110 | 30935 | 7 | 21 | +| `loop` | The drive-to-green engine: turns, tools, gate settling, steering, adapters | core | 110 | 31095 | 7 | 22 | | `rule-packs` | The ESLint rule packs the gate enforces, grouped by stack | core | 164 | 19000 | 3 | 3 | -| `render` | Terminal UI — status bar, menus, wizards, markdown, diffs, spinners | core | 39 | 8925 | 6 | 5 | -| `cli` | Argument parsing, the interactive REPL, and per-mode wiring | core | 22 | 7279 | 2 | 19 | +| `render` | Terminal UI — status bar, menus, wizards, markdown, diffs, spinners | core | 40 | 9395 | 7 | 5 | +| `cli` | Argument parsing, the interactive REPL, and per-mode wiring | core | 22 | 7481 | 2 | 19 | | `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 | @@ -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 | 545 | 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 | 8 | 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,7 +56,7 @@ buries the ones someone can actually go and break. | Pair | One way | The other | | --- | --- | --- | -| `(root)` ↔ `cli` | `cli.ts:22` → `./cli/worklist-deps` | `cli/config-menu.ts:8` → `../models-config` | +| `(root)` ↔ `cli` | `cli.ts:22` → `./cli/worklist-deps` | `cli/config-menu.ts:10` → `../models-config` | | `(root)` ↔ `inference` | `classify.ts:1` → `./inference` | `inference/image-gen.ts:4` → `../models-config` | | `(root)` ↔ `loop` | `cli.ts:21` → `./loop` | `loop/expert-handoff.ts:21` → `../models-config` | | `agent` ↔ `inference` | `agent/agent-runner.ts:13` → `../inference` | `inference/wire.ts:9` → `../agent` | @@ -67,6 +67,7 @@ buries the ones someone can actually go and break. | `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:2` → `../../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:871` | | `mapMode` | `cli.ts:491` | | `recipesMode` | `cli.ts:510` | -| `repl` | `cli/repl.ts:577` | +| `repl` | `cli/repl.ts:601` | | `reviewMode` | `cli.ts:191` | | `runOnce` | `cli.ts:103` | | `runTraceCommand` | `cli/repl-commands.ts:109` | diff --git a/packages/core/src/cli/capability-menu.ts b/packages/core/src/cli/capability-menu.ts index 4baa493c..0a4a9aaf 100644 --- a/packages/core/src/cli/capability-menu.ts +++ b/packages/core/src/cli/capability-menu.ts @@ -17,6 +17,8 @@ export interface ICapabilityMenuDeps { 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; } /** @@ -72,6 +74,7 @@ export function runCapabilityMenu(deps: ICapabilityMenuDeps): Promise { 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/config-menu.ts b/packages/core/src/cli/config-menu.ts index 154ceff4..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,10 +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) => @@ -309,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 ─────────────────────────────────────────────────────────────── /** @@ -344,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.cyan, 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 => { @@ -431,6 +468,7 @@ export function runConfigMenu(deps: IConfigDeps): Promise { 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 dd2175d7..84270a3d 100644 --- a/packages/core/src/cli/repl-recipe.ts +++ b/packages/core/src/cli/repl-recipe.ts @@ -14,6 +14,8 @@ export interface IReplRecipeDeps { 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; } /** @@ -49,6 +51,7 @@ export async function openRecipePicker(deps: IReplRecipeDeps): Promise { 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 5b2e2d14..21e14f84 100644 --- a/packages/core/src/cli/repl-scaffold.ts +++ b/packages/core/src/cli/repl-scaffold.ts @@ -16,8 +16,12 @@ export interface IReplScaffoldDeps { readonly suspend: () => void; readonly resume: () => void; readonly out: (s: string) => void; - /** Pane / status overlay — when set, scaffold wizards skip nested alt-screen. */ + /** 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). */ @@ -176,6 +180,10 @@ export async function openScaffoldInRepl( 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 diff --git a/packages/core/src/cli/repl-work.ts b/packages/core/src/cli/repl-work.ts index d05f34e5..655a5310 100644 --- a/packages/core/src/cli/repl-work.ts +++ b/packages/core/src/cli/repl-work.ts @@ -37,7 +37,16 @@ export interface IRunWorkCommandOpts { args: ICliArgs; arg: string; echo: (s: string) => void; + /** + * Classic readline (null when the multiline editor owns stdin). Prefer + * {@link askApprove} under the pane console — `rl.question` is unavailable there. + */ rl: Rl; + /** + * Interactive approve/cancel when `rl` is null (pane editor). Overlay menus, + * etc. When both `rl` and this are absent, planning cancels as non-interactive. + */ + askApprove?: () => Promise<"approve" | "cancel">; workProvider: OpenAICompatibleProvider; activeModelEntry: IModelEntry; /** Session gate command (may be empty). */ @@ -77,25 +86,44 @@ async function resolveArgPath( return null; } -async function approvePlan( +/** Prompt for plan approval. Exported for unit tests. */ +export async function approvePlan( echo: (s: string) => void, - rl: Rl, - checklist: string + checklist: string, + ask: (() => Promise<"approve" | "cancel">) | null ): Promise<"approve" | "cancel"> { echo(`\nProposed worklist:\n${checklist}\n`); echo("Approve this list? (approve/cancel)\n"); - if (rl === null) { + if (ask === null) { echo("(non-interactive — cancelling)\n"); return "cancel"; } - const answer = (await rl.question("> ")).trim().toLowerCase(); + return ask(); +} + +function approveAskFromOpts( + opts: IRunWorkCommandOpts +): (() => Promise<"approve" | "cancel">) | null { + if (opts.askApprove !== undefined) { + return opts.askApprove; + } + + const { rl } = opts; + + if (rl === null) { + return null; + } + + return async () => { + const answer = (await rl.question("> ")).trim().toLowerCase(); - return answer === "approve" || answer === "approved" || answer === "go" - ? "approve" - : "cancel"; + return answer === "approve" || answer === "approved" || answer === "go" + ? "approve" + : "cancel"; + }; } /** @@ -106,7 +134,7 @@ async function planFromGoal( opts: IRunWorkCommandOpts, goal: string ): Promise { - const { echo, rl, activeModelEntry } = opts; + const { echo, activeModelEntry } = opts; echo("▸ planning a worklist from your goal...\n"); @@ -125,7 +153,9 @@ async function planFromGoal( features: planned.features, }); - if ((await approvePlan(echo, rl, preview)) !== "approve") { + if ( + (await approvePlan(echo, preview, approveAskFromOpts(opts))) !== "approve" + ) { echo("worklist cancelled\n"); return null; @@ -163,6 +193,36 @@ interface IResolvedWorklist { accepts: Map; } +/** Persist planned items under `.tsforge/worklist/` and return the resolved start. */ +async function persistPlannedItems( + opts: IRunWorkCommandOpts, + items: IWorklistItem[], + sourcePath: string | null, + goal: string +): Promise { + const state = await prepareWorklistState(opts.args.dir, { goal, items }); + + return state === null + ? null + : { state, sourcePath, accepts: acceptMapOf(items) }; +} + +/** File had no checklist markers — ask the planner to extract items from prose. */ +async function planFromNarrativeFile( + opts: IRunWorkCommandOpts, + asPath: string +): Promise { + const md = (await readFile(asPath, "utf8")).trim(); + const items = await planFromGoal( + opts, + md.length > 0 ? md.slice(0, 12_000) : opts.arg + ); + + return items === null + ? null + : persistPlannedItems(opts, items, asPath, opts.arg); +} + async function resolveWorklistStart( opts: IRunWorkCommandOpts, asPath: string | null, @@ -184,29 +244,22 @@ async function resolveWorklistStart( ...(asPath !== null ? { path: asPath } : {}), }); - return state === null - ? null - : { - state, - sourcePath: asPath ?? (await resolveWorklistPath(cwd)), - accepts: new Map(), - }; - } - - const items = await planFromGoal(opts, opts.arg); + if (state !== null) { + return { + state, + sourcePath: asPath ?? (await resolveWorklistPath(cwd)), + accepts: new Map(), + }; + } - if (items === null) { - return null; + return asPath === null ? null : planFromNarrativeFile(opts, asPath); } - const state = await prepareWorklistState(cwd, { - goal: opts.arg, - items, - }); + const items = await planFromGoal(opts, opts.arg); - return state === null + return items === null ? null - : { state, sourcePath: null, accepts: acceptMapOf(items) }; + : persistPlannedItems(opts, items, null, opts.arg); } function stuckMessage(result: Awaited>): string { diff --git a/packages/core/src/cli/repl.ts b/packages/core/src/cli/repl.ts index 07931e08..82fe5c2c 100644 --- a/packages/core/src/cli/repl.ts +++ b/packages/core/src/cli/repl.ts @@ -131,7 +131,13 @@ import { runTraceCommand, } from "./repl-commands"; import { runWorkCommand } from "./repl-work"; -import { formatWorklistLines, worklistBadge } from "../loop/worklist"; +import { + WORKLIST_STATE, + formatWorklistLines, + worklistBadge, +} from "../loop/worklist"; +import { loadState } from "../loop/greenfield"; +import { runInlineMenu } from "../render/inline-menu"; /** A unique-enough id for a new session (time + a little randomness). */ function newSessionId(): string { @@ -1252,6 +1258,8 @@ export async function repl(args: ICliArgs): Promise { chrome.clearOverlay(); }, }, + columns: transcriptCols(), + viewportRows: overlayBudget(), }); } finally { editorControl?.setInputInert(false); @@ -1398,6 +1406,17 @@ export async function repl(args: ICliArgs): Promise { 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; @@ -1492,14 +1511,78 @@ export async function repl(args: ICliArgs): Promise { } }; + /** Paint the Tasks rail from a worklist state (or empty-rail hints). */ + const syncWorklistPanel = ( + workState: Awaited> + ): void => { + if (!panesLive()) { + return; + } + + const cols = Math.max(12, paneScreen.panelInnerCols()); + const maxPending = Math.max(4, paneScreen.panelListBudgetRows()); + const state = workState ?? { goal: "worklist", features: [] }; + + paneScreen.setPanel( + formatWorklistLines(state, { + columns: cols, + maxPending, + color: true, + }) + ); + paneScreen.setWorklistBadge( + state.features.length > 0 ? worklistBadge(state) : "" + ); + + syncPaneChrome(); + }; + handleWork = async (workArg: string): Promise => { await runWorkCommand({ args, arg: workArg, echo: (s) => { - process.stdout.write(s); + streamOut(s); }, rl, + // Pane editor leaves `rl` null — approve via the shared overlay menu. + askApprove: async () => { + editorControl?.suspend(); + editorControl?.setInputInert(true); + + try { + const picked = await runInlineMenu( + [ + { + id: "approve", + label: "approve", + describe: "Save this list and start driving remaining items", + }, + { + id: "cancel", + label: "cancel", + describe: "Discard the proposed worklist", + }, + ], + { + title: "Approve this worklist?", + render: (lines) => { + chrome.setOverlay(lines); + }, + close: () => { + chrome.clearOverlay(); + }, + columns: transcriptCols(), + viewportRows: overlayBudget(), + } + ); + + return picked === 0 ? "approve" : "cancel"; + } finally { + editorControl?.setInputInert(false); + editorControl?.resume(); + } + }, workProvider: provider, activeModelEntry, gate: session.gate, @@ -1507,21 +1590,12 @@ export async function repl(args: ICliArgs): Promise { logFile, id, onProgress: (workState) => { - const lines = formatWorklistLines(workState); - - if (panesLive()) { - paneScreen.setPanel(lines); - paneScreen.setWorklistBadge(worklistBadge(workState)); - syncPaneChrome(); - } + syncWorklistPanel(workState); }, }); - if (panesLive()) { - paneScreen.clearPanel(); - paneScreen.setWorklistBadge(""); - syncPaneChrome(); - } + // Keep the Tasks rail hydrated from disk after /work (do not wipe on exit). + syncWorklistPanel(await loadState(args.dir, WORKLIST_STATE)); }; /** Stream conversation text: main pane when live, else plain stdout (pipes). */ @@ -1802,6 +1876,7 @@ export async function repl(args: ICliArgs): Promise { }, }, columns: transcriptCols(), + viewportRows: overlayBudget(), }); } finally { editorControl?.setInputInert(false); @@ -2149,6 +2224,8 @@ export async function repl(args: ICliArgs): Promise { suspend, resume, out: (s) => process.stdout.write(s), + columns: transcriptCols(), + viewportRows: overlayBudget(), }) : openRecipePicker({ cwd: args.dir, @@ -2159,6 +2236,7 @@ export async function repl(args: ICliArgs): Promise { chrome.clearOverlay(); }, columns: transcriptCols(), + viewportRows: overlayBudget(), out: (s) => process.stdout.write(s), runRecipe: (recipe) => { if (recipe.gate !== undefined) { @@ -2182,6 +2260,7 @@ export async function repl(args: ICliArgs): Promise { chrome.clearOverlay(); }, columns: transcriptCols(), + viewportRows: overlayBudget(), }; }; @@ -2238,6 +2317,8 @@ export async function repl(args: ICliArgs): Promise { chrome.clearOverlay(); }, }, + columns: transcriptCols(), + viewportRows: overlayBudget(), }); }; @@ -2292,6 +2373,7 @@ export async function repl(args: ICliArgs): Promise { chrome.clearOverlay(); }, columns: transcriptCols(), + viewportRows: overlayBudget(), }; try { @@ -2682,7 +2764,12 @@ export async function repl(args: ICliArgs): Promise { } } - // Empty landing otherwise — discovery lives in the input placeholder. + // Resume Tasks rail from a prior /work run (`.tsforge/worklist/`). + void loadState(args.dir, WORKLIST_STATE).then((workState) => { + if (panesLive()) { + syncWorklistPanel(workState); + } + }); }; if (interactiveTty) { diff --git a/packages/core/src/loop/worklist/panel.ts b/packages/core/src/loop/worklist/panel.ts index 4a122603..3f982b98 100644 --- a/packages/core/src/loop/worklist/panel.ts +++ b/packages/core/src/loop/worklist/panel.ts @@ -1,29 +1,35 @@ import type { IFeature, IGreenfieldState } from "../greenfield"; +import { stripSgr } from "../../render/frame/ansi-plain"; +import { CONSOLE } from "../../render/frame/chrome"; +import { paint } from "../../render/style"; +import { displayWidth, sliceToWidth } from "../../render/width"; export interface IFormatWorklistLinesOptions { - /** How many pending (not-yet-current) items to preview. Default 3. */ + /** + * 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 = header). */ + /** Highlight this line index when the panel is focused (0 = first body line). */ selectedIndex?: number; - /** When true, prefix the selected row with `▸ `. */ + /** 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; } -function boxOf(feature: IFeature, current: boolean): string { - if (feature.passes) { - return "[x]"; - } +type ItemKind = "done" | "current" | "pending" | "parked"; - if (feature.parked === true) { - return "[~]"; - } +const GLYPH: Record = { + done: "✓", + current: "▸", + pending: "○", + parked: "~", +}; - if (current) { - return "[>]"; - } - - return "[ ]"; -} +const CONT_INDENT = " "; /** Compact badge for the top status strip, e.g. `3/7`. */ export function worklistBadge(state: IGreenfieldState): string { @@ -38,50 +44,204 @@ export function worklistBadge(state: IGreenfieldState): string { return `${done}/${total}`; } +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 === "parked") { + 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 === "parked") { + return part; + } + + return paint(part, CONSOLE.muted, true); +} + +/** One item: glyph + wrapped description (continuations indented). */ +function formatItemLines( + feature: IFeature, + kind: ItemKind, + columns: number, + color: boolean +): string[] { + const glyph = GLYPH[kind]; + const painted = paintGlyph(glyph, kind, color); + const budget = Math.max(4, columns - displayWidth(`${glyph} `)); + const parts = wrapWords(feature.desc.trim(), budget); + + return parts.map((part, i) => { + const body = paintBody(part, kind, color); + + return i === 0 ? `${painted} ${body}` : `${CONT_INDENT}${body}`; + }); +} + +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.startsWith(GLYPH.current)) { + return line; + } + + return paint(`▸ ${plain}`, CONSOLE.bright, color); + }); +} + /** - * Compact live-region / panel lines for the worklist — counts and checkmarks - * from gate state only (never model narration). + * Tasks-rail body lines — goal cue + checklist from gate state only + * (never model narration). Sticky `Tasks N/M` title is painted separately. */ export function formatWorklistLines( state: IGreenfieldState, opts: IFormatWorklistLinesOptions = {} ): string[] { - const maxPending = opts.maxPending ?? 3; + const maxPending = opts.maxPending ?? 12; + const columns = Math.max(12, opts.columns ?? 36); + const color = opts.color !== false; const total = state.features.length; if (total === 0) { - return ["worklist", "/work to start"]; + return [ + paint("/work PLAN.md", CONSOLE.muted, color), + paint("or /work ", CONSOLE.muted, color), + ]; } - const done = state.features.filter((f) => f.passes).length; + const done = state.features.filter((f) => f.passes); const current = state.features.find((f) => !f.passes && !(f.parked ?? false)); const pending = state.features.filter( (f) => !f.passes && !(f.parked ?? false) && f.id !== current?.id ); const parked = state.features.filter((f) => (f.parked ?? false) && !f.passes); - const lines: string[] = [`worklist ${done}/${total}`]; + const lines: string[] = []; + const goal = state.goal.trim(); + + if (goal.length > 0 && goal !== "worklist") { + lines.push(paint(clip(goal, columns), CONSOLE.muted, color)); + } + + for (const feature of done.slice(-2)) { + lines.push(...formatItemLines(feature, "done", columns, color)); + } if (current !== undefined) { - lines.push(`${boxOf(current, true)} ${current.desc}`); - } else if (done === total) { - lines.push("All done."); + lines.push(...formatItemLines(current, "current", columns, color)); + } else if (done.length === total) { + lines.push(paint("All done.", CONSOLE.bright, color)); } else if (parked.length > 0) { - lines.push(`Parked ${parked.length} — revisit`); + lines.push( + paint(`Parked ${String(parked.length)} — revisit`, CONSOLE.warn, color) + ); } for (const feature of pending.slice(0, maxPending)) { - lines.push(`${boxOf(feature, false)} ${feature.desc}`); + lines.push(...formatItemLines(feature, "pending", columns, color)); } if (pending.length > maxPending) { - lines.push(`… +${pending.length - maxPending} more`); + const more = `… +${String(pending.length - maxPending)} more`; + + lines.push(paint(more, CONSOLE.muted, color)); } - if (opts.showSelection === true && opts.selectedIndex !== undefined) { - const idx = opts.selectedIndex; + for (const feature of parked.slice(0, 2)) { + lines.push(...formatItemLines(feature, "parked", columns, color)); + } - return lines.map((line, i) => (i === idx ? `▸ ${line}` : ` ${line}`)); + if (opts.showSelection === true && opts.selectedIndex !== undefined) { + return applySelection(lines, opts.selectedIndex, color); } return lines; diff --git a/packages/core/src/render/command-menu.ts b/packages/core/src/render/command-menu.ts index b080520d..fe77d255 100644 --- a/packages/core/src/render/command-menu.ts +++ b/packages/core/src/render/command-menu.ts @@ -48,6 +48,8 @@ export interface IPaletteView { 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; } /** @@ -87,7 +89,12 @@ export function pickCommand(view: IPaletteView): Promise { : process.stdout.columns > 0 ? process.stdout.columns : 80; - const viewportRows = process.stdout.rows > 0 ? process.stdout.rows : 24; + 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 eed5b955..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.cyan, color) : " "; const text = truncatePath(path, Math.max(0, columns - 2)); - return `${gutter} ${paint(text, active ? STYLE.cyan : 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/index.ts b/packages/core/src/render/frame/index.ts index e49d1c9b..2dbb0d37 100644 --- a/packages/core/src/render/frame/index.ts +++ b/packages/core/src/render/frame/index.ts @@ -63,6 +63,8 @@ export { PANE_MIN_ROWS, PANE_SPLIT_MIN_COLS, PANEL_WIDTH, + PANEL_MAIN_MIN_COLS, + panelWidthFor, INPUT_BAND_ROWS, INPUT_INNER_ROWS, INPUT_INNER_ROWS_MAX, diff --git a/packages/core/src/render/frame/layout.ts b/packages/core/src/render/frame/layout.ts index 47ae2a08..9968f9bf 100644 --- a/packages/core/src/render/frame/layout.ts +++ b/packages/core/src/render/frame/layout.ts @@ -10,8 +10,28 @@ export const PANE_MIN_ROWS = 16; /** Minimum total columns to keep a side panel. */ export const PANE_SPLIT_MIN_COLS = 72; -/** Side panel width when split. */ -export const PANEL_WIDTH = 28; +/** 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): @@ -167,10 +187,11 @@ export function computeLayout(opts: IComputeLayoutOpts): ILayoutRects { 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 - PANEL_WIDTH >= 24; + opts.cols - panelCols >= PANEL_MAIN_MIN_COLS; // Gutter spine runs through main + input + bottom pad. const spineRows = bodyRows + inputRows + footerRows; @@ -203,7 +224,7 @@ export function computeLayout(opts: IComputeLayoutOpts): ILayoutRects { }; } - const mainCols = opts.cols - PANEL_WIDTH - 1; // 1-col gutter + const mainCols = opts.cols - panelCols - 1; // 1-col gutter return { top, @@ -212,7 +233,7 @@ export function computeLayout(opts: IComputeLayoutOpts): ILayoutRects { row: topRows, col: mainCols + 1, rows: spineRows, - cols: PANEL_WIDTH, + cols: panelCols, }, input, footer, diff --git a/packages/core/src/render/frame/pane-screen.ts b/packages/core/src/render/frame/pane-screen.ts index 4acb9530..a4d63901 100644 --- a/packages/core/src/render/frame/pane-screen.ts +++ b/packages/core/src/render/frame/pane-screen.ts @@ -49,7 +49,7 @@ import { Scrollback } from "./scrollback"; import { stripSgr } from "./ansi-plain"; import { handleFocusKey, handleMouseKey, handleScrollKey } from "./pane-keys"; import type { PaneKeyResult } from "./pane-keys"; -import { STYLE, paint } from "../style"; +import { paint } from "../style"; import { displayWidth } from "../width"; import { formatScrollbarColumn, overlayScrollbarCol } from "./scrollbar"; import { frameContentRow, outerInsets, wrapOuterFrame } from "./outer-frame"; @@ -74,7 +74,8 @@ export const FORGE_EDITOR_GUTTER = INPUT_EDITOR_GUTTER; const FORGE_PLACEHOLDER = "describe a task, or /help"; const GUTTER = "│"; -const EMPTY_PANEL_LINES = ["—", "/work"] as const; +/** Fallback when no worklist lines are set — mirrors formatWorklistLines empty. */ +const EMPTY_PANEL_LINES = ["/work PLAN.md", "or /work "] as const; /** * Interactive console TUI: dense top strip, hairlines, scroll + rail, caret input. @@ -773,6 +774,39 @@ export class PaneScreen { 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; @@ -1130,16 +1164,26 @@ export class PaneScreen { 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.map((l) => paint(l, STYLE.dim, true))]; + 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 abs === this.focus.selection - ? paint(`▸ ${stripSgr(line)}`, CONSOLE.bright, true) - : ` ${line}`; + return paint(`▸ ${plain}`, CONSOLE.bright, true); }); return [...title, ...body]; diff --git a/packages/core/src/render/index.ts b/packages/core/src/render/index.ts index 2ebcb2e2..00233356 100644 --- a/packages/core/src/render/index.ts +++ b/packages/core/src/render/index.ts @@ -32,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, diff --git a/packages/core/src/render/inline-menu.ts b/packages/core/src/render/inline-menu.ts index 6a178021..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 (cyan + bold — matches console interactive accent); 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.cyan}${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,33 +67,26 @@ export function formatMenuRows( title: string ): string[] { const width = Math.max(20, columns); - const lines: string[] = []; - - // Title: bold header at the TOP (default ink — only the selected row is cyan). - 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) { @@ -146,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, + }); } /** @@ -178,6 +133,11 @@ export interface IInlineMenuDeps { 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; } /** @@ -216,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/wizard.ts b/packages/core/src/render/wizard.ts index cddf322e..8af8a299 100644 --- a/packages/core/src/render/wizard.ts +++ b/packages/core/src/render/wizard.ts @@ -1,6 +1,16 @@ 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, @@ -15,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 ─────────────────────────── @@ -425,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)}` + ), "", ]; } @@ -441,50 +465,91 @@ function optionRow( opt: IWizardOption, active: boolean, marker: string, - color: boolean + color: boolean, + columns: number ): string { - const gutter = active ? paint("›", STYLE.cyan, color) : " "; - const label = paint(opt.label, active ? STYLE.cyan : 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 @@ -501,7 +566,7 @@ function textFieldRows( : step.mask === true ? "•".repeat(raw.length) : raw; - const field = `${shown}${paint("▏", STYLE.cyan, 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)]; @@ -512,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); @@ -521,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( @@ -537,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"). */ @@ -593,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 @@ -614,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]; @@ -630,7 +732,9 @@ export function renderFrame( color, visiblePosition(steps, state, state.stepIndex), visibleTotal(steps, state), - title + title, + columns, + viewportRows ); } @@ -704,6 +808,10 @@ export interface IRunWizardOpts { * 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; } /** @@ -769,7 +877,20 @@ export function runWizard( } const draw = (): void => { - const frame = 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")); diff --git a/packages/core/src/setup/run-setup.ts b/packages/core/src/setup/run-setup.ts index 1f83be16..1c2b8723 100644 --- a/packages/core/src/setup/run-setup.ts +++ b/packages/core/src/setup/run-setup.ts @@ -22,9 +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 / status bar). When set, the wizard skips - * its nested alt-screen and paints through the REPL chrome instead. */ + /** 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 = @@ -119,6 +123,10 @@ export async function runSetup(opts: IRunSetupOptions): Promise { : { 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/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/frame-tui.test.ts b/packages/core/tests/frame-tui.test.ts index bb7a44b1..1e930070 100644 --- a/packages/core/tests/frame-tui.test.ts +++ b/packages/core/tests/frame-tui.test.ts @@ -8,6 +8,7 @@ import { Scrollback, computeLayout, canUsePaneTui, + panelWidthFor, PaneScreen, ENTER_ALT, EXIT_ALT, @@ -18,12 +19,15 @@ import { 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 { @@ -245,6 +249,7 @@ describe("computeLayout", () => { 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); @@ -252,6 +257,14 @@ describe("computeLayout", () => { 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); @@ -804,6 +817,56 @@ describe("PaneScreen", () => { 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( + { + goal: "PLAN.md", + features: [ + { + id: "a", + desc: "Accept a one-line description via argument or interactive prompt.", + passes: false, + attempts: 0, + }, + { + id: "b", + desc: "Create a flat list of independent verifiable features.", + passes: false, + attempts: 0, + }, + ], + }, + { 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); @@ -827,6 +890,40 @@ describe("PaneScreen", () => { 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); 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/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/repl-work.test.ts b/packages/core/tests/repl-work.test.ts new file mode 100644 index 00000000..ce840c9f --- /dev/null +++ b/packages/core/tests/repl-work.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { approvePlan } from "../src/cli/repl-work"; + +describe("approvePlan", () => { + test("cancels when no asker is available (non-interactive)", async () => { + const out: string[] = []; + + await expect( + approvePlan((s) => out.push(s), "checklist", null) + ).resolves.toBe("cancel"); + expect(out.join("")).toContain("non-interactive"); + }); + + test("uses askApprove callback (pane editor path)", async () => { + const out: string[] = []; + + await expect( + approvePlan( + (s) => out.push(s), + "- [ ] one\n", + async () => "approve" + ) + ).resolves.toBe("approve"); + expect(out.join("")).toContain("Proposed worklist"); + expect(out.join("")).not.toContain("non-interactive"); + }); + + test("propagates cancel from asker", async () => { + await expect( + approvePlan( + () => undefined, + "x", + async () => "cancel" + ) + ).resolves.toBe("cancel"); + }); +}); diff --git a/packages/core/tests/wizard.test.ts b/packages/core/tests/wizard.test.ts index 19f4e0fd..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"); }); @@ -278,7 +278,7 @@ describe("runWizard interactive teardown", () => { }); expect(renders.length).toBe(1); - expect(renders[0]?.some((l) => l.includes("› bare PascalCase"))).toBe( + expect(renders[0]?.some((l) => l.includes("▸ bare PascalCase"))).toBe( true ); // Nested alt-screen would fight PaneScreen — view mode must stay on host chrome. @@ -286,7 +286,7 @@ describe("runWizard interactive teardown", () => { fake.emit("keypress", undefined, { name: "down" }); expect(renders.length).toBe(2); - expect(renders[1]?.some((l) => l.includes("› I-prefix"))).toBe(true); + expect(renders[1]?.some((l) => l.includes("▸ I-prefix"))).toBe(true); fake.emit("keypress", undefined, { name: "escape" }); const state = await done; diff --git a/packages/core/tests/worklist-panel.test.ts b/packages/core/tests/worklist-panel.test.ts index 4d4a91e4..7b37a148 100644 --- a/packages/core/tests/worklist-panel.test.ts +++ b/packages/core/tests/worklist-panel.test.ts @@ -1,61 +1,122 @@ import { test, expect, describe } from "bun:test"; import { formatWorklistLines, worklistBadge } from "../src/loop/worklist/panel"; import type { IGreenfieldState } from "../src/loop/greenfield"; +import { stripSgr } from "../src/render/frame"; +import { CONSOLE } from "../src/render/frame/chrome"; -function state(features: IGreenfieldState["features"]): IGreenfieldState { - return { goal: "g", features }; +function state( + features: IGreenfieldState["features"], + goal = "g" +): IGreenfieldState { + return { goal, features }; +} + +function plain(lines: readonly string[]): string[] { + return lines.map(stripSgr); } describe("formatWorklistLines", () => { - test("shows count, current item with [>], and pending", () => { + test("shows goal cue, current ▸, pending ○ — no worklist N/M header", () => { + const lines = formatWorklistLines( + state( + [ + { id: "a", desc: "First", passes: true, attempts: 1 }, + { id: "b", desc: "Second", passes: false, attempts: 0 }, + { id: "c", desc: "Third", passes: false, attempts: 0 }, + { id: "d", desc: "Fourth", passes: false, attempts: 0 }, + ], + "PLAN.md" + ), + { maxPending: 2, columns: 36, color: false } + ); + const text = plain(lines); + + expect(text[0]).toBe("PLAN.md"); + expect(text.some((l) => l.startsWith("worklist"))).toBe(false); + expect(text).toContain("✓ First"); + expect(text).toContain("▸ Second"); + expect(text).toContain("○ Third"); + expect(text).toContain("○ Fourth"); + }); + + test("wraps long descriptions to columns", () => { + const columns = 20; const lines = formatWorklistLines( - state([ - { id: "a", desc: "First", passes: true, attempts: 1 }, - { id: "b", desc: "Second", passes: false, attempts: 0 }, - { id: "c", desc: "Third", passes: false, attempts: 0 }, - { id: "d", desc: "Fourth", passes: false, attempts: 0 }, - ]), - { maxPending: 2 } + state( + [ + { + id: "a", + desc: "Accept a one-line goal and produce a sprint checklist", + passes: false, + attempts: 0, + }, + ], + "worklist" + ), + { columns, color: false } ); + const text = plain(lines); + const current = text.find((l) => l.startsWith("▸ ")); + + expect(current).toBeDefined(); + expect(text.length).toBeGreaterThan(1); + expect(text.some((l) => l.startsWith(" "))).toBe(true); - expect(lines[0]).toBe("worklist 1/4"); - expect(lines[1]).toBe("[>] Second"); - expect(lines[2]).toBe("[ ] Third"); - expect(lines[3]).toBe("[ ] Fourth"); + for (const line of text) { + expect(line.length).toBeLessThanOrEqual(columns); + } + + expect(text.join(" ")).toContain("checklist"); + expect(text.join("\n")).not.toMatch(/checklis\n/u); }); - test("empty state points at /work", () => { - expect(formatWorklistLines(state([]))).toEqual([ - "worklist", - "/work to start", - ]); + test("empty state points at /work PLAN.md", () => { + expect( + plain(formatWorklistLines(state([], "worklist"), { color: false })) + ).toEqual(["/work PLAN.md", "or /work "]); }); test("all done and parked-only copy", () => { expect( + plain( + formatWorklistLines( + state([{ id: "a", desc: "A", passes: true, attempts: 1 }]), + { color: false } + ) + ) + ).toContain("All done."); + + const parked = plain( formatWorklistLines( - state([{ id: "a", desc: "A", passes: true, attempts: 1 }]) - )[1] - ).toBe("All done."); - - const parked = formatWorklistLines( - state([ - { id: "a", desc: "A", passes: true, attempts: 1 }, - { id: "b", desc: "B", passes: false, attempts: 2, parked: true }, - ]) + state([ + { id: "a", desc: "A", passes: true, attempts: 1 }, + { id: "b", desc: "B", passes: false, attempts: 2, parked: true }, + ]), + { color: false } + ) + ); + + expect(parked.some((l) => l.includes("Parked 1"))).toBe(true); + expect(parked.some((l) => l.startsWith("~ "))).toBe(true); + }); + + test("selection prefix when focused skips double ▸ on current", () => { + const lines = formatWorklistLines( + state([{ id: "a", desc: "A", passes: false, attempts: 0 }], "worklist"), + { showSelection: true, selectedIndex: 0, color: false } ); - expect(parked[1]).toBe("Parked 1 — revisit"); + expect(plain(lines)[0]?.startsWith("▸ ")).toBe(true); + expect(plain(lines)[0]?.startsWith("▸ ▸")).toBe(false); }); - test("selection prefix when focused", () => { + test("color mode paints current with CONSOLE.bright", () => { const lines = formatWorklistLines( - state([{ id: "a", desc: "A", passes: false, attempts: 0 }]), - { showSelection: true, selectedIndex: 1 } + state([{ id: "a", desc: "Now", passes: false, attempts: 0 }], "worklist"), + { columns: 36, color: true } ); - expect(lines[1]?.startsWith("▸ ")).toBe(true); - expect(lines[0]?.startsWith(" ")).toBe(true); + expect(lines.some((l) => l.includes(CONSOLE.bright))).toBe(true); }); test("worklistBadge is done/total", () => { diff --git a/scripts/e2e-help-menu-pty.py b/scripts/e2e-help-menu-pty.py index 1483ef6f..5789c0de 100644 --- a/scripts/e2e-help-menu-pty.py +++ b/scripts/e2e-help-menu-pty.py @@ -2,7 +2,7 @@ """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 styling still uses brand+bold after scroll. + 2. Selection uses pane CONSOLE.bright (green ▸). 3. Selecting a command runs it (no // double-slash regression). Uses readline input (TSFORGE_BASIC_INPUT) so `/help` submits as a slash command @@ -23,8 +23,8 @@ 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(): @@ -57,8 +57,8 @@ def main(): t.check("footer stays visible after scroll", "esc close" in buf) t.check( - "selected row uses brand+bold styling", - buf.count(BRAND_BOLD) >= 1, + "selected row uses CONSOLE.bright (green)", + buf.count(SELECT_GREEN) >= 1 and "▸" in buf, ) os.write(m, b"\x1b") # close /help From 6a64542d91ebb6f8f636a1cfd7fad9cf28f6868e Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sun, 9 Aug 2026 10:57:34 +0200 Subject: [PATCH 5/8] feat: session-bound checklist plans with present_plan and task tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace /work greenfield driving with plan-mode present → approve → nested UUID plans. Status is tools-only; task_complete requires a green gate, and Phase B keeps the session open until the active plan is fully done. --- apps/docs/src/content/docs/big-picture.mdx | 2 +- .../docs/src/content/docs/cli/interactive.mdx | 6 +- apps/docs/src/content/docs/cli/plan-mode.mdx | 73 ++- .../docs/src/content/docs/loop/greenfield.mdx | 2 + .../src/content/docs/reference/commands.mdx | 2 +- packages/core/ARCHITECTURE.md | 45 +- packages/core/src/agent/agent.constants.ts | 124 ++++ packages/core/src/cli.ts | 121 ---- packages/core/src/cli/args.ts | 13 - packages/core/src/cli/commands.ts | 8 +- packages/core/src/cli/repl-work.ts | 371 ------------ packages/core/src/cli/repl.ts | 191 ++++--- packages/core/src/cli/worklist-deps.ts | 67 --- packages/core/src/loop/index.ts | 19 +- packages/core/src/loop/model-call.ts | 30 +- packages/core/src/loop/session.ts | 306 +++++++++- packages/core/src/loop/tools/execute-tool.ts | 12 + .../core/src/loop/tools/present-plan-tool.ts | 76 +++ packages/core/src/loop/tools/task-tools.ts | 239 ++++++++ packages/core/src/loop/tools/tool-context.ts | 8 + packages/core/src/loop/turn.ts | 36 +- .../core/src/loop/worklist/checklist-store.ts | 536 ++++++++++++++++++ .../core/src/loop/worklist/checklist.types.ts | 54 ++ packages/core/src/loop/worklist/index.ts | 41 +- packages/core/src/loop/worklist/panel.ts | 257 +++++++-- packages/core/src/loop/worklist/parse.ts | 18 +- packages/core/src/loop/worklist/seed.ts | 322 +++++++++++ packages/core/src/policy/classify.ts | 8 + packages/core/src/render/frame/pane-screen.ts | 3 +- packages/core/src/session-store.ts | 7 + packages/core/tests/checklist-nudge.test.ts | 26 + packages/core/tests/checklist-store.test.ts | 198 +++++++ packages/core/tests/cli.test.ts | 3 + packages/core/tests/frame-tui.test.ts | 23 +- packages/core/tests/model-call.test.ts | 31 + packages/core/tests/present-plan-tool.test.ts | 71 +++ packages/core/tests/repl-work.test.ts | 37 -- packages/core/tests/session-store.test.ts | 16 + packages/core/tests/tools-gating.test.ts | 16 + packages/core/tests/worklist-panel.test.ts | 181 +++--- packages/core/tests/worklist-parse.test.ts | 10 +- packages/core/tests/worklist-seed.test.ts | 141 +++++ 42 files changed, 2822 insertions(+), 928 deletions(-) delete mode 100644 packages/core/src/cli/repl-work.ts delete mode 100644 packages/core/src/cli/worklist-deps.ts create mode 100644 packages/core/src/loop/tools/present-plan-tool.ts create mode 100644 packages/core/src/loop/tools/task-tools.ts create mode 100644 packages/core/src/loop/worklist/checklist-store.ts create mode 100644 packages/core/src/loop/worklist/checklist.types.ts create mode 100644 packages/core/src/loop/worklist/seed.ts create mode 100644 packages/core/tests/checklist-nudge.test.ts create mode 100644 packages/core/tests/checklist-store.test.ts create mode 100644 packages/core/tests/present-plan-tool.test.ts delete mode 100644 packages/core/tests/repl-work.test.ts create mode 100644 packages/core/tests/worklist-seed.test.ts 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 eacd1982..120e1dcd 100644 --- a/packages/core/ARCHITECTURE.md +++ b/packages/core/ARCHITECTURE.md @@ -2,7 +2,7 @@ -Derived from `packages/core/src`: **30 subsystems**, **575 files**, **105613 lines**, **137 cross-subsystem edges**. +Derived from `packages/core/src`: **30 subsystems**, **577 files**, **106549 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 | 110 | 31095 | 7 | 22 | +| `loop` | The drive-to-green engine: turns, tools, gate settling, steering, adapters | core | 114 | 32564 | 7 | 22 | | `rule-packs` | The ESLint rule packs the gate enforces, grouped by stack | core | 164 | 19000 | 3 | 3 | -| `render` | Terminal UI — status bar, menus, wizards, markdown, diffs, spinners | core | 40 | 9395 | 7 | 5 | -| `cli` | Argument parsing, the interactive REPL, and per-mode wiring | core | 22 | 7481 | 2 | 19 | +| `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 | 6991 | 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 | 2707 | 8 | 9 | | `scaffold` | Stands up a new project from an archetype and configures its gate | optional | 15 | 2526 | 3 | 2 | -| `(root)` | CLI entry, model registry, session persistence — the loose files in src/ | core | 6 | 2436 | 5 | 15 | | `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 | 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 | 1256 | 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 | @@ -39,7 +39,7 @@ inventory, see the hand-drawn map on [Internals](/internals/). | `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 | 553 | 2 | 5 | -| `validate` | Runs the gate command and parses tool output into structured errors | core | 7 | 475 | 8 | 2 | +| `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 | | `constitution` ⚠️ | Baseline system-role text and the reference ESLint constitution | optional | 1 | 267 | 0 | 0 | @@ -56,9 +56,9 @@ buries the ones someone can actually go and break. | Pair | One way | The other | | --- | --- | --- | -| `(root)` ↔ `cli` | `cli.ts:22` → `./cli/worklist-deps` | `cli/config-menu.ts:10` → `../models-config` | +| `(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:21` → `./loop` | `loop/expert-handoff.ts:21` → `../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` | @@ -67,7 +67,7 @@ buries the ones someone can actually go and break. | `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:2` → `../../render/frame/ansi-plain` | `render/agent-tree.ts:8` → `../loop/loop.types` | +| `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` | @@ -92,21 +92,20 @@ Async functions returning an exit code, declared under the CLI — the commands. | Function | Declared | | --- | --- | -| `agentsMode` | `cli.ts:356` | -| `greenfieldMode` | `cli.ts:653` | +| `agentsMode` | `cli.ts:346` | +| `greenfieldMode` | `cli.ts:643` | | `harnessDiagnoseMode` | `cli/harness-diagnose-mode.ts:211` | | `harnessReviewMode` | `cli/harness-review-mode.ts:684` | -| `main` | `cli.ts:871` | -| `mapMode` | `cli.ts:491` | -| `recipesMode` | `cli.ts:510` | -| `repl` | `cli/repl.ts:601` | -| `reviewMode` | `cli.ts:191` | -| `runOnce` | `cli.ts:103` | +| `main` | `cli.ts:754` | +| `mapMode` | `cli.ts:481` | +| `recipesMode` | `cli.ts:500` | +| `repl` | `cli/repl.ts:605` | +| `reviewMode` | `cli.ts:181` | +| `runOnce` | `cli.ts:93` | | `runTraceCommand` | `cli/repl-commands.ts:109` | -| `scaffoldMode` | `cli.ts:842` | -| `setupMode` | `cli.ts:499` | -| `traceMode` | `cli.ts:561` | -| `worklistMode` | `cli.ts:732` | +| `scaffoldMode` | `cli.ts:725` | +| `setupMode` | `cli.ts:489` | +| `traceMode` | `cli.ts:551` | ## Imports that leave `src/` 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.ts b/packages/core/src/cli.ts index 3caa623a..06b6fccf 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -8,19 +8,9 @@ import { runGreenfield, prepareState, planFeatures, - hasState, - prepareWorklistState, - runWorklist, - acceptMapOf, - parseWorklist, - resolveWorklistPath, - tickWorklistFile, - WORKLIST_STATE, type IGreenfieldDeps, type Reporter, } from "./loop"; -import { createWorklistDeps } from "./cli/worklist-deps"; -import { readFile } from "node:fs/promises"; import { modelAgent, AgentRunner, type IAgentResult } from "./agent"; import { AgentScheduler } from "./agent/agent-scheduler"; import { loadAgentSpecs, findAgentSpec } from "./config/agent-specs"; @@ -724,113 +714,6 @@ async function greenfieldMode(args: ICliArgs): Promise { return result.status === "done" ? 0 : 1; } -/** - * `tsforge --work [PLAN.md] --accept ""`: drive a human-written checklist - * to completion (or park leftovers after one revisit). Resumes `.tsforge/worklist/` - * when present. - */ -async function worklistMode(args: ICliArgs): Promise { - if (args.accept.length === 0) { - process.stdout.write( - "worklist needs a build gate — pass --accept '' or set `gate` in the recipe\n" - ); - - return 1; - } - - const pathHint = args.task.length > 0 ? args.task : undefined; - let sourcePath = - pathHint !== undefined - ? await resolveWorklistPath(args.dir, pathHint) - : await resolveWorklistPath(args.dir); - - if (!(await hasState(args.dir, WORKLIST_STATE)) && sourcePath === null) { - process.stdout.write( - "no worklist found — pass a path (tsforge --work PLAN.md) or add PLAN.md / TASKS.md\n" - ); - - return 1; - } - - const state = await prepareWorklistState(args.dir, { - goal: "worklist", - ...(sourcePath !== null ? { path: sourcePath } : {}), - }); - - if (state === null) { - process.stdout.write("worklist is empty — nothing to build\n"); - - return 1; - } - - // On resume the source path may still be discoverable for --tick / accepts. - sourcePath ??= await resolveWorklistPath(args.dir, pathHint); - - let accepts = new Map(); - - if (sourcePath !== null) { - try { - accepts = acceptMapOf( - parseWorklist(await readFile(sourcePath, "utf8"), { - includeDone: true, - }) - ); - } catch { - accepts = new Map(); - } - } - - const roleName = (specific: string): string => - specific.length > 0 ? specific : args.model; - const work = makeProvider( - (await resolveModelByName(roleName(args.workModel))).entry - ); - const evaluator = makeProvider( - (await resolveModelByName(roleName(args.evaluatorModel))).entry - ); - const report = makeReporter(resolveLogPath("worklist", args.log), "worklist"); - const thinkingTokenBudget = - args.thinkingBudget > 0 - ? args.thinkingBudget - : envNumber("TSFORGE_THINKING_BUDGET"); - - const deps = createWorklistDeps({ - cwd: args.dir, - accept: args.accept, - accepts, - scope: scopeOf(args), - work, - evaluator, - report, - ...(thinkingTokenBudget === undefined ? {} : { thinkingTokenBudget }), - ...(args.maxTurns > 0 ? { maxTurns: args.maxTurns } : {}), - }); - - const result = await runWorklist(args.dir, state, deps, { onEvent: report }); - - if (args.tick && sourcePath !== null) { - await tickWorklistFile(sourcePath, result.features); - } - - const done = result.features.filter((f) => f.passes).length; - const statusMsg = - result.status === "done" - ? "✓ all worklist items verified" - : result.status === "needs-infra" - ? `✗ infrastructure unavailable: ${result.infra ?? "?"}` - : `✗ stuck on '${result.stuckFeature ?? "?"}'`; - - process.stdout.write(`\n${statusMsg} (${done}/${result.features.length})\n`); - - await runNotify( - args.dir, - args.notify, - `worklist ${result.status} ${done}/${result.features.length}` - ); - - return result.status === "done" ? 0 : 1; -} - /** * `tsforge scaffold …` — greenfield wizard that stands up boringstack (or its * Astro static site). Delegates the remaining argv to the scaffold command's own @@ -972,10 +855,6 @@ export async function main(): Promise { return greenfieldMode(args); } - if (args.work) { - return worklistMode(args); - } - // A positional task with a scope + gate ⇒ one-shot; otherwise interactive. return isOneShot(args) ? runOnce(args) : repl(args); } diff --git a/packages/core/src/cli/args.ts b/packages/core/src/cli/args.ts index d112c8a1..c4b2585c 100644 --- a/packages/core/src/cli/args.ts +++ b/packages/core/src/cli/args.ts @@ -54,11 +54,6 @@ export interface ICliArgs { /** Run the greenfield feature-checklist outer loop (`--greenfield`, or a recipe * with `mode: "greenfield"`). `task` carries the one-line build goal. */ greenfield: boolean; - /** Run a human-written worklist (`--work`). `task` is an optional list path; - * when empty, looks up PLAN.md → TASKS.md → .specs/next.md. */ - work: boolean; - /** Opt-in rewrite of the human checklist file as items pass (`--tick`). */ - tick: boolean; /** Shell command to run on completion of an unattended run (`--notify `), * with the outcome in $TSFORGE_STATUS. "" = no notification. */ notify: string; @@ -118,8 +113,6 @@ const BOOL_FLAGS: Record< | "withReview" | "scout" | "greenfield" - | "work" - | "tick" | "setupYes" | "version" | "help" @@ -136,8 +129,6 @@ const BOOL_FLAGS: Record< "--with-review": "withReview", "--scout": "scout", "--greenfield": "greenfield", - "--work": "work", - "--tick": "tick", "--yes": "setupYes", "--version": "version", "-V": "version", @@ -232,8 +223,6 @@ export function cliUsage(): string { " --policy-mode plan|default|acceptEdits|ci|dontAsk|bypassPermissions", ` --profile strictness: ${PROFILE_IDS.join("|")}`, " --notify run a command when an unattended run finishes", - " --work [path] drive a checklist (PLAN.md / TASKS.md / path)", - " --tick rewrite the human checklist as items pass", " --version, -V print the version and exit", " --help, -h this help", "", @@ -266,8 +255,6 @@ export function parseArgs(argv: readonly string[]): ICliArgs { withReview: false, scout: false, greenfield: false, - work: false, - tick: false, notify: "", base: "", map: false, diff --git a/packages/core/src/cli/commands.ts b/packages/core/src/cli/commands.ts index 2eb783cc..d877584e 100644 --- a/packages/core/src/cli/commands.ts +++ b/packages/core/src/cli/commands.ts @@ -31,13 +31,7 @@ export const COMMANDS: readonly ICommandSpec[] = [ { name: "/plan", summary: - "toggle plan mode (on by default: explore → clarify → plan; 'approve' implements)", - }, - { - name: "/work", - arg: "[file|goal]", - summary: - "drive a checklist (PLAN.md / file) to completion, or plan one from a goal", + "toggle plan mode (on by default: explore → checklist → approve saves + implements)", }, { name: "/copy", diff --git a/packages/core/src/cli/repl-work.ts b/packages/core/src/cli/repl-work.ts deleted file mode 100644 index 655a5310..00000000 --- a/packages/core/src/cli/repl-work.ts +++ /dev/null @@ -1,371 +0,0 @@ -/** - * `/work` REPL flow: resume or parse a checklist, optionally plan from a goal, - * then drive `runWorklist` with a fresh task per item. - */ -import { access, readFile } from "node:fs/promises"; -import { isAbsolute, join } from "node:path"; -import type { createInterface } from "node:readline/promises"; -import type { OpenAICompatibleProvider } from "../inference"; -import type { IModelEntry } from "../models-config"; -import { resolveCapabilityModel, resolveModelByName } from "../models-config"; -import { - hasState, - loadState, - planFeatures, - renderProgress, - type IGreenfieldState, -} from "../loop/greenfield"; -import { - acceptMapOf, - parseWorklist, - prepareWorklistState, - resolveWorklistPath, - runWorklist, - tickWorklistFile, - WORKLIST_STATE, -} from "../loop/worklist"; -import type { IWorklistItem } from "../loop/worklist"; -import type { Reporter } from "../loop"; -import { makeProvider, envNumber } from "./model-setup"; -import { makeReporter } from "./logging"; -import { scopeOf, type ICliArgs } from "./args"; -import { createWorklistDeps } from "./worklist-deps"; - -type Rl = ReturnType | null; - -export interface IRunWorkCommandOpts { - args: ICliArgs; - arg: string; - echo: (s: string) => void; - /** - * Classic readline (null when the multiline editor owns stdin). Prefer - * {@link askApprove} under the pane console — `rl.question` is unavailable there. - */ - rl: Rl; - /** - * Interactive approve/cancel when `rl` is null (pane editor). Overlay menus, - * etc. When both `rl` and this are absent, planning cancels as non-interactive. - */ - askApprove?: () => Promise<"approve" | "cancel">; - workProvider: OpenAICompatibleProvider; - activeModelEntry: IModelEntry; - /** Session gate command (may be empty). */ - gate: string; - /** Opt-in tick of the human file. */ - tick?: boolean; - logFile: string; - id: string; - /** Push worklist slot lines into the live region (Phase 2). */ - onProgress?: (state: IGreenfieldState) => void; -} - -async function pathExists(path: string): Promise { - try { - await access(path); - - return true; - } catch { - return false; - } -} - -async function resolveArgPath( - cwd: string, - arg: string -): Promise { - if (arg.length === 0) { - return resolveWorklistPath(cwd); - } - - const candidate = isAbsolute(arg) ? arg : join(cwd, arg); - - if (await pathExists(candidate)) { - return candidate; - } - - return null; -} - -/** Prompt for plan approval. Exported for unit tests. */ -export async function approvePlan( - echo: (s: string) => void, - checklist: string, - ask: (() => Promise<"approve" | "cancel">) | null -): Promise<"approve" | "cancel"> { - echo(`\nProposed worklist:\n${checklist}\n`); - echo("Approve this list? (approve/cancel)\n"); - - if (ask === null) { - echo("(non-interactive — cancelling)\n"); - - return "cancel"; - } - - return ask(); -} - -function approveAskFromOpts( - opts: IRunWorkCommandOpts -): (() => Promise<"approve" | "cancel">) | null { - if (opts.askApprove !== undefined) { - return opts.askApprove; - } - - const { rl } = opts; - - if (rl === null) { - return null; - } - - return async () => { - const answer = (await rl.question("> ")).trim().toLowerCase(); - - return answer === "approve" || answer === "approved" || answer === "go" - ? "approve" - : "cancel"; - }; -} - -/** - * Plan a worklist from a free-text goal and ask for approval. - * Persistence is left to the caller. - */ -async function planFromGoal( - opts: IRunWorkCommandOpts, - goal: string -): Promise { - const { echo, activeModelEntry } = opts; - - echo("▸ planning a worklist from your goal...\n"); - - const plannerResolved = await resolveCapabilityModel("planner"); - const planner = makeProvider(plannerResolved?.entry ?? activeModelEntry); - const planned = await planFeatures(planner, goal); - - if (planned === null || planned.features.length === 0) { - echo("planner produced no items — nothing to run\n"); - - return null; - } - - const preview = renderProgress({ - goal, - features: planned.features, - }); - - if ( - (await approvePlan(echo, preview, approveAskFromOpts(opts))) !== "approve" - ) { - echo("worklist cancelled\n"); - - return null; - } - - return planned.features.map((f) => ({ - id: f.id, - text: f.desc, - done: false, - })); -} - -/** Load accepts from a source markdown file (best-effort on resume). */ -async function acceptsFromFile( - path: string | null -): Promise> { - if (path === null) { - return new Map(); - } - - try { - const items = parseWorklist(await readFile(path, "utf8"), { - includeDone: true, - }); - - return acceptMapOf(items); - } catch { - return new Map(); - } -} - -interface IResolvedWorklist { - state: IGreenfieldState; - sourcePath: string | null; - accepts: Map; -} - -/** Persist planned items under `.tsforge/worklist/` and return the resolved start. */ -async function persistPlannedItems( - opts: IRunWorkCommandOpts, - items: IWorklistItem[], - sourcePath: string | null, - goal: string -): Promise { - const state = await prepareWorklistState(opts.args.dir, { goal, items }); - - return state === null - ? null - : { state, sourcePath, accepts: acceptMapOf(items) }; -} - -/** File had no checklist markers — ask the planner to extract items from prose. */ -async function planFromNarrativeFile( - opts: IRunWorkCommandOpts, - asPath: string -): Promise { - const md = (await readFile(asPath, "utf8")).trim(); - const items = await planFromGoal( - opts, - md.length > 0 ? md.slice(0, 12_000) : opts.arg - ); - - return items === null - ? null - : persistPlannedItems(opts, items, asPath, opts.arg); -} - -async function resolveWorklistStart( - opts: IRunWorkCommandOpts, - asPath: string | null, - isGoal: boolean -): Promise { - const cwd = opts.args.dir; - - if (await hasState(cwd, WORKLIST_STATE)) { - const state = await prepareWorklistState(cwd, { goal: "worklist" }); - - return state === null - ? null - : { state, sourcePath: asPath, accepts: new Map() }; - } - - if (!isGoal) { - const state = await prepareWorklistState(cwd, { - goal: "worklist", - ...(asPath !== null ? { path: asPath } : {}), - }); - - if (state !== null) { - return { - state, - sourcePath: asPath ?? (await resolveWorklistPath(cwd)), - accepts: new Map(), - }; - } - - return asPath === null ? null : planFromNarrativeFile(opts, asPath); - } - - const items = await planFromGoal(opts, opts.arg); - - return items === null - ? null - : persistPlannedItems(opts, items, null, opts.arg); -} - -function stuckMessage(result: Awaited>): string { - if (result.status === "done") { - return "✓ all worklist items verified"; - } - - if (result.status === "needs-infra") { - return `✗ infrastructure unavailable: ${result.infra ?? "?"}`; - } - - const parkedIds = result.features - .filter((f) => f.parked === true) - .map((f) => f.id); - const parked = - parkedIds.length > 0 ? parkedIds.join(", ") : (result.stuckFeature ?? "?"); - - return `✗ stuck — parked: ${parked}`; -} - -/** Execute `/work [file|goal]`. */ -export async function runWorkCommand(opts: IRunWorkCommandOpts): Promise { - const { args, arg, echo, workProvider, gate, logFile, id } = opts; - const cwd = args.dir; - const asPath = await resolveArgPath(cwd, arg); - const isGoal = arg.length > 0 && asPath === null; - const resolved = await resolveWorklistStart(opts, asPath, isGoal); - - if (resolved === null) { - echo( - "no worklist found — add PLAN.md / TASKS.md, pass a file, or `/work `\n" - ); - - return; - } - - const { state, sourcePath } = resolved; - let { accepts } = resolved; - - if (accepts.size === 0) { - accepts = await acceptsFromFile(sourcePath); - } - - if ((gate.length === 0 || gate === "true") && accepts.size === 0) { - echo( - "worklist needs a gate — `/gate ''` or per-item `accept:` in the list\n" - ); - - return; - } - - echo( - `▸ worklist: ${state.features.filter((f) => f.passes).length}/${state.features.length} done — driving remaining items\n` - ); - - const evaluatorName = - opts.args.evaluatorModel.length > 0 - ? opts.args.evaluatorModel - : opts.args.model; - const evaluator = makeProvider( - evaluatorName.length > 0 - ? (await resolveModelByName(evaluatorName)).entry - : opts.activeModelEntry - ); - const baseReport = makeReporter(logFile, id, `${id}-work`); - const thinkingTokenBudget = envNumber("TSFORGE_THINKING_BUDGET"); - - opts.onProgress?.(state); - - const report: Reporter = (event) => { - baseReport(event); - - if (opts.onProgress === undefined) { - return; - } - - void loadState(cwd, WORKLIST_STATE).then((latest) => { - if (latest !== null) { - opts.onProgress?.(latest); - } - }); - }; - - const deps = createWorklistDeps({ - cwd, - accept: gate, - accepts, - scope: scopeOf(args), - work: workProvider, - evaluator, - report, - ...(thinkingTokenBudget === undefined ? {} : { thinkingTokenBudget }), - ...(args.maxTurns > 0 ? { maxTurns: args.maxTurns } : {}), - }); - - const result = await runWorklist(cwd, state, deps, { onEvent: report }); - - opts.onProgress?.({ ...state, features: result.features }); - - if (opts.tick === true && sourcePath !== null) { - await tickWorklistFile(sourcePath, result.features); - } - - const done = result.features.filter((f) => f.passes).length; - - echo( - `\n${stuckMessage(result)} (${done}/${result.features.length}) — see .tsforge/${WORKLIST_STATE}/progress.md\n` - ); -} diff --git a/packages/core/src/cli/repl.ts b/packages/core/src/cli/repl.ts index 82fe5c2c..ab0741cd 100644 --- a/packages/core/src/cli/repl.ts +++ b/packages/core/src/cli/repl.ts @@ -130,14 +130,17 @@ import { runReviewCommand, runTraceCommand, } from "./repl-commands"; -import { runWorkCommand } from "./repl-work"; import { - WORKLIST_STATE, formatWorklistLines, + formatPlanProposal, worklistBadge, + pendingPlanBadge, + seedWorklistFromPlan, + persistPlanDocument, + goalFromMessages, + loadPlan, } from "../loop/worklist"; -import { loadState } from "../loop/greenfield"; -import { runInlineMenu } from "../render/inline-menu"; +import type { IPlanDocument } from "../loop/worklist"; /** A unique-enough id for a new session (time + a little randomness). */ function newSessionId(): string { @@ -515,6 +518,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. @@ -657,6 +664,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], }); }; @@ -1028,13 +1036,44 @@ 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") { + const pending = session.takePendingPlan(); + let plan: IPlanDocument | null = null; + + if (pending !== null) { + plan = persistPlanDocument(args.dir, pending); + } else { + 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; + } + + plan = seeded.plan; + } + + 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)); @@ -1047,14 +1086,13 @@ export async function repl(args: ICliArgs): 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 planned = + const plannedHeading = last?.role === "assistant" && /^##\s*plan\b/im.test(last.content); - // Only nudge approve when a real plan was proposed — casual turns in - // plan mode already show ◆plan in the top strip; repeating the PLAN - // footer after every "sup" is noise. - if (planned) { + if (plannedHeading && session.getPendingPlan() === null) { const cols = panesLive() ? paneScreen.mainInnerCols() : process.stdout.columns > 0 @@ -1095,7 +1133,6 @@ export async function repl(args: ICliArgs): Promise { let openScaffold: () => Promise; // Assigned after the pane console exists (same pattern as handleHelp). let handleCopy: () => void = () => undefined; - let handleWork: (arg: string) => Promise = () => Promise.resolve(); // 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. @@ -1115,10 +1152,6 @@ export async function repl(args: ICliArgs): Promise { handleCopy(); break; - case "work": - await handleWork(arg); - break; - case "clear": { // Rebuild the session with the current state (config is not reused; // repl's /clear creates a fresh Session.create call) @@ -1128,6 +1161,7 @@ export async function repl(args: ICliArgs): Promise { // 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, @@ -1152,9 +1186,11 @@ export async function repl(args: ICliArgs): Promise { // 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)); @@ -1511,93 +1547,59 @@ export async function repl(args: ICliArgs): Promise { } }; - /** Paint the Tasks rail from a worklist state (or empty-rail hints). */ - const syncWorklistPanel = ( - workState: Awaited> - ): void => { + /** 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()); - const state = workState ?? { goal: "worklist", features: [] }; paneScreen.setPanel( - formatWorklistLines(state, { + formatWorklistLines(plan, { columns: cols, maxPending, color: true, }) ); - paneScreen.setWorklistBadge( - state.features.length > 0 ? worklistBadge(state) : "" - ); + paneScreen.setWorklistBadge(worklistBadge(plan)); syncPaneChrome(); }; - handleWork = async (workArg: string): Promise => { - await runWorkCommand({ - args, - arg: workArg, - echo: (s) => { - streamOut(s); - }, - rl, - // Pane editor leaves `rl` null — approve via the shared overlay menu. - askApprove: async () => { - editorControl?.suspend(); - editorControl?.setInputInert(true); - - try { - const picked = await runInlineMenu( - [ - { - id: "approve", - label: "approve", - describe: "Save this list and start driving remaining items", - }, - { - id: "cancel", - label: "cancel", - describe: "Discard the proposed worklist", - }, - ], - { - title: "Approve this worklist?", - render: (lines) => { - chrome.setOverlay(lines); - }, - close: () => { - chrome.clearOverlay(); - }, - columns: transcriptCols(), - viewportRows: overlayBudget(), - } - ); - - return picked === 0 ? "approve" : "cancel"; - } finally { - editorControl?.setInputInert(false); - editorControl?.resume(); - } - }, - workProvider: provider, - activeModelEntry, - gate: session.gate, - tick: args.tick, - logFile, - id, - onProgress: (workState) => { - syncWorklistPanel(workState); - }, + 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(); + } - // Keep the Tasks rail hydrated from disk after /work (do not wipe on exit). - syncWorklistPanel(await loadState(args.dir, WORKLIST_STATE)); + 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()) { @@ -2043,8 +2045,14 @@ export async function repl(args: ICliArgs): Promise { process.stdout.on("resize", handleResize); - // Restore the terminal even on an unexpected exit (leave 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", () => { + editorForExit?.close(); paneScreen.leave(); }); @@ -2693,6 +2701,7 @@ export async function repl(args: ICliArgs): Promise { }; editorControl = editorHandle; + editorForExit = editorHandle; editorHandle.onSubmit(submitLine); editorHandle.onInterrupt(() => { @@ -2764,12 +2773,14 @@ export async function repl(args: ICliArgs): Promise { } } - // Resume Tasks rail from a prior /work run (`.tsforge/worklist/`). - void loadState(args.dir, WORKLIST_STATE).then((workState) => { - if (panesLive()) { - syncWorklistPanel(workState); - } - }); + // Rehydrate Tasks rail from the session-bound plan (`activePlanId`). + const planId = session.getActivePlanId(); + const plan = + planId !== null ? loadPlan(args.dir, planId) : null; + + if (panesLive()) { + syncWorklistPanel(plan); + } }; if (interactiveTty) { diff --git a/packages/core/src/cli/worklist-deps.ts b/packages/core/src/cli/worklist-deps.ts deleted file mode 100644 index 64d658f9..00000000 --- a/packages/core/src/cli/worklist-deps.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { composeGate } from "../gate/gate-runner"; -import { validate } from "../validate"; -import type { OpenAICompatibleProvider } from "../inference"; -import { judgeStage } from "../loop/boringstack/gate-stages"; -import { runTask } from "../loop"; -import { RUN_STATUS } from "../loop/loop.constants"; -import type { Reporter, IFeature, IGreenfieldDeps } from "../loop"; - -export interface IWorklistDepsOptions { - cwd: string; - /** Session / CLI default gate. */ - accept: string; - /** Per-feature accept overrides (feature id → command). */ - accepts?: ReadonlyMap; - scope: string[]; - work: OpenAICompatibleProvider; - evaluator: OpenAICompatibleProvider; - report: Reporter; - maxTurns?: number; - thinkingTokenBudget?: number; -} - -/** - * Fresh `runTask` per worklist item — same shape as CLI `greenfieldDeps`, so a - * long list does not share one drifting transcript across items. - */ -export function createWorklistDeps( - opts: IWorklistDepsOptions -): IGreenfieldDeps { - return { - implement: async (feature: IFeature) => { - const accept = - opts.accepts?.get(feature.id) ?? - (opts.accept.length > 0 ? opts.accept : "true"); - - const base = { - id: feature.id, - intent: feature.desc, - accept, - files: opts.scope, - context: [], - }; - - const gate = composeGate([ - { - run: (cwd, gateOpts) => - validate(base, cwd, undefined, gateOpts ?? {}), - }, - judgeStage(opts.evaluator, opts.cwd, feature), - ]); - - const result = await runTask(base, opts.cwd, opts.work, { - onEvent: opts.report, - gate, - ...(opts.thinkingTokenBudget === undefined - ? {} - : { thinkingTokenBudget: opts.thinkingTokenBudget }), - ...(opts.maxTurns === undefined ? {} : { maxTurns: opts.maxTurns }), - }); - - return { - done: result.status === RUN_STATUS.done, - ...(result.handoff !== undefined ? { handoff: result.handoff } : {}), - }; - }, - }; -} diff --git a/packages/core/src/loop/index.ts b/packages/core/src/loop/index.ts index d019d015..84f142b3 100644 --- a/packages/core/src/loop/index.ts +++ b/packages/core/src/loop/index.ts @@ -37,8 +37,24 @@ export { 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 { IWorklistItem, IPrepareWorklistOptions } from "./worklist"; export type { IFeature, IGreenfieldState, @@ -62,6 +78,7 @@ export { export { Session, PLAN_APPROVED_NOTE, + checklistOpenNudge, 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..030bf18f 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,49 @@ 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).`; +} /** Default edits between incremental checks. */ const CHECK_EVERY = 3; @@ -830,6 +874,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 +934,44 @@ 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. - if (offerCheck) { + // Wire runCheckGate whenever a gate exists OR the callable `check` tool is + // offered. Interactive REPL sessions need this for task_complete (gate must + // be green before an item can be marked done) even when `check` itself is + // not advertised. Reads `this.ctx` LAZILY so a mid-build `setGate` swap is + // honored; never `validate(accept)` alone (vacuous-recheck trap). + if (offerCheck || this.hasGate) { this.ctx.tool.runCheck = () => 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 +1277,11 @@ export class Session { this.hasGate = true; } + // task_complete needs runCheck; wire it when a gate appears mid-session. + if (this.hasGate && this.ctx.tool.runCheck === undefined) { + this.ctx.tool.runCheck = () => runCheckGate(this.ctx); + } + this.refreshTaskContract(); } @@ -1266,6 +1336,187 @@ 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 +1903,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 +2473,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 +2514,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 +2886,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..09dedbdf --- /dev/null +++ b/packages/core/src/loop/tools/present-plan-tool.ts @@ -0,0 +1,76 @@ +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 +): unknown | 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..7fa3fa5c --- /dev/null +++ b/packages/core/src/loop/tools/task-tools.ts @@ -0,0 +1,239 @@ +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.runCheck === 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.runCheck(); + + 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..142a4ca2 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,13 @@ 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; + /** 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..a1380e76 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,16 @@ 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 +259,7 @@ export function toolsFor( ...conventions, ...check, ...askUser, + ...taskTools, ...web, ...git, ...script, @@ -253,6 +274,7 @@ export function toolsFor( ...conventions, ...check, ...askUser, + ...taskTools, ...LSP_TOOLS, ...web, ...git, @@ -329,6 +351,12 @@ 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"]; + /** 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 +2596,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..c3d896bb --- /dev/null +++ b/packages/core/src/loop/worklist/checklist-store.ts @@ -0,0 +1,536 @@ +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 { + let found = false; + + const walk = (nodes: readonly IChecklistItem[]): IChecklistItem[] => + nodes.map((item) => { + if (item.id === id) { + found = true; + + return updater(item); + } + + if (!item.children) { + return item; + } + + return { ...item, children: walk(item.children) }; + }); + + const next = walk(items); + + return found ? next : null; +} + +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) { + const active = findItem(plan.items, plan.activeItemId); + lines.push( + `active: ${active ? `${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 index 23fdf956..5ae4704c 100644 --- a/packages/core/src/loop/worklist/index.ts +++ b/packages/core/src/loop/worklist/index.ts @@ -12,6 +12,45 @@ export { tickWorklistFile, } from "./run"; export type { IPrepareWorklistOptions } from "./run"; -export { formatWorklistLines, worklistBadge } from "./panel"; +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 index 3f982b98..ba268632 100644 --- a/packages/core/src/loop/worklist/panel.ts +++ b/packages/core/src/loop/worklist/panel.ts @@ -1,7 +1,14 @@ -import type { IFeature, IGreenfieldState } from "../greenfield"; +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 { paint } from "../../render/style"; +import { + filledRoleBadge, + roleBadgeCols, + roleCardCols, + roleHairline, +} from "../../render/ansi"; +import { STYLE, paint } from "../../render/style"; import { displayWidth, sliceToWidth } from "../../render/width"; export interface IFormatWorklistLinesOptions { @@ -20,30 +27,113 @@ export interface IFormatWorklistLinesOptions { color?: boolean; } -type ItemKind = "done" | "current" | "pending" | "parked"; +type ItemKind = "done" | "current" | "pending" | "blocked"; const GLYPH: Record = { done: "✓", current: "▸", pending: "○", - parked: "~", + blocked: "!", }; const CONT_INDENT = " "; /** Compact badge for the top status strip, e.g. `3/7`. */ -export function worklistBadge(state: IGreenfieldState): string { - const total = state.features.length; +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 ""; } - const done = state.features.filter((f) => f.passes).length; - 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; } @@ -116,7 +206,7 @@ function paintGlyph(glyph: string, kind: ItemKind, color: boolean): string { return paint(glyph, CONSOLE.bright, true); } - if (kind === "parked") { + if (kind === "blocked") { return paint(glyph, CONSOLE.warn, true); } @@ -132,30 +222,63 @@ function paintBody(part: string, kind: ItemKind, color: boolean): string { return paint(part, CONSOLE.bright, true); } - if (kind === "parked") { + if (kind === "blocked") { return part; } return paint(part, CONSOLE.muted, true); } -/** One item: glyph + wrapped description (continuations indented). */ +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( - feature: IFeature, + item: IChecklistItem, kind: ItemKind, columns: number, - color: boolean + color: boolean, + depth: number, + extras: readonly string[] ): string[] { const glyph = GLYPH[kind]; const painted = paintGlyph(glyph, kind, color); - const budget = Math.max(4, columns - displayWidth(`${glyph} `)); - const parts = wrapWords(feature.desc.trim(), budget); - - return parts.map((part, i) => { + 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 ? `${painted} ${body}` : `${CONT_INDENT}${body}`; + 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( @@ -170,7 +293,7 @@ function applySelection( const plain = stripSgr(line); - if (plain.startsWith(GLYPH.current)) { + if (plain.trimStart().startsWith(GLYPH.current)) { return line; } @@ -179,65 +302,89 @@ function applySelection( } /** - * Tasks-rail body lines — goal cue + checklist from gate state only - * (never model narration). Sticky `Tasks N/M` title is painted separately. + * Tasks-rail body lines — goal cue + nested checklist for the session-bound plan. */ export function formatWorklistLines( - state: IGreenfieldState, + plan: IPlanDocument | null, opts: IFormatWorklistLinesOptions = {} ): string[] { const maxPending = opts.maxPending ?? 12; const columns = Math.max(12, opts.columns ?? 36); const color = opts.color !== false; - const total = state.features.length; - if (total === 0) { + if (plan === null || plan.items.length === 0) { return [ - paint("/work PLAN.md", CONSOLE.muted, color), - paint("or /work ", CONSOLE.muted, color), + paint("approve a plan", CONSOLE.muted, color), + paint("to fill this list", CONSOLE.muted, color), ]; } - const done = state.features.filter((f) => f.passes); - const current = state.features.find((f) => !f.passes && !(f.parked ?? false)); - const pending = state.features.filter( - (f) => !f.passes && !(f.parked ?? false) && f.id !== current?.id - ); - const parked = state.features.filter((f) => (f.parked ?? false) && !f.passes); - const lines: string[] = []; - const goal = state.goal.trim(); + const goal = plan.goal.trim(); - if (goal.length > 0 && goal !== "worklist") { + if (goal.length > 0) { lines.push(paint(clip(goal, columns), CONSOLE.muted, color)); } - for (const feature of done.slice(-2)) { - lines.push(...formatItemLines(feature, "done", columns, color)); - } + let pendingShown = 0; + let pendingHidden = 0; - if (current !== undefined) { - lines.push(...formatItemLines(current, "current", columns, color)); - } else if (done.length === total) { - lines.push(paint("All done.", CONSOLE.bright, color)); - } else if (parked.length > 0) { - lines.push( - paint(`Parked ${String(parked.length)} — revisit`, CONSOLE.warn, color) - ); - } + const walk = (nodes: readonly IChecklistItem[], depth: number): void => { + for (const item of nodes) { + const kind = kindOf(item, plan.activeItemId); + const isFocus = + kind === "current" || + (plan.activeItemId !== null && item.id === plan.activeItemId); - for (const feature of pending.slice(0, maxPending)) { - lines.push(...formatItemLines(feature, "pending", columns, color)); - } + if (kind === "pending" && !isFocus) { + if (pendingShown >= maxPending) { + pendingHidden += 1; + + if (item.children) { + walk(item.children, depth + 1); + } + + continue; + } + + pendingShown += 1; + } + + const extras: string[] = []; + + if (isFocus) { + if (item.verify !== undefined && item.verify.trim().length > 0) { + extras.push(`verify: ${item.verify.trim()}`); + } - if (pending.length > maxPending) { - const more = `… +${String(pending.length - maxPending)} more`; + if ( + item.blockedReason !== undefined && + item.blockedReason.trim().length > 0 + ) { + extras.push(`blocked: ${item.blockedReason.trim()}`); + } + } - lines.push(paint(more, CONSOLE.muted, color)); + lines.push( + ...formatItemLines(item, kind, columns, color, depth, extras) + ); + + if (item.children) { + walk(item.children, depth + 1); + } + } + }; + + walk(plan.items, 0); + + if (pendingHidden > 0) { + lines.push( + paint(`… +${String(pendingHidden)} more`, CONSOLE.muted, color) + ); } - for (const feature of parked.slice(0, 2)) { - lines.push(...formatItemLines(feature, "parked", columns, color)); + if (countOpen(plan.items) === 0) { + lines.push(paint("All done.", CONSOLE.bright, color)); } if (opts.showSelection === true && opts.selectedIndex !== undefined) { diff --git a/packages/core/src/loop/worklist/parse.ts b/packages/core/src/loop/worklist/parse.ts index 3b573434..246e2998 100644 --- a/packages/core/src/loop/worklist/parse.ts +++ b/packages/core/src/loop/worklist/parse.ts @@ -4,10 +4,13 @@ import { isFeatureId } from "../greenfield/state"; import type { IFeature } from "../greenfield/greenfield.types"; import type { IParseWorklistOptions, IWorklistItem } from "./worklist.types"; -const DEFAULT_LOOKUP = ["PLAN.md", "TASKS.md", ".specs/next.md"] as const; +/** 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 @@ -145,6 +148,17 @@ function collectDrafts(md: string): IWorklistDraft[] { 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); } @@ -243,7 +257,7 @@ export function acceptMapOf( /** * Resolve which worklist file to use. Explicit path wins when present; - * otherwise PLAN.md → TASKS.md → .specs/next.md under `cwd`. + * otherwise PLAN.md → TASKS.md under `cwd`. */ export async function resolveWorklistPath( cwd: string, diff --git a/packages/core/src/loop/worklist/seed.ts b/packages/core/src/loop/worklist/seed.ts new file mode 100644 index 00000000..f31717c8 --- /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, + IPlanDocument, + IPlanDraft, +} from "./checklist.types"; +import type { IChecklistItemDraft } 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 | null { + 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/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/frame/pane-screen.ts b/packages/core/src/render/frame/pane-screen.ts index a4d63901..dcf7d754 100644 --- a/packages/core/src/render/frame/pane-screen.ts +++ b/packages/core/src/render/frame/pane-screen.ts @@ -75,7 +75,7 @@ const FORGE_PLACEHOLDER = "describe a task, or /help"; const GUTTER = "│"; /** Fallback when no worklist lines are set — mirrors formatWorklistLines empty. */ -const EMPTY_PANEL_LINES = ["/work PLAN.md", "or /work "] as const; +const EMPTY_PANEL_LINES = ["approve a plan", "to fill this list"] as const; /** * Interactive console TUI: dense top strip, hairlines, scroll + rail, caret input. @@ -309,6 +309,7 @@ export class PaneScreen { head === "(empty)" || head === "—" || head.startsWith("No worklist") || + head === "approve a plan" || head === "/work to start" || head === "/work" ) { 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/tests/checklist-nudge.test.ts b/packages/core/tests/checklist-nudge.test.ts new file mode 100644 index 00000000..b02610ce --- /dev/null +++ b/packages/core/tests/checklist-nudge.test.ts @@ -0,0 +1,26 @@ +import { test, expect, describe } from "bun:test"; +import { checklistOpenNudge } 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); + }); +}); diff --git a/packages/core/tests/checklist-store.test.ts b/packages/core/tests/checklist-store.test.ts new file mode 100644 index 00000000..45cbf1b3 --- /dev/null +++ b/packages/core/tests/checklist-store.test.ts @@ -0,0 +1,198 @@ +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", + runCheck: 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", + runCheck: 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/cli.test.ts b/packages/core/tests/cli.test.ts index a0be32a1..ab62b646 100644 --- a/packages/core/tests/cli.test.ts +++ b/packages/core/tests/cli.test.ts @@ -491,6 +491,9 @@ 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"); diff --git a/packages/core/tests/frame-tui.test.ts b/packages/core/tests/frame-tui.test.ts index 1e930070..e3c4ed84 100644 --- a/packages/core/tests/frame-tui.test.ts +++ b/packages/core/tests/frame-tui.test.ts @@ -530,7 +530,7 @@ describe("PaneScreen", () => { // Under-rule joins the gutter spine (├) and the outer rail (┤). expect(railRule[gutterIdx]).toBe("├"); expect(railRule.trimEnd().endsWith("┤")).toBe(true); - expect(screen.text()).toContain("/work"); + 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"); @@ -721,7 +721,7 @@ describe("PaneScreen", () => { panes.enter(); // Wider than the main pane — old path let this punch through the gutter. panes.appendMain(`│ ${"W".repeat(200)}\n`); - panes.setPanel(["/work"]); + panes.setPanel(["approve a plan"]); const screen = new VirtualScreen(24, 100); @@ -828,19 +828,22 @@ describe("PaneScreen", () => { panes.setPanel( formatWorklistLines( { + schemaVersion: 2, + id: "plan-tui", goal: "PLAN.md", - features: [ + activeItemId: "a", + updatedAt: "2026-01-01T00:00:00.000Z", + items: [ { id: "a", - desc: "Accept a one-line description via argument or interactive prompt.", - passes: false, - attempts: 0, + title: + "Accept a one-line description via argument or interactive prompt.", + status: "active", }, { id: "b", - desc: "Create a flat list of independent verifiable features.", - passes: false, - attempts: 0, + title: "Create a flat list of independent verifiable features.", + status: "pending", }, ], }, @@ -1255,7 +1258,7 @@ describe("PaneScreen", () => { 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("/work"); + expect(narrow.text()).not.toContain("approve a plan"); }); test("resize no-op when geometry unchanged does not clear", () => { 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/present-plan-tool.test.ts b/packages/core/tests/present-plan-tool.test.ts new file mode 100644 index 00000000..cc93df41 --- /dev/null +++ b/packages/core/tests/present-plan-tool.test.ts @@ -0,0 +1,71 @@ +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/repl-work.test.ts b/packages/core/tests/repl-work.test.ts deleted file mode 100644 index ce840c9f..00000000 --- a/packages/core/tests/repl-work.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { approvePlan } from "../src/cli/repl-work"; - -describe("approvePlan", () => { - test("cancels when no asker is available (non-interactive)", async () => { - const out: string[] = []; - - await expect( - approvePlan((s) => out.push(s), "checklist", null) - ).resolves.toBe("cancel"); - expect(out.join("")).toContain("non-interactive"); - }); - - test("uses askApprove callback (pane editor path)", async () => { - const out: string[] = []; - - await expect( - approvePlan( - (s) => out.push(s), - "- [ ] one\n", - async () => "approve" - ) - ).resolves.toBe("approve"); - expect(out.join("")).toContain("Proposed worklist"); - expect(out.join("")).not.toContain("non-interactive"); - }); - - test("propagates cancel from asker", async () => { - await expect( - approvePlan( - () => undefined, - "x", - async () => "cancel" - ) - ).resolves.toBe("cancel"); - }); -}); 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/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/worklist-panel.test.ts b/packages/core/tests/worklist-panel.test.ts index 7b37a148..867e61db 100644 --- a/packages/core/tests/worklist-panel.test.ts +++ b/packages/core/tests/worklist-panel.test.ts @@ -1,14 +1,27 @@ import { test, expect, describe } from "bun:test"; -import { formatWorklistLines, worklistBadge } from "../src/loop/worklist/panel"; -import type { IGreenfieldState } from "../src/loop/greenfield"; +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 state( - features: IGreenfieldState["features"], - goal = "g" -): IGreenfieldState { - return { goal, features }; +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[] { @@ -16,103 +29,74 @@ function plain(lines: readonly string[]): string[] { } describe("formatWorklistLines", () => { - test("shows goal cue, current ▸, pending ○ — no worklist N/M header", () => { + test("shows goal cue, nested tree, active ▸", () => { const lines = formatWorklistLines( - state( + plan( [ - { id: "a", desc: "First", passes: true, attempts: 1 }, - { id: "b", desc: "Second", passes: false, attempts: 0 }, - { id: "c", desc: "Third", passes: false, attempts: 0 }, - { id: "d", desc: "Fourth", passes: false, attempts: 0 }, + { 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" + "PLAN.md", + "b" ), - { maxPending: 2, columns: 36, color: false } + { maxPending: 4, columns: 36, color: false } ); const text = plain(lines); expect(text[0]).toBe("PLAN.md"); - expect(text.some((l) => l.startsWith("worklist"))).toBe(false); expect(text).toContain("✓ First"); expect(text).toContain("▸ Second"); + expect(text.some((l) => l.includes("Child"))).toBe(true); expect(text).toContain("○ Third"); - expect(text).toContain("○ Fourth"); }); - test("wraps long descriptions to columns", () => { - const columns = 20; + test("shows verify on focused row", () => { const lines = formatWorklistLines( - state( + plan( [ { id: "a", - desc: "Accept a one-line goal and produce a sprint checklist", - passes: false, - attempts: 0, + title: "Wire rail", + status: "active", + verify: "bun test panel", }, ], - "worklist" + "goal", + "a" ), - { columns, color: false } + { columns: 40, color: false } ); const text = plain(lines); - const current = text.find((l) => l.startsWith("▸ ")); - - expect(current).toBeDefined(); - expect(text.length).toBeGreaterThan(1); - expect(text.some((l) => l.startsWith(" "))).toBe(true); - for (const line of text) { - expect(line.length).toBeLessThanOrEqual(columns); - } - - expect(text.join(" ")).toContain("checklist"); - expect(text.join("\n")).not.toMatch(/checklis\n/u); + expect(text.some((l) => l.includes("verify: bun test panel"))).toBe(true); }); - test("empty state points at /work PLAN.md", () => { - expect( - plain(formatWorklistLines(state([], "worklist"), { color: false })) - ).toEqual(["/work PLAN.md", "or /work "]); + test("empty state points at plan approve", () => { + expect(plain(formatWorklistLines(null, { color: false }))).toEqual([ + "approve a plan", + "to fill this list", + ]); }); - test("all done and parked-only copy", () => { + test("all done copy", () => { expect( plain( - formatWorklistLines( - state([{ id: "a", desc: "A", passes: true, attempts: 1 }]), - { color: false } - ) + formatWorklistLines(plan([{ id: "a", title: "A", status: "done" }]), { + color: false, + }) ) ).toContain("All done."); - - const parked = plain( - formatWorklistLines( - state([ - { id: "a", desc: "A", passes: true, attempts: 1 }, - { id: "b", desc: "B", passes: false, attempts: 2, parked: true }, - ]), - { color: false } - ) - ); - - expect(parked.some((l) => l.includes("Parked 1"))).toBe(true); - expect(parked.some((l) => l.startsWith("~ "))).toBe(true); - }); - - test("selection prefix when focused skips double ▸ on current", () => { - const lines = formatWorklistLines( - state([{ id: "a", desc: "A", passes: false, attempts: 0 }], "worklist"), - { showSelection: true, selectedIndex: 0, color: false } - ); - - expect(plain(lines)[0]?.startsWith("▸ ")).toBe(true); - expect(plain(lines)[0]?.startsWith("▸ ▸")).toBe(false); }); test("color mode paints current with CONSOLE.bright", () => { const lines = formatWorklistLines( - state([{ id: "a", desc: "Now", passes: false, attempts: 0 }], "worklist"), + plan([{ id: "a", title: "Now", status: "active" }], "g", "a"), { columns: 36, color: true } ); @@ -122,12 +106,63 @@ describe("formatWorklistLines", () => { test("worklistBadge is done/total", () => { expect( worklistBadge( - state([ - { id: "a", desc: "A", passes: true, attempts: 1 }, - { id: "b", desc: "B", passes: false, attempts: 0 }, + plan([ + { id: "a", title: "A", status: "done" }, + { id: "b", title: "B", status: "pending" }, ]) ) ).toBe("1/2"); - expect(worklistBadge(state([]))).toBe(""); + 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 index 532bcdb3..033b2fa6 100644 --- a/packages/core/tests/worklist-parse.test.ts +++ b/packages/core/tests/worklist-parse.test.ts @@ -118,6 +118,12 @@ describe("parseWorklist", () => { 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", () => { @@ -139,12 +145,12 @@ describe("resolveWorklistPath", () => { expect(await resolveWorklistPath(dir, "MY.md")).toBe(path); }); - test("looks up PLAN.md, then TASKS.md, then .specs/next.md", async () => { + 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)).toBe(join(dir, ".specs", "next.md")); + expect(await resolveWorklistPath(dir)).toBeNull(); await writeFile(join(dir, "TASKS.md"), "- [ ] from tasks\n"); expect(await resolveWorklistPath(dir)).toBe(join(dir, "TASKS.md")); diff --git a/packages/core/tests/worklist-seed.test.ts b/packages/core/tests/worklist-seed.test.ts new file mode 100644 index 00000000..7b855d74 --- /dev/null +++ b/packages/core/tests/worklist-seed.test.ts @@ -0,0 +1,141 @@ +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"); + }); +}); From cab11cbae3125632dff41e53cadba5f7f167b671 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sun, 9 Aug 2026 11:17:57 +0200 Subject: [PATCH 6/8] fix: clear the checklist validate gate before ship Separate task_complete's gate seam from the model's check tool, fix lint complexity/format drift, regenerate ARCHITECTURE.md, and teach the PTY e2e to drive present_plan. --- packages/core/ARCHITECTURE.md | 12 +- packages/core/src/cli/repl.ts | 238 ++++++++++-------- packages/core/src/loop/session.ts | 28 ++- .../core/src/loop/tools/present-plan-tool.ts | 3 +- packages/core/src/loop/tools/task-tools.ts | 8 +- packages/core/src/loop/tools/tool-context.ts | 6 + packages/core/src/loop/turn.ts | 10 +- .../core/src/loop/worklist/checklist-store.ts | 19 +- packages/core/src/loop/worklist/panel.ts | 137 +++++----- packages/core/src/loop/worklist/seed.ts | 4 +- packages/core/tests/checklist-store.test.ts | 7 +- packages/core/tests/plan-mode.test.ts | 4 +- packages/core/tests/present-plan-tool.test.ts | 4 +- packages/core/tests/tool-accounting.test.ts | 5 + packages/core/tests/worklist-panel.test.ts | 13 +- packages/core/tests/worklist-seed.test.ts | 5 +- scripts/e2e-pty.py | 30 ++- 17 files changed, 308 insertions(+), 225 deletions(-) diff --git a/packages/core/ARCHITECTURE.md b/packages/core/ARCHITECTURE.md index 120e1dcd..b0b86581 100644 --- a/packages/core/ARCHITECTURE.md +++ b/packages/core/ARCHITECTURE.md @@ -2,7 +2,7 @@ -Derived from `packages/core/src`: **30 subsystems**, **577 files**, **106549 lines**, **136 cross-subsystem edges**. +Derived from `packages/core/src`: **30 subsystems**, **578 files**, **107023 lines**, **136 cross-subsystem edges**. This page is the exhaustive record: every subsystem, every cross-subsystem edge, and @@ -13,14 +13,14 @@ 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 | 114 | 32564 | 7 | 22 | +| `loop` | The drive-to-green engine: turns, tools, gate settling, steering, adapters | core | 115 | 32916 | 7 | 22 | | `rule-packs` | The ESLint rule packs the gate enforces, grouped by stack | core | 164 | 19000 | 3 | 3 | | `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 | 6991 | 2 | 18 | +| `cli` | Argument parsing, the interactive REPL, and per-mode wiring | core | 20 | 7051 | 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 | 2707 | 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 | 2322 | 5 | 15 | @@ -28,7 +28,7 @@ inventory, see the hand-drawn map on [Internals](/internals/). | `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 | 1256 | 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 | @@ -99,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:605` | +| `repl` | `cli/repl.ts:608` | | `reviewMode` | `cli.ts:181` | | `runOnce` | `cli.ts:93` | | `runTraceCommand` | `cli/repl-commands.ts:109` | diff --git a/packages/core/src/cli/repl.ts b/packages/core/src/cli/repl.ts index ab0741cd..c41b5cb2 100644 --- a/packages/core/src/cli/repl.ts +++ b/packages/core/src/cli/repl.ts @@ -1017,6 +1017,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, @@ -1039,43 +1107,7 @@ export async function repl(args: ICliArgs): Promise { // 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") { - const pending = session.takePendingPlan(); - let plan: IPlanDocument | null = null; - - if (pending !== null) { - plan = persistPlanDocument(args.dir, pending); - } else { - 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; - } - - plan = seeded.plan; - } - - 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)); + await approveBoundPlan(); return; } @@ -1083,24 +1115,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; - - // 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`); - } + await discussInPlanMode(line); return; } @@ -1134,6 +1149,60 @@ export async function repl(args: ICliArgs): 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. const command = async (line: string): Promise => { @@ -1152,60 +1221,9 @@ export async function repl(args: ICliArgs): Promise { handleCopy(); 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; - 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"); + case "clear": + await clearConversation(); break; - } case "compact": { // Compaction is a full model round-trip (can take many seconds). Drive the @@ -1581,6 +1599,7 @@ export async function repl(args: ICliArgs): Promise { : 80; echo(`\n${formatPlanProposal(plan, cols, true)}\n`); + // Preview in Tasks rail before approve (pending badge). if (panesLive()) { paneScreen.setPanel( @@ -2775,8 +2794,7 @@ export async function repl(args: ICliArgs): Promise { // Rehydrate Tasks rail from the session-bound plan (`activePlanId`). const planId = session.getActivePlanId(); - const plan = - planId !== null ? loadPlan(args.dir, planId) : null; + const plan = planId !== null ? loadPlan(args.dir, planId) : null; if (panesLive()) { syncWorklistPanel(plan); diff --git a/packages/core/src/loop/session.ts b/packages/core/src/loop/session.ts index 030bf18f..bf0d90a4 100644 --- a/packages/core/src/loop/session.ts +++ b/packages/core/src/loop/session.ts @@ -388,7 +388,7 @@ const PLAN_MODE_NOTE = "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, call the `present_plan` tool with " + - '{ goal, items: [{ title, detail?, files?, verify?, children? }] }. ' + + "{ 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 " + @@ -948,15 +948,18 @@ export class Session { this.ctx = ctx; - // Wire runCheckGate whenever a gate exists OR the callable `check` tool is - // offered. Interactive REPL sessions need this for task_complete (gate must - // be green before an item can be marked done) even when `check` itself is - // not advertised. Reads `this.ctx` LAZILY so a mid-build `setGate` swap is - // honored; never `validate(accept)` alone (vacuous-recheck trap). - if (offerCheck || this.hasGate) { + // `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; @@ -1277,9 +1280,9 @@ export class Session { this.hasGate = true; } - // task_complete needs runCheck; wire it when a gate appears mid-session. - if (this.hasGate && this.ctx.tool.runCheck === undefined) { - this.ctx.tool.runCheck = () => runCheckGate(this.ctx); + // 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(); @@ -1370,6 +1373,7 @@ export class Session { /** Take and clear the pending proposal (approve path). */ takePendingPlan(): IPlanDocument | null { const plan = this.pendingPlan; + this.pendingPlan = null; return plan; @@ -1417,9 +1421,7 @@ export class Session { ? null : findItem(plan.items, plan.activeItemId); const active = - focused === null - ? "(none)" - : `${focused.title} (${focused.id})`; + focused === null ? "(none)" : `${focused.title} (${focused.id})`; return [ CHECKLIST_CONTRACT_MARKER, diff --git a/packages/core/src/loop/tools/present-plan-tool.ts b/packages/core/src/loop/tools/present-plan-tool.ts index 09dedbdf..85b3d86d 100644 --- a/packages/core/src/loop/tools/present-plan-tool.ts +++ b/packages/core/src/loop/tools/present-plan-tool.ts @@ -13,7 +13,7 @@ function isRecord(v: unknown): v is Record { */ export function presentPlanArgsToRaw( args: Record -): unknown | null { +): Record | null { if (isRecord(args.plan)) { return args.plan; } @@ -56,6 +56,7 @@ export function doPresentPlan( } const plan: IPlanDocument = normalized.plan; + ctx.onPlanPresented?.(plan); const open = countOpen(plan.items); diff --git a/packages/core/src/loop/tools/task-tools.ts b/packages/core/src/loop/tools/task-tools.ts index 7fa3fa5c..47b79efe 100644 --- a/packages/core/src/loop/tools/task-tools.ts +++ b/packages/core/src/loop/tools/task-tools.ts @@ -12,9 +12,7 @@ import { reject, str, type IToolContext } from "./tool-context"; function requirePlan( ctx: IToolContext -): - | { ok: true; planId: string; cwd: string } - | { ok: false; error: string } { +): { ok: true; planId: string; cwd: string } | { ok: false; error: string } { const planId = ctx.activePlanId; if (typeof planId !== "string" || planId.length === 0) { @@ -156,7 +154,7 @@ export async function doTaskComplete( ); } - if (ctx.runCheck === undefined) { + if (ctx.runTaskGate === undefined) { return reject( ctx, "task_complete", @@ -170,7 +168,7 @@ export async function doTaskComplete( message: "task_complete: running gate before marking done", }); - const gate = await ctx.runCheck(); + const gate = await ctx.runTaskGate(); if (!gate.passed) { const sample = gate.errors diff --git a/packages/core/src/loop/tools/tool-context.ts b/packages/core/src/loop/tools/tool-context.ts index 142a4ca2..33cfb615 100644 --- a/packages/core/src/loop/tools/tool-context.ts +++ b/packages/core/src/loop/tools/tool-context.ts @@ -162,6 +162,12 @@ 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; diff --git a/packages/core/src/loop/turn.ts b/packages/core/src/loop/turn.ts index a1380e76..1d8571a1 100644 --- a/packages/core/src/loop/turn.ts +++ b/packages/core/src/loop/turn.ts @@ -238,7 +238,12 @@ export function toolsFor( // 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] + ? [ + 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 @@ -351,6 +356,9 @@ 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). */ diff --git a/packages/core/src/loop/worklist/checklist-store.ts b/packages/core/src/loop/worklist/checklist-store.ts index c3d896bb..9933f4c1 100644 --- a/packages/core/src/loop/worklist/checklist-store.ts +++ b/packages/core/src/loop/worklist/checklist-store.ts @@ -226,6 +226,7 @@ export function savePlan(cwd: string, plan: IPlanDocument): void { updatedAt: plan.updatedAt, }; const rest = index.plans.filter((p) => p.id !== plan.id); + savePlanIndex(cwd, { plans: [entry, ...rest] }); } @@ -274,26 +275,24 @@ export function updateItemById( id: string, updater: (item: IChecklistItem) => IChecklistItem ): IChecklistItem[] | null { - let found = false; + if (findItem(items, id) === null) { + return null; + } const walk = (nodes: readonly IChecklistItem[]): IChecklistItem[] => nodes.map((item) => { if (item.id === id) { - found = true; - return updater(item); } - if (!item.children) { + if (item.children === undefined) { return item; } return { ...item, children: walk(item.children) }; }); - const next = walk(items); - - return found ? next : null; + return walk(items); } export function countOpen(items: readonly IChecklistItem[]): number { @@ -493,10 +492,11 @@ export function formatPlanTree( const indent = opts.indent ?? " "; const lines: string[] = [`goal: ${plan.goal}`]; - if (plan.activeItemId) { + if (plan.activeItemId !== null) { const active = findItem(plan.items, plan.activeItemId); + lines.push( - `active: ${active ? `${active.title} (${active.id})` : plan.activeItemId}` + `active: ${active !== null ? `${active.title} (${active.id})` : plan.activeItemId}` ); } else { lines.push("active: (none)"); @@ -522,6 +522,7 @@ export function formatPlanTree( ? "[!]" : "[ ]"; const pad = indent.repeat(depth); + lines.push(`${pad}${mark} ${item.title} (${item.id})`); if (item.children) { diff --git a/packages/core/src/loop/worklist/panel.ts b/packages/core/src/loop/worklist/panel.ts index ba268632..386d4dd6 100644 --- a/packages/core/src/loop/worklist/panel.ts +++ b/packages/core/src/loop/worklist/panel.ts @@ -112,6 +112,7 @@ export function formatPlanProposal( 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) { @@ -229,10 +230,7 @@ function paintBody(part: string, kind: ItemKind, color: boolean): string { return paint(part, CONSOLE.muted, true); } -function kindOf( - item: IChecklistItem, - activeItemId: string | null -): ItemKind { +function kindOf(item: IChecklistItem, activeItemId: string | null): ItemKind { if (item.status === "done") { return "done"; } @@ -273,9 +271,7 @@ function formatItemLines( 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) - ); + lines.push(paint(`${nest}${CONT_INDENT}${clipped}`, CONSOLE.muted, color)); } return lines; @@ -301,6 +297,72 @@ function applySelection( }); } +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. */ @@ -326,60 +388,19 @@ export function formatWorklistLines( lines.push(paint(clip(goal, columns), CONSOLE.muted, color)); } - let pendingShown = 0; - let pendingHidden = 0; + const pending = { shown: 0, hidden: 0 }; - const walk = (nodes: readonly IChecklistItem[], depth: number): 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 (pendingShown >= maxPending) { - pendingHidden += 1; - - if (item.children) { - walk(item.children, depth + 1); - } - - continue; - } - - pendingShown += 1; - } - - const extras: string[] = []; - - if (isFocus) { - 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()}`); - } - } - - lines.push( - ...formatItemLines(item, kind, columns, color, depth, extras) - ); - - if (item.children) { - walk(item.children, depth + 1); - } - } - }; - - walk(plan.items, 0); + walkChecklist(plan, plan.items, 0, { + maxPending, + columns, + color, + pending, + out: lines, + }); - if (pendingHidden > 0) { + if (pending.hidden > 0) { lines.push( - paint(`… +${String(pendingHidden)} more`, CONSOLE.muted, color) + paint(`… +${String(pending.hidden)} more`, CONSOLE.muted, color) ); } diff --git a/packages/core/src/loop/worklist/seed.ts b/packages/core/src/loop/worklist/seed.ts index f31717c8..413a034a 100644 --- a/packages/core/src/loop/worklist/seed.ts +++ b/packages/core/src/loop/worklist/seed.ts @@ -4,10 +4,10 @@ */ import type { IChecklistItem, + IChecklistItemDraft, IPlanDocument, IPlanDraft, } from "./checklist.types"; -import type { IChecklistItemDraft } from "./checklist.types"; import { savePlan } from "./checklist-store"; export type SeedWorklistResult = @@ -19,7 +19,7 @@ function isRecord(v: unknown): v is Record { } /** Prefer ```json … ```; else any fenced block that parses as a plan draft. */ -export function extractPlanJson(assistantText: string): unknown | null { +export function extractPlanJson(assistantText: string): unknown { const fences = [ ...assistantText.matchAll(/```(?:json)?\s*\n([\s\S]*?)```/giu), ]; diff --git a/packages/core/tests/checklist-store.test.ts b/packages/core/tests/checklist-store.test.ts index 45cbf1b3..b79c524d 100644 --- a/packages/core/tests/checklist-store.test.ts +++ b/packages/core/tests/checklist-store.test.ts @@ -79,6 +79,7 @@ describe("checklist-store", () => { test("focus + uncomplete persist via tools scoped to activePlanId", async () => { const plan = samplePlan("bound"); + savePlan(dir, plan); const reports: string[] = []; @@ -92,7 +93,7 @@ describe("checklist-store", () => { }, task: "session", activePlanId: "bound", - runCheck: async () => ({ + runTaskGate: async () => ({ passed: true, errors: [], output: "", @@ -120,6 +121,7 @@ describe("checklist-store", () => { test("task_complete refuses when gate is red — item stays open", async () => { const plan = samplePlan("red"); + savePlan(dir, plan); const ctx: IToolContext = { @@ -128,7 +130,7 @@ describe("checklist-store", () => { report: () => undefined, task: "session", activePlanId: "red", - runCheck: async () => ({ + runTaskGate: async () => ({ passed: false, errors: [{ key: "x", message: "TS2304: Cannot find name 'x'" }], output: "fail", @@ -146,6 +148,7 @@ describe("checklist-store", () => { test("task_complete refuses when no gate is wired", async () => { const plan = samplePlan("nogate"); + savePlan(dir, plan); const ctx: IToolContext = { 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 index cc93df41..cea88d99 100644 --- a/packages/core/tests/present-plan-tool.test.ts +++ b/packages/core/tests/present-plan-tool.test.ts @@ -6,9 +6,7 @@ import { 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 { +function ctx(onPlanPresented?: (plan: IPlanDocument) => void): IToolContext { return { cwd: "/tmp", files: ["**/*"], 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/worklist-panel.test.ts b/packages/core/tests/worklist-panel.test.ts index 867e61db..24c50379 100644 --- a/packages/core/tests/worklist-panel.test.ts +++ b/packages/core/tests/worklist-panel.test.ts @@ -145,7 +145,14 @@ describe("formatWorklistLines", () => { "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 }], + [ + { + id: "a", + title: "Create src/notes.ts CLI", + status: "pending", + detail, + }, + ], "Build a tiny static notes CLI" ), 48, @@ -160,9 +167,7 @@ describe("formatWorklistLines", () => { test("pendingPlanBadge marks open count", () => { expect( - pendingPlanBadge( - plan([{ id: "a", title: "A", status: "pending" }]) - ) + pendingPlanBadge(plan([{ id: "a", title: "A", status: "pending" }])) ).toBe("·1"); }); }); diff --git a/packages/core/tests/worklist-seed.test.ts b/packages/core/tests/worklist-seed.test.ts index 7b855d74..0971b9bc 100644 --- a/packages/core/tests/worklist-seed.test.ts +++ b/packages/core/tests/worklist-seed.test.ts @@ -134,7 +134,10 @@ describe("goalFromMessages", () => { role: "user", content: "fix the Tasks rail\n\n[PLAN MODE — read-only. …]", }, - { role: "assistant", content: "```json\n{\"items\":[{\"title\":\"a\"}]}\n```" }, + { + role: "assistant", + content: '```json\n{"items":[{"title":"a"}]}\n```', + }, ]) ).toBe("fix the Tasks rail"); }); diff --git a/scripts/e2e-pty.py b/scripts/e2e-pty.py index e4e055c7..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", + } + ], + }, ) @@ -82,9 +95,10 @@ def scenario_plan_lifecycle(port): ) got, buf = read_until( master, - lambda b: "REPLY TO REFINE" in b - or "reply to refine" in b.lower() - or "## Plan" in b, + 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, ) From 30569475bfc290f5e054cccd979678370d29b7bd Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sun, 9 Aug 2026 11:24:46 +0200 Subject: [PATCH 7/8] fix(tui): paint --continue transcript at pane width Resume was rendering USER/AGENT cards at full stdout.columns into a narrower main pane (and after the Tasks rail), so box rails rewrapped. Sync the rail first, pass mainInnerCols, and skip checklist injects from the visual replay (Tasks rail already shows them). --- packages/core/src/cli/repl.ts | 25 +++++++++----- packages/core/src/loop/index.ts | 1 + packages/core/src/loop/session.ts | 21 ++++++++++++ packages/core/tests/checklist-nudge.test.ts | 38 ++++++++++++++++++++- packages/core/tests/message-render.test.ts | 27 +++++++++++++++ 5 files changed, 102 insertions(+), 10 deletions(-) diff --git a/packages/core/src/cli/repl.ts b/packages/core/src/cli/repl.ts index c41b5cb2..a52b5f24 100644 --- a/packages/core/src/cli/repl.ts +++ b/packages/core/src/cli/repl.ts @@ -39,6 +39,7 @@ import { import { Session, PLAN_APPROVED_NOTE, + isEphemeralUserInject, type Reporter, type ILoopEvent, } from "../loop"; @@ -2780,24 +2781,30 @@ export async function repl(args: ICliArgs): Promise { 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) { - panes.appendMain(renderMessage(message, { color: true, speaker })); - } - } - - // Rehydrate Tasks rail from the session-bound plan (`activePlanId`). - const planId = session.getActivePlanId(); - const plan = planId !== null ? loadPlan(args.dir, planId) : null; + if (isEphemeralUserInject(message)) { + continue; + } - if (panesLive()) { - syncWorklistPanel(plan); + panes.appendMain( + renderMessage(message, { color: true, speaker, columns }) + ); + } } }; diff --git a/packages/core/src/loop/index.ts b/packages/core/src/loop/index.ts index 84f142b3..36227003 100644 --- a/packages/core/src/loop/index.ts +++ b/packages/core/src/loop/index.ts @@ -79,6 +79,7 @@ export { Session, PLAN_APPROVED_NOTE, checklistOpenNudge, + isEphemeralUserInject, filterGateStream, type ISessionConfig, type ISendResult, diff --git a/packages/core/src/loop/session.ts b/packages/core/src/loop/session.ts index bf0d90a4..87aa75fe 100644 --- a/packages/core/src/loop/session.ts +++ b/packages/core/src/loop/session.ts @@ -431,6 +431,27 @@ export function checklistOpenNudge(opts: { 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; diff --git a/packages/core/tests/checklist-nudge.test.ts b/packages/core/tests/checklist-nudge.test.ts index b02610ce..79520f11 100644 --- a/packages/core/tests/checklist-nudge.test.ts +++ b/packages/core/tests/checklist-nudge.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe } from "bun:test"; -import { checklistOpenNudge } from "../src/loop/session"; +import { checklistOpenNudge, isEphemeralUserInject } from "../src/loop/session"; describe("checklistOpenNudge (Phase B messaging)", () => { test("gate-green / checklist-open without task_complete this turn", () => { @@ -24,3 +24,39 @@ describe("checklistOpenNudge (Phase B messaging)", () => { 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/message-render.test.ts b/packages/core/tests/message-render.test.ts index cacddc63..098cb4d7 100644 --- a/packages/core/tests/message-render.test.ts +++ b/packages/core/tests/message-render.test.ts @@ -59,6 +59,33 @@ describe("renderMessage — hybrid bubbles", () => { 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", () => { From 0e1e44a7671018ef759ba0817472a8317e2afb6f Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sun, 9 Aug 2026 11:25:13 +0200 Subject: [PATCH 8/8] chore: regenerate ARCHITECTURE.md after continue transcript fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's arch drift check is outside bun run validate — keep the map in sync after the resume-path line count change. --- packages/core/ARCHITECTURE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/ARCHITECTURE.md b/packages/core/ARCHITECTURE.md index b0b86581..b9155121 100644 --- a/packages/core/ARCHITECTURE.md +++ b/packages/core/ARCHITECTURE.md @@ -2,7 +2,7 @@ -Derived from `packages/core/src`: **30 subsystems**, **578 files**, **107023 lines**, **136 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,10 +13,10 @@ 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 | 115 | 32916 | 7 | 22 | +| `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 | | `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 | 7051 | 2 | 18 | +| `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 | @@ -99,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:608` | +| `repl` | `cli/repl.ts:609` | | `reviewMode` | `cli.ts:181` | | `runOnce` | `cli.ts:93` | | `runTraceCommand` | `cli/repl-commands.ts:109` |