Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -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/**
7 changes: 7 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`: 정해진 것과, 아직 기다리는 것
```

예제는 실행할 수 있고, 이 프레임워크가 무엇을 하고 무엇을 하지 않는지 보는 가장 짧은 길이다.
Expand All @@ -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)과, 취약점 제보는
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

176 changes: 176 additions & 0 deletions docs/dev-tui.md
Original file line number Diff line number Diff line change
@@ -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<any, any, any, any>` 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 <path>`), 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.
23 changes: 23 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
32 changes: 32 additions & 0 deletions packages/cli/src/ui/ansi.ts
Original file line number Diff line number Diff line change
@@ -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`
}
Loading