diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 813eb6a..52d9cd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,29 +46,34 @@ jobs: run: bun run test:coverage # bun writes lcov SF: paths relative to the package dir (src/index.ts), - # but Codecov and Sonar resolve them from the repo root - rewrite them once + # but Codecov and Sonar resolve them from the repo root - rewrite them once. + # Every package that emitted a report, so adding one needs no change here. - name: Normalize coverage paths - if: ${{ !cancelled() && hashFiles('packages/core/coverage/lcov.info') != '' }} - run: sed -i '/^SF:packages\//! s|^SF:|SF:packages/core/|' packages/core/coverage/lcov.info + if: ${{ !cancelled() && hashFiles('packages/*/coverage/lcov.info') != '' }} + run: | + for report in packages/*/coverage/lcov.info; do + package=$(dirname "$(dirname "$report")") + sed -i "/^SF:packages\//! s|^SF:|SF:$package/|" "$report" + done - name: Build run: bun run build # Tests may pass and a later step fail - still report what was measured - name: Upload coverage to Codecov - if: ${{ !cancelled() && hashFiles('packages/core/coverage/lcov.info') != '' }} + if: ${{ !cancelled() && hashFiles('packages/*/coverage/lcov.info') != '' }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: - files: ./packages/core/coverage/lcov.info + files: ./packages/core/coverage/lcov.info,./packages/cli/coverage/lcov.info use_oidc: true # Failed tests are what this reports, so it must run when the suite fails - name: Upload test results to Codecov - if: ${{ !cancelled() && hashFiles('packages/core/test-report.junit.xml') != '' }} + if: ${{ !cancelled() && hashFiles('packages/*/test-report.junit.xml') != '' }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: report_type: test_results - files: ./packages/core/test-report.junit.xml + files: ./packages/core/test-report.junit.xml,./packages/cli/test-report.junit.xml use_oidc: true - name: SonarQube scan diff --git a/NOTICE b/NOTICE index d7beb97..61ef967 100644 --- a/NOTICE +++ b/NOTICE @@ -19,3 +19,14 @@ the files that carry them: packages/core/src/sandbox/contract/** packages/core/src/sandbox/harness/** packages/core/test/sandbox/harness/** + +--- + +This product includes software derived from the eve project +(https://github.com/vercel/eve) by Vercel, Inc., licensed under the Apache +License, Version 2.0. The derived portions are the terminal boot chrome — the +palette and section rendering, the escape-sequence sanitizer, the grapheme-aware +width measurement, the inline repaint engine, and the progress pulse — and are +marked with an attribution header in the files that carry them: + + packages/cli/src/ui/** diff --git a/README.ko.md b/README.ko.md index eb409d6..fca1889 100644 --- a/README.ko.md +++ b/README.ko.md @@ -137,11 +137,14 @@ packages/ harness/ # 그 계약 위의 HarnessV1SandboxProvider docker/ # 로컬 Docker 백엔드 (호스트 전용) scripts/ # 런타임을 가정하지 않고 측정하는 프로브 + cli/ # @pleasedev/cli — 아직 배포하지 않는다. 명령어가 없다 + src/ui/ # 세션이 시작되기 전에 `please dev`가 그리는 부팅 크롬 examples/ claude-code-docker/ # 로컬 컨테이너 안의 Claude Code. 위 API로 작성됐다 docs/ prior-art.md # eve, flue, AI SDK 하네스, Agent SDK가 이미 하고 있는 것 project-layout.md # 레이아웃 논증, 정해진 것, 아직 열린 것 + dev-tui.md # `please dev`: 정해진 것과, 아직 기다리는 것 ``` 예제는 실행할 수 있고, 이 프레임워크가 무엇을 하고 무엇을 하지 않는지 보는 가장 짧은 길이다. @@ -168,6 +171,10 @@ bun run packages/core/scripts/probe-permissions.ts # Anthropic 자격 증 [`docs/project-layout.md`](docs/project-layout.md)은 그 기록 위에 세운 논증이다. 계약이 이미 우리 대신 정해 버린 것들, 선언 문법이 디렉티브가 아니라 함수인 이유, 그리고 아직 열려 있는 질문들. +[`docs/dev-tui.md`](docs/dev-tui.md)은 그 위에 얹힌 논증이다. 대화형 `please dev`가 터미널을 +[`@ai-sdk/tui`](https://ai-sdk.dev/docs/ai-sdk-harnesses/terminal-ui)와 eve에서 이식한 부팅 크롬 +사이에 어떻게 나누는지, 그리고 그 명령어가 아직 어떤 `defineAgent` 결정을 기다리고 있는지. + ## 기여 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고. [행동 강령](CODE_OF_CONDUCT.md)과, 취약점 제보는 diff --git a/README.md b/README.md index 865a201..ddb3e7f 100644 --- a/README.md +++ b/README.md @@ -147,11 +147,14 @@ packages/ harness/ # HarnessV1SandboxProvider over that contract docker/ # local Docker backend (host-only) scripts/ # probes that measure the runtime rather than assume it + cli/ # @pleasedev/cli — unreleased; has no command yet + src/ui/ # the boot chrome `please dev` draws before the session starts examples/ claude-code-docker/ # Claude Code in a local container, built on the API above docs/ prior-art.md # what eve, flue, the AI SDK harnesses and the Agent SDK already do project-layout.md # the layout argument, what is settled, and what is still open + dev-tui.md # `please dev`: what is decided, and what it waits on ``` The example is runnable, and is the shortest way to see what the framework does and does not do: @@ -179,6 +182,11 @@ from what exists rather than from recollection. contract already decides for us, why the declaration syntax is a function rather than a directive, and the questions still open. +[`docs/dev-tui.md`](docs/dev-tui.md) is the one built on top of that: how an interactive +`please dev` divides the terminal between +[`@ai-sdk/tui`](https://ai-sdk.dev/docs/ai-sdk-harnesses/terminal-ui) and a boot chrome ported from +eve, and which `defineAgent` decisions the command is still waiting on. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). Please also read the diff --git a/bun.lock b/bun.lock index ff0af5d..cd8da10 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,16 @@ "@pleasedev/core": "workspace:*", }, }, + "packages/cli": { + "name": "@pleasedev/cli", + "version": "0.0.0", + "dependencies": { + "picocolors": "^1.1.1", + }, + "devDependencies": { + "typescript": "^6", + }, + }, "packages/core": { "name": "@pleasedev/core", "version": "0.0.0", @@ -211,6 +221,8 @@ "@pleaseai/eslint-config": ["@pleaseai/eslint-config@0.0.4", "", { "dependencies": { "@antfu/eslint-config": "^8.0.0", "eslint-plugin-package-json": "^0.91.0" }, "peerDependencies": { "eslint": "^9.10.0 || ^10.0.0" } }, "sha512-FWzFoZfz1Di/BCT+G9HZpyWcOT4L3lcu9jWRfOWTjRsCkEtbUVBbgp9Wa8+C5lumGq5S5ecAKyDbfAb6xd+w0Q=="], + "@pleasedev/cli": ["@pleasedev/cli@workspace:packages/cli"], + "@pleasedev/core": ["@pleasedev/core@workspace:packages/core"], "@pleasedev/example-claude-code-docker": ["@pleasedev/example-claude-code-docker@workspace:examples/claude-code-docker"], diff --git a/docs/dev-tui.md b/docs/dev-tui.md new file mode 100644 index 0000000..3e8173d --- /dev/null +++ b/docs/dev-tui.md @@ -0,0 +1,176 @@ +# The dev TUI + +> **Status: half decided, half blocked.** The boot chrome described below is implemented and +> tested in `@pleasedev/cli` (`packages/cli/src/ui`). The `dev` command it exists for is **not**, +> because the interactive UI needs two things from `defineAgent` that `defineAgent` does not +> expose. Those are named in [What the interactive UI needs](#what-the-interactive-ui-needs), +> and they are API decisions, not omissions to be quietly filled in. +> +> This note was first written while `defineAgent` was still unmerged, and said the command was +> waiting for it to land. It has landed (#13), and the two gaps are unchanged — they are +> properties of the shape that shipped, not of its being unfinished, and the wording below is +> checked against that shipped shape rather than against a draft. + +Measurements below are against `@ai-sdk/tui@1.0.84` and `@ai-sdk/harness@1.0.91`, read on +2026-08-28 from the packages themselves rather than from the documentation. + +## What `please dev` is + +One command: take the agent this project declares, bring its sandbox up, and hand the running +session to an interactive terminal so a person can talk to it and watch what it does. + +It is the smallest thing that makes the sandbox layer usable by a human rather than by a test. +Everything `please` has today is reachable only from a script — `examples/claude-code-docker` +drives exactly one turn with a prompt baked into the file — and a framework whose only interactive +surface is "edit the string and run it again" cannot be evaluated by the people it is for. + +## Two UIs, two screens + +The terminal work splits cleanly in two, and the split is not a matter of taste — it follows from +what each half does to the screen. + +**The session is [`@ai-sdk/tui`](https://ai-sdk.dev/docs/ai-sdk-harnesses/terminal-ui)'s.** +`runAgentTUI` renders the transcript, markdown, tool sections, reasoning sections and manual tool +approvals, and runs until the user exits with `Esc` or `Ctrl+C`. It does this in the **alternate +screen buffer** — its bundle contains the `?1049h` / `?1049l` pair — so it takes the whole terminal +for the length of the session and gives it back untouched afterwards. + +**Everything either side of that is ours**, and it belongs on the main screen. The banner naming +what is about to run, the phases of bringing a container up, and whatever is left behind when the +run ends all have to survive the alternate buffer being torn down. A failure that never reaches +the interactive UI has nothing to say from inside a buffer that was never entered. + +That is why the reference for our half is +[eve's `cli/ui`](https://github.com/vercel/eve/tree/main/packages/eve/src/cli/ui) rather than +another TUI framework. eve's live region is built on the opposite premise from `@ai-sdk/tui`'s — +it deliberately streams into *native scrollback* so the user keeps real scrolling, copy/paste and +a transcript that outlives the process. Those two designs cannot be merged. They do not have to +be: they own different screens at different times. + +So the port is small on purpose, and stops where `@ai-sdk/tui` starts: + +| Ported from eve | Why | Not ported | +| --- | --- | --- | +| `output.ts` → `theme.ts`, `sanitize.ts` | palette, banner, aligned sections, escape-sequence removal | — | +| `terminal-text.ts` → `text.ts` | a Hangul glyph is two cells; a row measured with `.length` wraps, and a wrapped row breaks the repaint | word wrapping, cursor-column arithmetic — the editor's, not ours | +| `live-region.ts` | phases commit to scrollback while the current one animates | `clearAll`, bracketed paste — a REPL's, not a boot's | +| `progress-pulse.ts`, `live-row.ts` → `boot-row.ts` | the boot is long waits, not quick steps | — | +| — | — | `alt-screen.ts`: that buffer is `@ai-sdk/tui`'s | + +The port also simplifies where the runtime has caught up: eve's hand-written table of zero-width +code points is replaced by the Unicode property escapes `\p{Mn}\p{Me}\p{Cf}`, and its shared +grapheme splitter by `Intl.Segmenter`, which Bun ships. + +## Where it lives + +A new workspace package, `@pleasedev/cli` (`packages/cli`), rather than a `@pleasedev/core` subpath. + +`core`'s subpath rule exists to keep host-only code out of a bundle that cannot run it — the +docker backend spawns a CLI, so it sits behind `@pleasedev/core/sandbox/docker` and a Worker +bundling `@pleasedev/core` never pulls it in. A command-line program is not a subpath case of that +rule; it is the other side of it. It has a `bin`, it owns `process.stdout`, it reads a config file +off disk, and none of that is runtime-neutral in any sense the rule was written for. + +The package is `private: true` for now. It has no command yet, and publishing an empty `please` +binary would be worse than not publishing one. Registration in `release-please-config.json` and +`.release-please-manifest.json` happens in the change that adds `dev`; `sonar-project.properties` +is updated already, because the source and tests exist and should be analysed. + +## What the interactive UI needs + +`runAgentTUI` takes an `AgentTUIAgent`. The shipped type is `Agent` from `ai`, +which structurally requires `version`, `id`, `tools`, `generate` and `stream` — but the +implementation in 1.0.84 reads exactly two of them: **`agent.tools`** and **`agent.stream`**. + +`HarnessAgent` already `implements Agent`, so it nearly fits. The one mismatch is the one the AI +SDK's own guide papers over with an adapter: `HarnessAgent.stream()` requires a `session` on every +call, and the terminal UI does not know about sessions. The documented workaround is a small +object that closes over one session for the lifetime of the run. + +`defineAgent` as merged does not expose the pieces that adapter needs: + +1. **No `stream`.** `AgentSession.prompt()` returns a completed `AgentTurn` — it wraps + `HarnessAgent.generate()`. The terminal UI cannot render a turn that only arrives once it is + over; incremental rendering is the entire point of it. +2. **No `tools`.** `Agent` exposes `createSession` and nothing else. `runAgentTUI` reads + `agent.tools` to render tool sections, and the merged builtin-plus-user tool set lives on the + `HarnessAgent` that `defineAgent` builds and keeps to itself. +3. **No boot progress.** `createSession()` is a single opaque await covering the image pull, the + container create, the definition's `onCreate`, the adapter's bootstrap inside the container + (`pnpm install`, then the Claude Code CLI) and the session start. The example puts that at + "roughly half a minute". The boot row can pulse through it, but it cannot *name* a phase it is + not told about. + +Three ways out, and this is the decision to make rather than to assume: + +- **(a) Widen `AgentSession`** with `stream()` and surface `tools` on `Agent`. Keeps `defineAgent` + the only entry point; grows a surface that is one commit old, and re-exports AI SDK stream + types through it. +- **(b) Expose the underlying pair** — return the `HarnessAgent` and its `HarnessAgentSession` + from `createSession`, and let the CLI build the adapter. Smallest addition, and honest about the + fact that the harness boundary is the AI SDK's; leaks a type `defineAgent` otherwise hides. +- **(c) Let the CLI construct its own `HarnessAgent`** from the `AgentDefinition`, which + `defineAgent` would export alongside the built agent. No new runtime surface at all; duplicates + the sandbox-and-workspace wiring `defineAgent` exists to centralise, which is how the two drift. + +(b) is the current preference — it adds one accessor rather than a parallel API, and the type it +leaks is one the project already refuses to wrap on principle. It is not decided. + +For (3), the smallest honest answer is an optional `onProgress` on `createSession`, reporting the +phases the framework can already see. Anything finer than that is the adapter's to report, and it +does not. + +## The boot sequence + +What the main screen shows, before the alternate buffer opens: + +``` +please dev +========== +claude-code · docker · node:22-bookworm + +[DOCKER] daemon reachable +▪ starting the container please-dev-8f2c1a +``` + +The last line is the boot row: one line, pulsing, replaced as phases advance and committed to +scrollback when a phase finishes. Then `runAgentTUI` takes the screen. On exit the main screen +returns with those lines still on it, and the run adds its own closing section — session id, +what it cost, where the workspace ended up. + +Three properties the row is built to hold, each pinned by a test: + +- **One phase is one screen line.** Detail is fitted to the width that is left and ellipsized + rather than allowed to wrap, measured in cells, so Korean and emoji do not silently wrap a row + and desynchronise every later repaint. +- **A non-TTY gets prose.** Piped to a file or a CI log, the row degrades to one plain line per + phase — and detail-only changes are dropped, because without a repaint each would be a line. +- **Nothing it prints is trusted.** Every value that came from another process — a container id, + a docker progress line, an error — is stripped of escape sequences first. A stray cursor + movement inside a row the engine did not write makes its row count a lie for the rest of the run. + +## Open questions + +1. **How does `dev` find the agent?** The example declares it as a default export from + `src/agent.ts`. Convention (look for `agent.ts` near the working directory) or configuration + (`--agent `), and if convention, whose directory — the process's, or a project root + located by walking up. Unanswered, and it is the same question + [`project-layout.md`](./project-layout.md) leaves open about layout generally. +2. **What happens to the session on exit?** `Esc` ends the terminal UI; the sandbox behind it is + still running. Destroying it is the safe default and throws away a warm container; `detach()` + keeps it resumable and bills for it on a paid backend. The AI SDK's guide says one session per + terminal run and to persist `detach()` state if you want to resume — which makes this a + `please dev` policy question, not an SDK one. +3. **What is `contextSize`?** `runAgentTUI` shows usage against a context window only when told + the number. No adapter reports it, so it is either a per-model table we maintain, a flag, or + left off. +4. **Does the sandbox option apply?** `runAgentTUI({ sandbox })` forwards an + `Experimental_SandboxSession` to *tool execution* in the host process. That is a different + thing from the sandbox the harness runs inside, which reaches the agent as a + `HarnessV1SandboxProvider`. Almost certainly left unset; worth stating so nobody wires the two + together by name. + +## Attribution + +`@ai-sdk/tui` and eve are both Apache-2.0, as is this repository. The eve-derived files carry an +attribution header naming the file they came from, and `NOTICE` records the derivation. diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..709be95 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,23 @@ +{ + "name": "@pleasedev/cli", + "type": "module", + "version": "0.0.0", + "private": true, + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/pleaseai/please.git", + "directory": "packages/cli" + }, + "scripts": { + "test": "bun test", + "test:coverage": "bun test --coverage --coverage-reporter=lcov --reporter=junit --reporter-outfile=test-report.junit.xml", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "picocolors": "^1.1.1" + }, + "devDependencies": { + "typescript": "^6" + } +} diff --git a/packages/cli/src/ui/ansi.ts b/packages/cli/src/ui/ansi.ts new file mode 100644 index 0000000..ca65521 --- /dev/null +++ b/packages/cli/src/ui/ansi.ts @@ -0,0 +1,32 @@ +/** + * The escape sequences the boot chrome writes, named. + * + * Built from `String.fromCharCode(27)` rather than a literal escape so the source stays + * copy-pasteable and greppable: a raw control byte in a file is invisible in a diff, a review + * comment, and half the tools that would show it. + */ + +/** The escape character every sequence below starts with. */ +export const ESC = String.fromCharCode(27) + +/** Hide and show the hardware cursor. The renderer draws its own where it needs one. */ +export const HIDE_CURSOR = `${ESC}[?25l` +export const SHOW_CURSOR = `${ESC}[?25h` + +/** Erase from the cursor to the end of the screen. */ +export const CLEAR_TO_END = `${ESC}[0J` + +/** + * Synchronized update markers. + * + * A terminal that understands them presents the whole repaint at once instead of showing the + * cleared-but-not-yet-redrawn intermediate state; one that does not ignores them as an + * unknown private mode. Either way the sequence is safe to emit. + */ +export const SYNC_START = `${ESC}[?2026h` +export const SYNC_END = `${ESC}[?2026l` + +/** Move the cursor up `lines` lines and to column 0 (CPL). Treats 0 as 1, so callers guard. */ +export function cursorPreviousLine(lines: number): string { + return `${ESC}[${lines}F` +} diff --git a/packages/cli/src/ui/boot-row.ts b/packages/cli/src/ui/boot-row.ts new file mode 100644 index 0000000..cc082fb --- /dev/null +++ b/packages/cli/src/ui/boot-row.ts @@ -0,0 +1,204 @@ +/** + * The boot row: one animated line saying what the run is waiting on. + * + * `please dev` spends its first stretch doing nothing the user can see — pulling an image, + * creating a container, letting the harness adapter install its runtime inside it. That is + * tens of seconds during which a silent terminal is indistinguishable from a hang, so the row + * names the phase and pulses to show the process is alive. + * + * Phases that finish are committed to scrollback, where they stay; the row itself is erased on + * {@link BootRow.stop} so the interactive UI starts against a clean line. + * + * Two things are deliberately not animated: a non-TTY (a CI log, a pipe) gets one plain line + * per phase instead, and so does any run whose output is being captured, because a repaint + * written into a file is just escape noise. + * + * Derived from vercel/eve `packages/eve/src/cli/ui/live-row.ts` (Apache-2.0) — see NOTICE. + */ + +import type { CliTheme } from './theme' +import process from 'node:process' +import { LiveRegion } from './live-region' +import { + assertPulseSequence, + PROGRESS_PULSE_ASCII_GLYPH, + PROGRESS_PULSE_GLYPH, + PROGRESS_PULSE_SEQUENCE, + pulseStepDurationMs, +} from './progress-pulse' +import { sanitizeForTerminal } from './sanitize' +import { ellipsize, visibleLength } from './text' +import { createCliTheme } from './theme' + +const DEFAULT_COLUMNS = 80 + +export interface BootRow { + /** Replace the row. `detail` is the changing part — a container id, a pull percentage. */ + update: (message: string, detail?: string) => void + /** Commit a finished phase to scrollback above the row, where it stays. */ + commit: (line: string) => void + /** Stop animating and erase the row. Committed lines are untouched. Idempotent. */ + stop: () => void +} + +export interface BootRowOutput { + readonly columns?: number + readonly isTTY?: boolean + write: (chunk: string) => unknown +} + +export interface BootRowOptions { + readonly output?: BootRowOutput + readonly theme?: CliTheme + /** Force animation on or off. Defaults to whether the output is a TTY. */ + readonly animate?: boolean + /** Force the ASCII glyph. Defaults to whether the environment claims a UTF-8 locale. */ + readonly ascii?: boolean + readonly pulseSequence?: string +} + +/** Collapse whitespace so a multi-line detail cannot turn one row into several. */ +function toRowText(input: string): string { + return sanitizeForTerminal(input).replace(/\s+/g, ' ').trim() +} + +function supportsUnicode(): boolean { + if (process.env.TERM === 'dumb') { + return false + } + const locale = process.env.LC_ALL ?? process.env.LC_CTYPE ?? process.env.LANG ?? '' + return /UTF-?8/i.test(locale) +} + +/** + * Lay out `glyph message detail` in one line. + * + * The detail is fitted to whatever the message leaves, and dropped to an ellipsis rather than + * allowed to wrap. One column is held back: a row filling the last cell leaves some terminals + * in a pending-wrap state whose interaction with the next repaint differs between them. + */ +function renderBootRow(input: { + readonly theme: CliTheme + readonly glyph: string + readonly lit: boolean + readonly message: string + readonly detail: string + readonly columns: number | undefined +}): string { + const maxWidth = Math.max(0, (input.columns ?? DEFAULT_COLUMNS) - 1) + const head = `${input.message}${input.detail === '' ? '...' : ''}` + const glyph = input.lit ? input.theme.success(input.glyph) : ' ' + const headWidth = visibleLength(input.glyph) + 1 + visibleLength(head) + + if (headWidth >= maxWidth) { + return ellipsize(`${glyph} ${head}`, maxWidth) + } + if (input.detail === '') { + return `${glyph} ${head}` + } + + const detail = ellipsize(` ${input.detail}`, maxWidth - headWidth) + return `${glyph} ${head}${input.theme.muted(detail)}` +} + +/** Start the boot row. Always returns a handle, animating or not. */ +export function startBootRow(options: BootRowOptions = {}): BootRow { + const output = options.output ?? process.stdout + const theme = options.theme ?? createCliTheme() + const pulseSequence = options.pulseSequence ?? PROGRESS_PULSE_SEQUENCE + assertPulseSequence(pulseSequence) + + const animate = options.animate ?? output.isTTY === true + const glyph = (options.ascii ?? !supportsUnicode()) + ? PROGRESS_PULSE_ASCII_GLYPH + : PROGRESS_PULSE_GLYPH + const live = animate ? new LiveRegion(output) : undefined + + let stepIndex = 0 + let lit = pulseSequence[0] === '1' + let current: { message: string, detail: string } | undefined + let loggedMessage: string | undefined + let painted = false + let stopped = false + let timer: ReturnType | undefined + + const row = (phase: { message: string, detail: string }): string => + renderBootRow({ theme, glyph, lit, ...phase, columns: output.columns }) + + const paint = (): void => { + if (current === undefined || live === undefined) { + return + } + live.update([row(current)]) + painted = true + } + + // Re-armed per step rather than driven by one interval: the steps have unequal durations, + // and an unref'd timer must never be the reason the process stays alive. + const scheduleStep = (): void => { + timer = setTimeout(() => { + if (stopped) { + return + } + stepIndex = (stepIndex + 1) % pulseSequence.length + const nextLit = pulseSequence[stepIndex] === '1' + if (nextLit !== lit) { + lit = nextLit + paint() + } + scheduleStep() + }, pulseStepDurationMs(stepIndex, pulseSequence.length)) + timer.unref?.() + } + + return { + update(message, detail = '') { + if (stopped) { + return + } + current = { message: toRowText(message), detail: toRowText(detail) } + + if (!animate) { + // Detail-only changes are dropped: without a repaint they would be one log line each. + if (current.message !== loggedMessage) { + loggedMessage = current.message + output.write(`${current.message}...\n`) + } + return + } + + paint() + if (timer === undefined) { + scheduleStep() + } + }, + commit(line) { + if (stopped) { + return + } + const committed = toRowText(line) + // The committed phase is over, so the next `update` starts a new one even when it + // reuses the message. Without this, a repeated name reads as a detail-only change + // and the non-animating path prints nothing at all. + loggedMessage = undefined + if (live === undefined || current === undefined) { + output.write(`${committed}\n`) + return + } + live.flush([committed], [row(current)]) + painted = true + }, + stop() { + if (stopped) { + return + } + stopped = true + if (timer !== undefined) { + clearTimeout(timer) + } + if (live !== undefined && painted) { + live.clear() + } + }, + } +} diff --git a/packages/cli/src/ui/index.ts b/packages/cli/src/ui/index.ts new file mode 100644 index 0000000..c5d3469 --- /dev/null +++ b/packages/cli/src/ui/index.ts @@ -0,0 +1,42 @@ +/** + * The boot chrome — everything `please dev` draws before the interactive UI takes the screen. + * + * The division is deliberate and is the whole reason this module is small. `@ai-sdk/tui` owns + * the session: it enters the alternate screen buffer, renders the transcript, tool sections, + * reasoning and approval prompts, and restores the terminal on exit. What it does not cover is + * everything either side of that — the banner, the phases of bringing a sandbox up, and the + * summary left behind afterwards — because none of it exists once an agent is streaming. + * + * So this is not a terminal UI framework. It is the main screen's half of one. + */ + +export { ESC } from './ansi' + +export { type BootRow, type BootRowOptions, type BootRowOutput, startBootRow } from './boot-row' + +export { LiveRegion, type LiveRegionOptions, type LiveRegionOutput } from './live-region' + +export { + assertPulseSequence, + InvalidPulseSequenceError, + isProgressPulseVisible, + PROGRESS_PULSE_ASCII_GLYPH, + PROGRESS_PULSE_DURATION_MS, + PROGRESS_PULSE_GLYPH, + PROGRESS_PULSE_SEQUENCE, + pulseStepDurationMs, +} from './progress-pulse' + +export { sanitizeForTerminal } from './sanitize' + +export { clipVisible, ellipsize, sliceVisible, visibleLength } from './text' + +export { + type CliRow, + type CliTheme, + type CliTone, + createCliTheme, + renderCliBanner, + renderCliSection, + renderCliTaggedLine, +} from './theme' diff --git a/packages/cli/src/ui/live-region.ts b/packages/cli/src/ui/live-region.ts new file mode 100644 index 0000000..b0c2424 --- /dev/null +++ b/packages/cli/src/ui/live-region.ts @@ -0,0 +1,95 @@ +/** + * The inline repaint engine: a few rows redrawn in place, above untouched scrollback. + * + * Deliberately *not* the alternate screen buffer. `@ai-sdk/tui` takes that buffer when the + * interactive session starts, and everything it draws there disappears when it exits. The boot + * chrome runs before that hand-off and belongs to the main screen, where its output survives — + * a run that fails to reach the TUI at all has to leave its diagnosis behind on the terminal. + * + * Two regions are maintained. Committed rows are printed once and owned by the terminal + * thereafter; live rows are redrawn on every update by moving to the top of the previous live + * region, clearing to the end of the screen, and reprinting. + * + * Writes go through the `write` captured at construction so that a later stdout interception — + * the usual way a CLI captures a subprocess's output — never sees the engine's own paints and + * mistakes them for program output. + * + * Derived from vercel/eve `packages/eve/src/cli/ui/live-region.ts` (Apache-2.0) — see NOTICE. + */ + +import { CLEAR_TO_END, cursorPreviousLine, HIDE_CURSOR, SHOW_CURSOR, SYNC_END, SYNC_START } from './ansi' + +export interface LiveRegionOutput { + write: (chunk: string) => unknown +} + +export interface LiveRegionOptions { + /** Wrap each paint in synchronized-update markers to avoid a visible flicker. */ + readonly synchronized?: boolean +} + +export class LiveRegion { + readonly #write: (chunk: string) => unknown + readonly #synchronized: boolean + /** Screen rows the live region currently occupies. */ + #liveRowCount = 0 + + constructor(output: LiveRegionOutput, options: LiveRegionOptions = {}) { + this.#write = output.write.bind(output) + this.#synchronized = options.synchronized ?? true + } + + hideCursor(): void { + this.#write(HIDE_CURSOR) + } + + showCursor(): void { + this.#write(SHOW_CURSOR) + } + + /** + * Repaint the live region from `liveRows`. + * + * Each row must already be styled and fit the terminal width: one row is one screen line, + * and a row that wraps makes the row count — and therefore every later repaint — wrong. + */ + update(liveRows: readonly string[]): void { + this.#paint([], liveRows) + } + + /** Commit rows to scrollback above the live region, then repaint it. */ + flush(committedRows: readonly string[], liveRows: readonly string[]): void { + this.#paint(committedRows, liveRows) + } + + /** Erase the live region, leaving committed scrollback and the cursor at its former top. */ + clear(): void { + if (this.#liveRowCount === 0) { + this.#write(`\r${CLEAR_TO_END}`) + return + } + this.#write(`${this.#moveToTop()}${CLEAR_TO_END}`) + this.#liveRowCount = 0 + } + + #paint(committedRows: readonly string[], liveRows: readonly string[]): void { + const body = this.#moveToTop() + + CLEAR_TO_END + + committedRows.map(row => `${row}\n`).join('') + + liveRows.join('\n') + + this.#write(this.#synchronized ? `${SYNC_START}${body}${SYNC_END}` : body) + this.#liveRowCount = liveRows.length + } + + /** + * The sequence that returns to column 0 of the first live row. + * + * After a paint the cursor sits at the end of the last live row, so the move is + * `liveRowCount - 1` lines up. CPL treats a 0 parameter as 1, so a single row — or none at + * all — uses a bare carriage return instead. + */ + #moveToTop(): string { + return this.#liveRowCount <= 1 ? '\r' : cursorPreviousLine(this.#liveRowCount - 1) + } +} diff --git a/packages/cli/src/ui/progress-pulse.ts b/packages/cli/src/ui/progress-pulse.ts new file mode 100644 index 0000000..471f6c4 --- /dev/null +++ b/packages/cli/src/ui/progress-pulse.ts @@ -0,0 +1,66 @@ +/** + * The one-cell indicator the boot row animates. + * + * A pulse rather than a spinner: the boot is not a sequence of quick steps whose motion says + * something, it is a handful of long waits — pulling an image, installing the runtime inside + * the container — where a fast spinner overstates how much is happening. The lit/unlit pattern + * is asymmetric so the loop reads as a heartbeat rather than a blink. + * + * Derived from vercel/eve `packages/eve/src/cli/ui/progress-pulse.ts` (Apache-2.0) — see NOTICE. + */ + +export const PROGRESS_PULSE_GLYPH = '▪' + +/** Single-cell fallback for terminals without the block glyph. */ +export const PROGRESS_PULSE_ASCII_GLYPH = '*' + +/** Lit (`1`) and unlit (`0`) steps of one loop. Must be 8 or 16 steps long. */ +export const PROGRESS_PULSE_SEQUENCE = '1111110000111111' + +/** Wall-clock duration of one complete loop. */ +export const PROGRESS_PULSE_DURATION_MS = 1000 + +export class InvalidPulseSequenceError extends RangeError { + constructor(message: string) { + super(message) + this.name = 'InvalidPulseSequenceError' + } +} + +/** Reject a sequence that would animate at an unintended rate or draw nothing. */ +export function assertPulseSequence(sequence: string): void { + if (sequence.length !== 8 && sequence.length !== 16) { + throw new InvalidPulseSequenceError('A pulse sequence must contain 8 or 16 steps.') + } + if (/[^01]/.test(sequence)) { + throw new InvalidPulseSequenceError('A pulse sequence step must be "0" or "1".') + } + // An all-unlit sequence type-checks and animates, but never draws the glyph — which is + // the one thing the row exists to show. Silence there reads as a hang. + if (!sequence.includes('1')) { + throw new InvalidPulseSequenceError('A pulse sequence must have at least one lit step.') + } +} + +/** + * Milliseconds step `index` is held for. + * + * Computed from the rounded boundaries either side of the step rather than by dividing the + * duration, so the rounding error does not accumulate and a 16-step loop still takes exactly + * {@link PROGRESS_PULSE_DURATION_MS}. + */ +export function pulseStepDurationMs(index: number, stepCount: number): number { + const start = Math.round((index * PROGRESS_PULSE_DURATION_MS) / stepCount) + const end = Math.round(((index + 1) * PROGRESS_PULSE_DURATION_MS) / stepCount) + return end - start +} + +/** Whether the pulse is lit at an elapsed time, for a caller driving its own clock. */ +export function isProgressPulseVisible( + elapsedMs: number, + sequence: string = PROGRESS_PULSE_SEQUENCE, +): boolean { + const loopTime = elapsedMs % PROGRESS_PULSE_DURATION_MS + const step = Math.floor((loopTime * sequence.length) / PROGRESS_PULSE_DURATION_MS) + return sequence[step] === '1' +} diff --git a/packages/cli/src/ui/sanitize.ts b/packages/cli/src/ui/sanitize.ts new file mode 100644 index 0000000..e3f41c4 --- /dev/null +++ b/packages/cli/src/ui/sanitize.ts @@ -0,0 +1,189 @@ +/** + * Escape-sequence removal for text the CLI did not author. + * + * Every string the boot chrome prints — a docker progress line, an adapter's stderr, a + * container id — is text some other program produced, and any of it may carry escape + * sequences. Left in place they do more than look wrong: the live region's repaint counts + * screen rows, and a stray cursor movement inside a row it did not write makes that count a + * lie for the rest of the run. + * + * Stripping the escape *character* alone is not enough, because the rest of the sequence + * (`[31m`) is ordinary text and would stay visible. So sequences are skipped as units: + * CSI runs to its final byte, string controls (OSC, DCS, …) run to their terminator, and + * everything else follows the general ECMA-48 shape — zero or more intermediate bytes, + * then one final byte — which covers a charset designation (`ESC ( B`), the screen + * alignment test (`ESC # 8`) and the single-byte forms (`ESC 7`, `ESC c`) alike. + * + * Malformed input stops the scan rather than extending it: a byte that cannot legally + * continue a sequence is treated as text, because reading on for a terminator that is not + * coming would delete real output on the way to it. + * + * Derived from vercel/eve `packages/eve/src/cli/ui/output.ts` (Apache-2.0) — see NOTICE. + */ + +const ESCAPE = 0x1B +const BELL = 0x07 +const STRING_TERMINATOR = 0x9C +const CSI_INTRODUCER = 0x5B +const CSI_FINAL_BYTE_START = 0x40 +const CSI_FINAL_BYTE_END = 0x7E +const STRING_TERMINATOR_BACKSLASH = 0x5C + +interface Scan { + readonly value: string + readonly start: number +} + +/** ESC-introduced string controls: DCS, SOS, OSC, PM, APC. */ +function isEscStringControl(codePoint: number): boolean { + return codePoint === 0x50 || codePoint === 0x58 || codePoint === 0x5D + || codePoint === 0x5E || codePoint === 0x5F +} + +/** The same five controls in their single-byte C1 spellings. */ +function isC1StringControl(codePoint: number): boolean { + return codePoint === 0x90 || codePoint === 0x98 || codePoint === 0x9D + || codePoint === 0x9E || codePoint === 0x9F +} + +/** + * ECMA-48 escape-sequence bytes: `ESC`, zero or more intermediates, then one final byte. + * + * A charset designation (`ESC ( B`) is just one instance of that shape, and so is the + * screen-alignment test (`ESC # 8`) — which is why the general rule replaces the special + * case for the former. Matching only the charset introducers left the `8` of `ESC # 8` + * behind as ordinary text. + */ +function isIntermediateByte(codePoint: number): boolean { + return codePoint >= 0x20 && codePoint <= 0x2F +} + +function isFinalByte(codePoint: number): boolean { + return codePoint >= 0x30 && codePoint <= 0x7E +} + +/** C0 and C1 controls, minus the newline and carriage return a caller may legitimately want. */ +function isUnsafeControl(codePoint: number): boolean { + return (codePoint >= 0x00 && codePoint <= 0x08) + || (codePoint >= 0x0B && codePoint <= 0x1F) + || (codePoint >= 0x7F && codePoint <= 0x9F) +} + +/** Skip to just past a CSI sequence's final byte. */ +function skipCsi(scan: Scan): number { + let index = scan.start + while (index < scan.value.length) { + const codePoint = scan.value.codePointAt(index) + if (codePoint === undefined) { + break + } + index += String.fromCodePoint(codePoint).length + if (codePoint >= CSI_FINAL_BYTE_START && codePoint <= CSI_FINAL_BYTE_END) { + return index + } + } + return index +} + +/** Skip to just past a string control's terminator: BEL, ST, or `ESC \`. */ +function skipStringControl(scan: Scan): number { + let index = scan.start + while (index < scan.value.length) { + const codePoint = scan.value.codePointAt(index) + if (codePoint === undefined) { + break + } + const next = index + String.fromCodePoint(codePoint).length + if (codePoint === BELL || codePoint === STRING_TERMINATOR) { + return next + } + if (codePoint === ESCAPE && scan.value.codePointAt(next) === STRING_TERMINATOR_BACKSLASH) { + return next + 1 + } + index = next + } + return index +} + +/** Skip whatever an ESC introduced, returning the index of the next text code point. */ +function skipEscape(scan: Scan): number { + const next = scan.start + 1 + const codePoint = scan.value.codePointAt(next) + if (codePoint === undefined) { + return next + } + if (codePoint === CSI_INTRODUCER) { + return skipCsi({ value: scan.value, start: next + 1 }) + } + if (isEscStringControl(codePoint)) { + return skipStringControl({ value: scan.value, start: next + 1 }) + } + + const width = String.fromCodePoint(codePoint).length + if (!isIntermediateByte(codePoint)) { + // A final byte straight after ESC ends the sequence there: `ESC 7`, `ESC c`. + return next + width + } + + // Intermediates run until the final byte that terminates the sequence. None of it is text. + let index = next + width + while (index < scan.value.length) { + const following = scan.value.codePointAt(index) + if (following === undefined) { + break + } + if (isFinalByte(following)) { + return index + String.fromCodePoint(following).length + } + // Only an intermediate or a final byte can legally continue the sequence. Anything else + // means the input is malformed — stop here and let it be text. Scanning on for a final + // byte would swallow every character up to the next ASCII letter, which on untrusted + // output is real text disappearing rather than an escape being removed. + if (!isIntermediateByte(following)) { + return index + } + index += String.fromCodePoint(following).length + } + return index +} + +/** + * Remove escape sequences and unsafe control characters, leaving the printable text. + * + * The newline and the tab survive, because callers lay text out with them — a section value + * is split on newlines deliberately. Every other control character goes, the carriage return + * included: a CR inside a row returns the cursor to column 0 mid-paint, and the live region + * cannot recover a row count from that. + */ +export function sanitizeForTerminal(input: string): string { + let output = '' + let index = 0 + + while (index < input.length) { + const codePoint = input.codePointAt(index) + if (codePoint === undefined) { + break + } + + if (codePoint === ESCAPE) { + index = skipEscape({ value: input, start: index }) + continue + } + if (codePoint === 0x9B) { // C1 CSI + index = skipCsi({ value: input, start: index + 1 }) + continue + } + if (isC1StringControl(codePoint)) { + index = skipStringControl({ value: input, start: index + 1 }) + continue + } + + const character = String.fromCodePoint(codePoint) + index += character.length + if (!isUnsafeControl(codePoint)) { + output += character + } + } + + return output +} diff --git a/packages/cli/src/ui/text.ts b/packages/cli/src/ui/text.ts new file mode 100644 index 0000000..fd9552c --- /dev/null +++ b/packages/cli/src/ui/text.ts @@ -0,0 +1,187 @@ +/** + * How wide a string is on screen, and how to cut it without breaking anything. + * + * `String.length` is the wrong measure three times over for the text this CLI prints: a + * Korean or CJK glyph occupies two terminal cells, an emoji sequence is many code points but + * one glyph in two cells, and an ANSI escape occupies none at all. A progress row fitted with + * `.length` therefore wraps — and a wrapped row breaks the live region, whose repaint assumes + * one row is one screen line. + * + * Measurement is per grapheme cluster via `Intl.Segmenter`, which the runtime already ships; + * a cluster's width is the widest code point in it, so combining marks and zero-width joiners + * cost nothing on top of the base character they attach to. + * + * Derived from vercel/eve `packages/eve/src/cli/ui/terminal-text.ts` (Apache-2.0) — see + * NOTICE. The port drops eve's hand-written zero-width code point table in favour of Unicode + * property escapes, and keeps only the measuring and clipping the boot chrome uses. + */ + +import { ESC } from './ansi' + +const RESET = `${ESC}[0m` +const ANSI_BODY = '\\[[0-?]*[ -/]*[@-~]' +const ANSI_PATTERN = new RegExp(ESC + ANSI_BODY, 'g') +const ANSI_PREFIX_PATTERN = new RegExp(`^${ESC}${ANSI_BODY}`) +/** Nonspacing and enclosing marks plus format characters (ZWJ, variation selectors). */ +const ZERO_WIDTH_PATTERN = /^[\p{Mn}\p{Me}\p{Cf}]$/u +const EMOJI_PRESENTATION_PATTERN = /\p{Emoji_Presentation}/u +const EXTENDED_PICTOGRAPHIC_PATTERN = /\p{Extended_Pictographic}/u +const KEYCAP_PATTERN = /^[#*0-9]\u{FE0F}?\u{20E3}$/u +const VARIATION_SELECTOR_16 = '\u{FE0F}' + +/** Cells a tab is rendered as. Fixed rather than stop-relative: a row has no column origin. */ +const TAB_WIDTH = 4 + +const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + +/** East Asian Wide and Fullwidth ranges. No `\p{East_Asian_Width}` exists in JS regex. */ +function isWideCodePoint(codePoint: number): boolean { + return codePoint >= 0x1100 && ( + codePoint <= 0x115F + || codePoint === 0x2329 || codePoint === 0x232A + || (codePoint >= 0x2E80 && codePoint <= 0xA4CF && codePoint !== 0x303F) + || (codePoint >= 0xAC00 && codePoint <= 0xD7A3) + || (codePoint >= 0xF900 && codePoint <= 0xFAFF) + || (codePoint >= 0xFE10 && codePoint <= 0xFE19) + || (codePoint >= 0xFE30 && codePoint <= 0xFE6F) + || (codePoint >= 0xFF00 && codePoint <= 0xFF60) + || (codePoint >= 0xFFE0 && codePoint <= 0xFFE6) + || (codePoint >= 0x1F300 && codePoint <= 0x1F64F) + || (codePoint >= 0x1F900 && codePoint <= 0x1F9FF) + || (codePoint >= 0x20000 && codePoint <= 0x3FFFD) + ) +} + +function codePointWidth(character: string, codePoint: number): number { + if (codePoint === 0x09) { + return TAB_WIDTH + } + if (codePoint < 0x20 || (codePoint >= 0x7F && codePoint < 0xA0)) { + return 0 + } + if (ZERO_WIDTH_PATTERN.test(character)) { + return 0 + } + return isWideCodePoint(codePoint) ? 2 : 1 +} + +/** + * Cells one grapheme cluster occupies. + * + * A cluster with emoji presentation is at least two cells even when its base code point is + * narrow — a keycap digit and a heart carrying `U+FE0F` both render as wide glyphs. + */ +function graphemeWidth(grapheme: string): number { + let width = 0 + for (const character of grapheme) { + const codePoint = character.codePointAt(0) + if (codePoint !== undefined) { + width = Math.max(width, codePointWidth(character, codePoint)) + } + } + + const emojiPresentation = EMOJI_PRESENTATION_PATTERN.test(grapheme) + || KEYCAP_PATTERN.test(grapheme) + || (grapheme.includes(VARIATION_SELECTOR_16) && EXTENDED_PICTOGRAPHIC_PATTERN.test(grapheme)) + + return emojiPresentation ? Math.max(2, width) : width +} + +interface TextUnit { + readonly text: string + readonly width: number + readonly ansi: boolean +} + +/** Split into indivisible units: whole escape sequences (width 0) and grapheme clusters. */ +function textUnits(input: string): TextUnit[] { + const units: TextUnit[] = [] + let index = 0 + + while (index < input.length) { + const remaining = input.slice(index) + const ansi = ANSI_PREFIX_PATTERN.exec(remaining) + if (ansi !== null) { + units.push({ text: ansi[0], width: 0, ansi: true }) + index += ansi[0].length + continue + } + + // `search` runs the pattern from 0 and restores `lastIndex`, so the `g` flag is inert here. + const nextAnsi = remaining.search(ANSI_PATTERN) + const plain = remaining.slice(0, nextAnsi === -1 ? remaining.length : nextAnsi) + for (const { segment } of segmenter.segment(plain)) { + units.push({ text: segment, width: graphemeWidth(segment), ansi: false }) + } + index += plain.length + } + + return units +} + +/** Terminal cells the string occupies, ignoring styling. */ +export function visibleLength(input: string): number { + let width = 0 + for (const unit of textUnits(input)) { + width += unit.width + } + return width +} + +/** + * The leading substring that fits in `width` cells. + * + * A wide grapheme that would straddle the limit is dropped whole rather than half-printed, + * and any styling that follows the cut is carried along so a truncated string does not end + * mid-escape. + */ +export function sliceVisible(input: string, width: number): string { + if (width <= 0) { + return '' + } + + const units = textUnits(input) + let output = '' + let visible = 0 + let index = 0 + + while (index < units.length && visible < width) { + const unit = units[index]! + if (unit.width > 0 && visible + unit.width > width) { + break + } + output += unit.text + visible += unit.width + index += 1 + } + + while (units[index]?.ansi === true) { + output += units[index]!.text + index += 1 + } + + return output +} + +/** Clip to `width` cells, resetting styling if the cut left a sequence open. */ +export function clipVisible(input: string, width: number): string { + if (visibleLength(input) <= width) { + return input + } + const sliced = sliceVisible(input, width) + return sliced.includes(ESC) ? `${sliced}${RESET}` : sliced +} + +/** Clip to `width` cells, marking the cut with an ellipsis when one was made. */ +export function ellipsize(input: string, width: number): string { + if (width <= 0) { + return '' + } + if (visibleLength(input) <= width) { + return input + } + if (width === 1) { + return '…' + } + return `${clipVisible(input, width - 1)}…` +} diff --git a/packages/cli/src/ui/theme.ts b/packages/cli/src/ui/theme.ts new file mode 100644 index 0000000..0853e33 --- /dev/null +++ b/packages/cli/src/ui/theme.ts @@ -0,0 +1,146 @@ +/** + * The palette, and the pieces of chrome drawn with it. + * + * Colour is resolved once at construction rather than per call, so a single run never + * switches halfway through. *Whether* it is on is picocolors' decision rather than this + * module's: `NO_COLOR`, `--no-color` and `TERM=dumb` turn it off; `FORCE_COLOR` and `CI` + * turn it on with no terminal in sight; a plain pipe leaves it off. A caller that needs a + * definite answer passes `color`, which is what the tests do. + * + * Derived from vercel/eve `packages/eve/src/cli/ui/output.ts` (Apache-2.0) — see NOTICE. + */ + +import picocolors from 'picocolors' +import { sanitizeForTerminal } from './sanitize' +import { visibleLength } from './text' + +export type CliTone + = | 'accent' + | 'danger' + | 'default' + | 'info' + | 'muted' + | 'success' + | 'warning' + +export interface CliTheme { + /** Whether the palette actually emits escapes. */ + readonly color: boolean + accent: (text: string) => string + danger: (text: string) => string + heading: (text: string) => string + info: (text: string) => string + label: (text: string) => string + muted: (text: string) => string + plain: (text: string) => string + success: (text: string) => string + warning: (text: string) => string +} + +/** One labelled value in a section. */ +export interface CliRow { + readonly label: string + readonly value: string + readonly tone?: CliTone +} + +export function createCliTheme(options: { color?: boolean } = {}): CliTheme { + // Delegated to picocolors outright rather than gated on `process.stdout.isTTY`. Its + // detection already includes the TTY branch, and it is what honours `NO_COLOR`, + // `--no-color` and `TERM=dumb` — a bare `isTTY` overrode all three and emitted escapes + // into a terminal that asked for none. ANDing the two is no better: it re-breaks the + // branches that deliberately enable colour *without* a TTY (`FORCE_COLOR`, `CI`), which + // is how a piped or CI-run build keeps its colour. An explicit `color` still wins. + const colors = picocolors.createColors(options.color ?? picocolors.isColorSupported) + + return { + color: colors.isColorSupported, + accent: text => colors.cyan(text), + danger: text => colors.red(text), + heading: text => colors.bold(colors.cyan(text)), + info: text => colors.blue(text), + label: text => colors.bold(text), + muted: text => colors.dim(text), + plain: text => text, + success: text => colors.green(text), + warning: text => colors.yellow(text), + } +} + +function applyTone(theme: CliTheme, tone: CliTone, value: string): string { + switch (tone) { + case 'accent': return theme.accent(value) + case 'danger': return theme.danger(value) + case 'info': return theme.info(value) + case 'muted': return theme.muted(value) + case 'success': return theme.success(value) + case 'warning': return theme.warning(value) + default: return theme.plain(value) + } +} + +/** Indent every line after the first, so a multi-line value stays under its own column. */ +function indentContinuation(lines: readonly string[], indent: string): string[] { + const [first = '', ...rest] = lines + return [first, ...rest.map(line => `${indent}${line}`)] +} + +/** A title with a rule under it, and an optional subtitle. */ +export function renderCliBanner( + theme: CliTheme, + input: { readonly title: string, readonly subtitle?: string }, +): string { + const title = sanitizeForTerminal(input.title) + const lines = [theme.heading(title), theme.muted('='.repeat(visibleLength(title)))] + if (input.subtitle !== undefined) { + lines.push(theme.muted(sanitizeForTerminal(input.subtitle))) + } + return lines.join('\n') +} + +/** + * A titled block of labelled values, with the values aligned to one column. + * + * Padding is by `label.length` rather than visible width because labels are authored here, + * not received — an English identifier, never user text. Values are sanitized because they + * are the opposite: a container id, a path, an error a process produced. + */ +export function renderCliSection( + theme: CliTheme, + input: { readonly title: string, readonly rows: readonly CliRow[] }, +): string { + const rows = input.rows.map(row => ({ + label: sanitizeForTerminal(row.label), + value: sanitizeForTerminal(row.value), + tone: row.tone, + })) + const labelWidth = rows.reduce((width, row) => Math.max(width, row.label.length), 0) + const lines = [theme.accent(sanitizeForTerminal(input.title))] + + for (const row of rows) { + const value = applyTone(theme, row.tone ?? 'default', row.value) + const [first = '', ...rest] = indentContinuation( + value.split('\n'), + `${' '.repeat(labelWidth)} `, + ) + lines.push(`${theme.label(row.label.padEnd(labelWidth))} ${first}`) + lines.push(...rest) + } + + return lines.join('\n') +} + +/** One `[TAG] message` line, for the phases the boot prints before the TUI takes over. */ +export function renderCliTaggedLine( + theme: CliTheme, + input: { readonly tag: string, readonly message: string, readonly tone?: CliTone }, +): string { + const prefix = `[${sanitizeForTerminal(input.tag).toUpperCase()}]` + const message = applyTone(theme, input.tone ?? 'default', sanitizeForTerminal(input.message)) + const [first = '', ...rest] = indentContinuation( + message.split('\n'), + `${' '.repeat(visibleLength(prefix))} `, + ) + const head = `${theme.muted(prefix)} ${first}` + return rest.length === 0 ? head : `${head}\n${rest.join('\n')}` +} diff --git a/packages/cli/test/ui/boot-row.test.ts b/packages/cli/test/ui/boot-row.test.ts new file mode 100644 index 0000000..bd07dbd --- /dev/null +++ b/packages/cli/test/ui/boot-row.test.ts @@ -0,0 +1,164 @@ +/** + * The boot row, driven without a clock. + * + * Animation is exercised only through the first paint: the pulse's timing is + * `progress-pulse`'s to test, and a test that waited on real timers would buy a slower suite + * and nothing else. What is asserted here is the part a hang would expose — that a non-TTY + * still says what is happening, that a committed phase survives, and that a stopped row is + * inert. + */ +import { describe, expect, it } from 'bun:test' +import { startBootRow } from '../../src/ui/boot-row' +import { visibleLength } from '../../src/ui/text' +import { createCliTheme } from '../../src/ui/theme' + +const ESC = String.fromCharCode(27) +const theme = createCliTheme({ color: false }) + +function fakeOutput(columns = 80): { + chunks: string[] + columns: number + write: (chunk: string) => void +} { + const chunks: string[] = [] + return { chunks, columns, write: chunk => void chunks.push(chunk) } +} + +function options(output: ReturnType, animate: boolean) { + return { output, theme, animate, ascii: true } +} + +describe('startBootRow, without animation', () => { + it('writes one line per phase', () => { + const output = fakeOutput() + const row = startBootRow(options(output, false)) + + row.update('starting the container') + row.update('bootstrapping the runtime') + row.stop() + + expect(output.chunks).toEqual([ + 'starting the container...\n', + 'bootstrapping the runtime...\n', + ]) + }) + + it('ignores a detail-only change, which without a repaint would be a line of its own', () => { + const output = fakeOutput() + const row = startBootRow(options(output, false)) + + row.update('pulling', 'layer 1') + row.update('pulling', 'layer 2') + row.stop() + + expect(output.chunks).toEqual(['pulling...\n']) + }) + + it('prints a phase again when its name is reused after a commit', () => { + const output = fakeOutput() + const row = startBootRow(options(output, false)) + + row.update('installing') + row.commit('deps ready') + row.update('installing') + row.stop() + + expect(output.chunks).toEqual([ + 'installing...\n', + 'deps ready\n', + 'installing...\n', + ]) + }) + + it('still commits a finished phase', () => { + const output = fakeOutput() + const row = startBootRow(options(output, false)) + + row.update('pulling') + row.commit('image ready') + row.stop() + + expect(output.chunks).toEqual(['pulling...\n', 'image ready\n']) + }) +}) + +describe('startBootRow, animating', () => { + it('paints the phase with the pulse glyph', () => { + const output = fakeOutput() + const row = startBootRow(options(output, true)) + + row.update('starting the container') + row.stop() + + expect(output.chunks[0]).toContain('* starting the container...') + }) + + it('drops the trailing ellipsis once there is a detail to show instead', () => { + const output = fakeOutput() + const row = startBootRow(options(output, true)) + + row.update('pulling', 'node:22-bookworm') + row.stop() + + expect(output.chunks[0]).toContain('* pulling node:22-bookworm') + }) + + it('collapses a multi-line detail so one phase stays one row', () => { + const output = fakeOutput() + const row = startBootRow(options(output, true)) + + row.update('bootstrapping', 'first\nsecond') + row.stop() + + expect(output.chunks[0]).toContain('* bootstrapping first second') + expect(output.chunks[0]).not.toContain('\n') + }) + + it('keeps a row inside the terminal width even when the text is wide', () => { + const output = fakeOutput(24) + const row = startBootRow(options(output, true)) + + row.update('컨테이너를 시작하는 중', '아주 긴 상세 내용이 이어진다') + row.stop() + + const painted = output.chunks[0]!.replace(`\r${ESC}[0J`, '') + expect(visibleLength(painted)).toBeLessThanOrEqual(23) + }) + + it('writes a committed phase above the row it keeps painting', () => { + const output = fakeOutput() + const row = startBootRow(options(output, true)) + + row.update('pulling') + output.chunks.length = 0 + row.commit('image ready') + row.stop() + + expect(output.chunks[0]).toContain('image ready\n') + expect(output.chunks[0]).toContain('* pulling...') + }) + + it('commits as a plain line when no phase has been painted yet', () => { + const output = fakeOutput() + const row = startBootRow(options(output, true)) + + row.commit('nothing to do') + row.stop() + + expect(output.chunks).toEqual(['nothing to do\n']) + }) + + it('erases the row on stop and ignores everything after it', () => { + const output = fakeOutput() + const row = startBootRow(options(output, true)) + + row.update('pulling') + output.chunks.length = 0 + row.stop() + row.update('too late') + row.commit('too late') + row.stop() + + expect(output.chunks).toEqual([`\r${ESC}[0J`]) + }) +}) diff --git a/packages/cli/test/ui/live-region.test.ts b/packages/cli/test/ui/live-region.test.ts new file mode 100644 index 0000000..1c81a8f --- /dev/null +++ b/packages/cli/test/ui/live-region.test.ts @@ -0,0 +1,103 @@ +/** + * The repaint's cursor arithmetic, which is the only part that can go wrong silently. + * + * A wrong row count does not throw — it eats a line of scrollback on every later paint — so + * the assertions are about the exact sequences written, not about what the screen looks like. + */ +import { describe, expect, it } from 'bun:test' +import { LiveRegion } from '../../src/ui/live-region' + +const ESC = String.fromCharCode(27) +const CLEAR_TO_END = `${ESC}[0J` +const SYNC_START = `${ESC}[?2026h` +const SYNC_END = `${ESC}[?2026l` + +function fakeOutput(): { chunks: string[], write: (chunk: string) => void } { + const chunks: string[] = [] + return { chunks, write: chunk => void chunks.push(chunk) } +} + +describe('LiveRegion', () => { + it('starts from a carriage return when nothing has been painted yet', () => { + const output = fakeOutput() + + new LiveRegion(output, { synchronized: false }).update(['one']) + + expect(output.chunks).toEqual([`\r${CLEAR_TO_END}one`]) + }) + + it('moves up one line short of the row count, because the cursor ends on the last row', () => { + const output = fakeOutput() + const region = new LiveRegion(output, { synchronized: false }) + + region.update(['one', 'two', 'three']) + output.chunks.length = 0 + region.update(['four', 'five', 'six']) + + expect(output.chunks).toEqual([`${ESC}[2F${CLEAR_TO_END}four\nfive\nsix`]) + }) + + it('uses a carriage return rather than CPL for a single row, which CPL cannot express', () => { + const output = fakeOutput() + const region = new LiveRegion(output, { synchronized: false }) + + region.update(['one']) + output.chunks.length = 0 + region.update(['two']) + + expect(output.chunks).toEqual([`\r${CLEAR_TO_END}two`]) + }) + + it('terminates each committed row so it scrolls away above the live region', () => { + const output = fakeOutput() + const region = new LiveRegion(output, { synchronized: false }) + + region.update(['live']) + output.chunks.length = 0 + region.flush(['done'], ['live']) + + expect(output.chunks).toEqual([`\r${CLEAR_TO_END}done\nlive`]) + }) + + it('counts only the live rows, not the rows it committed', () => { + const output = fakeOutput() + const region = new LiveRegion(output, { synchronized: false }) + + region.flush(['a', 'b', 'c'], ['live']) + output.chunks.length = 0 + region.update(['live again']) + + expect(output.chunks).toEqual([`\r${CLEAR_TO_END}live again`]) + }) + + it('wraps a paint in synchronized-update markers by default', () => { + const output = fakeOutput() + + new LiveRegion(output).update(['one']) + + expect(output.chunks).toEqual([`${SYNC_START}\r${CLEAR_TO_END}one${SYNC_END}`]) + }) + + it('erases the region and forgets its rows, so the next paint does not move up', () => { + const output = fakeOutput() + const region = new LiveRegion(output, { synchronized: false }) + + region.update(['one', 'two']) + output.chunks.length = 0 + region.clear() + region.update(['three']) + + expect(output.chunks).toEqual([ + `${ESC}[1F${CLEAR_TO_END}`, + `\r${CLEAR_TO_END}three`, + ]) + }) + + it('clears from the current line when it has never painted', () => { + const output = fakeOutput() + + new LiveRegion(output, { synchronized: false }).clear() + + expect(output.chunks).toEqual([`\r${CLEAR_TO_END}`]) + }) +}) diff --git a/packages/cli/test/ui/progress-pulse.test.ts b/packages/cli/test/ui/progress-pulse.test.ts new file mode 100644 index 0000000..1778863 --- /dev/null +++ b/packages/cli/test/ui/progress-pulse.test.ts @@ -0,0 +1,68 @@ +/** + * The pulse's arithmetic. + * + * The property worth pinning is that the rounding does not drift: a loop whose steps summed to + * 999 or 1001 ms would slowly fall out of step with anything else timed against the same + * duration, and the drift is exactly the kind that never shows up in a short test run. + */ +import { describe, expect, it } from 'bun:test' +import { + assertPulseSequence, + InvalidPulseSequenceError, + isProgressPulseVisible, + PROGRESS_PULSE_DURATION_MS, + PROGRESS_PULSE_SEQUENCE, + pulseStepDurationMs, +} from '../../src/ui/progress-pulse' + +describe('assertPulseSequence', () => { + it('accepts the sequence the boot row uses', () => { + expect(() => assertPulseSequence(PROGRESS_PULSE_SEQUENCE)).not.toThrow() + }) + + it('accepts an eight-step sequence', () => { + expect(() => assertPulseSequence('11110000')).not.toThrow() + }) + + it('rejects a length that would animate at an unintended rate', () => { + expect(() => assertPulseSequence('1010')).toThrow(InvalidPulseSequenceError) + }) + + it('rejects a step that is neither lit nor unlit', () => { + expect(() => assertPulseSequence('1111000x')).toThrow(InvalidPulseSequenceError) + }) + + it('rejects an all-unlit sequence, which animates but never draws the glyph', () => { + expect(() => assertPulseSequence('00000000')).toThrow(InvalidPulseSequenceError) + }) + + it('accepts an always-lit sequence, which is a static indicator rather than a broken one', () => { + expect(() => assertPulseSequence('11111111')).not.toThrow() + }) +}) + +describe('pulseStepDurationMs', () => { + it('sums to exactly one loop, so the rounding cannot accumulate', () => { + for (const stepCount of [8, 16]) { + const total = Array.from( + { length: stepCount }, + (_, index) => pulseStepDurationMs(index, stepCount), + ).reduce((sum, step) => sum + step, 0) + + expect(total).toBe(PROGRESS_PULSE_DURATION_MS) + } + }) +}) + +describe('isProgressPulseVisible', () => { + it('reads the step the elapsed time falls in', () => { + expect(isProgressPulseVisible(0, '11110000')).toBe(true) + expect(isProgressPulseVisible(PROGRESS_PULSE_DURATION_MS / 2, '11110000')).toBe(false) + }) + + it('repeats every loop', () => { + const elapsed = 250 + expect(isProgressPulseVisible(elapsed + PROGRESS_PULSE_DURATION_MS * 3)) + .toBe(isProgressPulseVisible(elapsed)) + }) +}) diff --git a/packages/cli/test/ui/sanitize.test.ts b/packages/cli/test/ui/sanitize.test.ts new file mode 100644 index 0000000..500010b --- /dev/null +++ b/packages/cli/test/ui/sanitize.test.ts @@ -0,0 +1,75 @@ +/** + * What survives sanitization, and what does not. + * + * The cases that matter are the ones where dropping the escape character alone would leave + * readable garbage behind — a colour code's `[31m`, a title-setting OSC's payload. + * + * Control characters are built with `String.fromCharCode` rather than written literally: + * a raw control byte in a test file is invisible in every diff that would review it. + */ +import { describe, expect, it } from 'bun:test' +import { sanitizeForTerminal } from '../../src/ui/sanitize' + +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) +const C1_CSI = String.fromCharCode(0x9B) +const BACKSPACE = String.fromCharCode(8) + +describe('sanitizeForTerminal', () => { + it('removes a colour sequence whole, not just its escape character', () => { + expect(sanitizeForTerminal(`${ESC}[31mred${ESC}[0m`)).toBe('red') + }) + + it('removes an OSC sequence and its payload, terminated by BEL', () => { + expect(sanitizeForTerminal(`${ESC}]0;window title${BEL}text`)).toBe('text') + }) + + it('removes an OSC sequence terminated by ESC backslash', () => { + expect(sanitizeForTerminal(`${ESC}]8;;https://example.com${ESC}\\link`)).toBe('link') + }) + + it('consumes the argument of a charset designation', () => { + expect(sanitizeForTerminal(`${ESC}(Bplain`)).toBe('plain') + }) + + it('consumes an intermediate-plus-final sequence that is not a charset designation', () => { + // `ESC # 8` is the screen-alignment test. Matching only the charset introducers left + // its final byte behind as text. + expect(sanitizeForTerminal(`${ESC}#8plain`)).toBe('plain') + }) + + it('stops at a byte that cannot continue the sequence, instead of eating the text after it', () => { + // Generalising the charset case to the ECMA-48 rule regressed this: the scan ran past a + // non-intermediate byte looking for a final one and consumed the `v` of `visible`. + expect(sanitizeForTerminal(`${ESC}(\u{1F642}visible`)).toBe('\u{1F642}visible') + }) + + it('consumes a sequence whose final byte follows ESC directly', () => { + expect(sanitizeForTerminal(`${ESC}7saved`)).toBe('saved') + expect(sanitizeForTerminal(`${ESC}creset`)).toBe('reset') + }) + + it('removes a single-byte C1 CSI introducer and its sequence', () => { + expect(sanitizeForTerminal(`${C1_CSI}31mred`)).toBe('red') + }) + + it('keeps the newline and tab a caller may lay out with', () => { + expect(sanitizeForTerminal('a\tb\nc')).toBe('a\tb\nc') + }) + + it('drops every other control character, the carriage return included', () => { + // A CR inside a row would return the cursor to column 0 mid-paint, which is exactly the + // corruption the live region cannot recover from — so it goes, unlike the newline. + expect(sanitizeForTerminal(`a${BACKSPACE}bc\rd`)).toBe('abcd') + }) + + it('leaves ordinary text, including wide characters, alone', () => { + expect(sanitizeForTerminal('컨테이너 시작 👍')).toBe('컨테이너 시작 👍') + }) + + it('does not hang on a truncated sequence at the end of the input', () => { + expect(sanitizeForTerminal(`text${ESC}`)).toBe('text') + expect(sanitizeForTerminal(`text${ESC}[`)).toBe('text') + expect(sanitizeForTerminal(`text${ESC}]0;unterminated`)).toBe('text') + }) +}) diff --git a/packages/cli/test/ui/text.test.ts b/packages/cli/test/ui/text.test.ts new file mode 100644 index 0000000..c7707c0 --- /dev/null +++ b/packages/cli/test/ui/text.test.ts @@ -0,0 +1,79 @@ +/** + * Width measurement, which is the reason this module exists rather than `String.length`. + * + * Every assertion below is one `.length` would get wrong. + */ +import { describe, expect, it } from 'bun:test' +import { clipVisible, ellipsize, sliceVisible, visibleLength } from '../../src/ui/text' + +const ESC = String.fromCharCode(27) +const RED = `${ESC}[31m` +const RESET = `${ESC}[0m` + +describe('visibleLength', () => { + it('counts a Hangul syllable as two cells', () => { + expect('시작'.length).toBe(2) + expect(visibleLength('시작')).toBe(4) + }) + + it('counts an emoji as two cells however many code points it holds', () => { + expect(visibleLength('👍')).toBe(2) + expect(visibleLength('👨‍👩‍👦')).toBe(2) + }) + + it('counts a combining mark as part of the character it attaches to', () => { + expect(visibleLength('é')).toBe(1) + }) + + it('counts an escape sequence as nothing', () => { + expect(visibleLength(`${RED}red${RESET}`)).toBe(3) + }) +}) + +describe('sliceVisible', () => { + it('drops a wide grapheme that would straddle the limit rather than half-printing it', () => { + expect(sliceVisible('시작', 3)).toBe('시') + }) + + it('cuts on the cell, not the code unit', () => { + expect(sliceVisible('컨테이너', 4)).toBe('컨테') + }) + + it('carries trailing styling past the cut so the slice does not end mid-sequence', () => { + expect(sliceVisible(`ab${RESET}`, 2)).toBe(`ab${RESET}`) + }) + + it('returns nothing for a non-positive width', () => { + expect(sliceVisible('abc', 0)).toBe('') + }) +}) + +describe('clipVisible', () => { + it('returns the input untouched when it already fits', () => { + expect(clipVisible('abc', 3)).toBe('abc') + }) + + it('resets styling when the cut left a sequence open', () => { + expect(clipVisible(`${RED}abcdef`, 3)).toBe(`${RED}abc${RESET}`) + }) +}) + +describe('ellipsize', () => { + it('leaves text that fits alone', () => { + expect(ellipsize('abc', 3)).toBe('abc') + }) + + it('marks a cut with an ellipsis, inside the width', () => { + expect(ellipsize('abcdef', 4)).toBe('abc…') + expect(visibleLength(ellipsize('abcdef', 4))).toBe(4) + }) + + it('keeps a wide-character result inside the width', () => { + expect(visibleLength(ellipsize('컨테이너 시작', 5))).toBeLessThanOrEqual(5) + }) + + it('degrades to a bare ellipsis at width 1 and nothing below it', () => { + expect(ellipsize('abcdef', 1)).toBe('…') + expect(ellipsize('abcdef', 0)).toBe('') + }) +}) diff --git a/packages/cli/test/ui/theme.test.ts b/packages/cli/test/ui/theme.test.ts new file mode 100644 index 0000000..44865cf --- /dev/null +++ b/packages/cli/test/ui/theme.test.ts @@ -0,0 +1,130 @@ +/** + * The chrome, rendered without colour. + * + * Colour is switched off in every case so the assertions are about layout — alignment, + * indentation, sanitization — rather than about which escape a palette happens to choose. + */ +import process from 'node:process' +import { describe, expect, it } from 'bun:test' +import { visibleLength } from '../../src/ui/text' +import { + createCliTheme, + renderCliBanner, + renderCliSection, + renderCliTaggedLine, +} from '../../src/ui/theme' + +const ESC = String.fromCharCode(27) +const theme = createCliTheme({ color: false }) + +describe('createCliTheme', () => { + it('emits no escapes when colour is off', () => { + expect(theme.color).toBe(false) + expect(theme.heading('title')).toBe('title') + expect(theme.danger('boom')).toBe('boom') + }) + + it('emits escapes when colour is forced on', () => { + expect(createCliTheme({ color: true }).danger('boom')).toContain(ESC) + }) + + // picocolors decides colour support once, at import, from the environment — so the only + // way to test the default across environments is to import it in a fresh process with a + // controlled one. Each child writes to a pipe, so `isTTY` is false throughout and every + // result below is the environment's doing rather than the terminal's. + const themeModule = `${import.meta.dir}/../../src/ui/theme.ts` + + async function defaultColorUnder(env: Record): Promise { + const child = Bun.spawn( + [ + 'bun', + '-e', + `const { createCliTheme } = await import(${JSON.stringify(themeModule)})` + + '; process.stdout.write(String(createCliTheme().color))', + ], + { env: { PATH: process.env.PATH ?? '', ...env }, stdout: 'pipe', stderr: 'inherit' }, + ) + const output = await new Response(child.stdout).text() + await child.exited + return output.trim() === 'true' + } + + it('stays off on a pipe with nothing asking for colour', async () => { + expect(await defaultColorUnder({})).toBe(false) + }) + + it('honours FORCE_COLOR without a terminal, which gating on isTTY would have broken', async () => { + expect(await defaultColorUnder({ FORCE_COLOR: '1' })).toBe(true) + }) + + it('honours CI without a terminal, for the same reason', async () => { + expect(await defaultColorUnder({ CI: '1' })).toBe(true) + }) + + it('honours NO_COLOR even when something else asked for colour', async () => { + expect(await defaultColorUnder({ FORCE_COLOR: '1', NO_COLOR: '1' })).toBe(false) + }) +}) + +describe('renderCliBanner', () => { + it('rules the title to its own length', () => { + expect(renderCliBanner(theme, { title: 'please dev' })).toBe('please dev\n==========') + }) + + it('adds the subtitle on its own line', () => { + expect(renderCliBanner(theme, { title: 'dev', subtitle: 'docker' })) + .toBe('dev\n===\ndocker') + }) + + it('rules a wide title to its cell width, not its code-unit length', () => { + // '컨테이너 시작' is 7 code units and 13 cells. Ruling by `.length` leaves the rule + // visibly short of the title — the exact mistake `text.ts` exists to prevent. + const [title = '', rule = ''] = renderCliBanner(theme, { title: '컨테이너 시작' }).split('\n') + + expect(visibleLength(rule)).toBe(visibleLength(title)) + expect(visibleLength(rule)).toBe(13) + }) +}) + +describe('renderCliSection', () => { + it('aligns values to one column past the widest label', () => { + expect(renderCliSection(theme, { + title: 'sandbox', + rows: [ + { label: 'image', value: 'node:22-bookworm' }, + { label: 'workDir', value: '/work' }, + ], + })).toBe('sandbox\nimage node:22-bookworm\nworkDir /work') + }) + + it('indents a multi-line value under its own column', () => { + expect(renderCliSection(theme, { + title: 'error', + rows: [{ label: 'cause', value: 'first\nsecond' }], + })).toBe('error\ncause first\n second') + }) + + it('strips escape sequences a value arrived with', () => { + expect(renderCliSection(theme, { + title: 'sandbox', + rows: [{ label: 'id', value: `${ESC}[31mabc123` }], + })).toBe('sandbox\nid abc123') + }) +}) + +describe('renderCliTaggedLine', () => { + it('upper-cases the tag and brackets it', () => { + expect(renderCliTaggedLine(theme, { tag: 'docker', message: 'image ready' })) + .toBe('[DOCKER] image ready') + }) + + it('indents continuation lines past the tag', () => { + expect(renderCliTaggedLine(theme, { tag: 'x', message: 'one\ntwo' })) + .toBe('[X] one\n two') + }) + + it('aligns a continuation under a wide tag, which is wider than its code units', () => { + expect(renderCliTaggedLine(theme, { tag: '도커', message: 'a\nb' })) + .toBe('[도커] a\n b') + }) +}) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..4b6d221 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src", + "test" + ] +} diff --git a/sonar-project.properties b/sonar-project.properties index be9046e..b48f5c4 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -7,11 +7,11 @@ sonar.organization=pleaseai # Sources and tests. This is a workspace, so the paths are per package - add the # next package here when one is created. -sonar.sources=packages/core/src -sonar.tests=packages/core/test +sonar.sources=packages/core/src,packages/cli/src +sonar.tests=packages/core/test,packages/cli/test -# Coverage report emitted by the test runner - the same file Codecov uploads -sonar.javascript.lcov.reportPaths=packages/core/coverage/lcov.info +# Coverage reports emitted by the test runner - the same files Codecov uploads +sonar.javascript.lcov.reportPaths=packages/core/coverage/lcov.info,packages/cli/coverage/lcov.info # Exclude generated, vendored, and coverage output from analysis. Tests live outside # sonar.sources, so no file is in both sets and none has to be excluded to keep the