diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0619df6..745b5e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,5 +21,5 @@ jobs: # Compile-smoke: the release Bun-compiles a single binary, which bundles # everything (unlike tsup) and can fail where `build` passes. Catch that # here so a release tag never breaks on it. - - run: bun build src/cli.tsx --compile --outfile /tmp/agent + - run: bun build src/cli.ts --compile --outfile /tmp/agent - run: /tmp/agent --version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d75657..c4e6d10 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,7 @@ jobs: mkdir -p dist for t in darwin-arm64 darwin-x64 linux-x64 linux-arm64; do echo "::group::build $t" - bun build src/cli.tsx --compile --target=bun-"$t" --outfile agent + bun build src/cli.ts --compile --target=bun-"$t" --outfile agent tar -czf "dist/agent-$t.tar.gz" agent rm -f agent echo "::endgroup::" diff --git a/.gitignore b/.gitignore index 29ded9d..79a1eee 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,3 @@ pnpm-lock.yaml # local dev worktrees .worktrees/ - -# screenshot-test render artifacts (regenerated by every test run) -test/__screenshots__/ diff --git a/CLAUDE.md b/CLAUDE.md index 1ca3c3c..ea3f87e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,16 +3,9 @@ ## Tests are unit tests only Every test in `test/` covers pure functions: input in, value out. Do not add a -test that renders the Ink UI. No fake TTY, no terminal emulator, no snapshot of -a painted screen, no `render()` from ink. - -The UI is verified by running it, not by asserting on frames. When a rendering -claim needs coverage, pull the logic out of the component into a pure function -in `src/lib/` or `src/ui/*Rows.ts` and test that instead. - -`test/screenshot.ts`, `test/screenshot.test.ts`, `test/connect-render.test.ts`, -and `test/scrollback.test.ts` were deleted for this reason. Do not bring them -back. +test that drives a terminal: no fake TTY, no terminal emulator, no snapshot of +printed output. When output needs coverage, pull the logic into a pure +function in `src/lib/` and test that instead. ## Conventions diff --git a/README.md b/README.md index 761ffdb..e793f13 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ # Ellipsis CLI Drive the [Ellipsis](https://ellipsis.dev) cloud from your terminal: start agent -sessions, stream their output live, manage configurations, and open a session -in the browser IDE. +sessions, stream their output live, and manage configurations. This is a thin client. The agent runs in the Ellipsis cloud; the CLI authenticates, opens a WebSocket, and streams results. It is open source @@ -62,8 +61,6 @@ agent session list --limit 20 # list recent sessions (filter by --source agent session get # inspect one session (prints a dashboard link) agent session get --watch # follow a session until it finishes agent session record # read a session's stored transcript, one line per record -agent session connect # connect to a session: transcript + live output + send messages -agent session connect # inside an Ellipsis sandbox: connects to the running session agent session stop # stop an in-flight session agent review 123 # review a pull request now, instead of waiting for a push @@ -124,8 +121,8 @@ legacy `ELLIPSIS_API_BASE`). `--watch` (on both `session start` and `session get`) streams the session's output live over WebSocket until it reaches a terminal status, falling back to periodic status polling if the live stream is unavailable. Either way it first -prints a clickable dashboard link. The stream protocol is specified in -[`docs/RUN_STREAMING_SPEC.md`](docs/RUN_STREAMING_SPEC.md). +prints a clickable dashboard link. How the stream works is described in +[`docs/SESSION_STREAMING.md`](docs/SESSION_STREAMING.md). ### Auth @@ -163,36 +160,6 @@ Hosts and tokens live in `~/.ellipsis/config.json` (mode 0600); set is migrated on first use — your existing login becomes a host named for its API base. -The config file also carries UI preferences. `"sessionBar"` scopes the session -list, which the interactive UI opens as a full screen with `esc`: - -```json -{ - "sessionBar": { - "hidden": false, - "days": 7, - "repo": "cwd", - "statuses": "all", - "sources": ["cli", "web"] - } -} -``` - -`hidden` drops the list entirely, so the chat never hands focus to it. `days` -hides sessions that have not moved in that long; `0` means no age cutoff. `repo` is `"cwd"` to -list only sessions on the repository your shell is in, or `"any"` for all of -them. `statuses` is `"unfinished"` to leave out the sessions that finished, -errored, or were stopped, or `"all"` to keep them. `sources` lists only sessions -started those ways (`react`, `web`, `api`, `cli`, `mention`, `cron`); leave it -out for all of them. - -Every field is optional and the defaults above are what you get with no -`sessionBar` at all. Two caveats on `repo`: a shell outside a repository lists -every repository rather than nothing, and sessions that name their repository -only inside their automation — dashboard starts, cron runs — do not -match a repo filter, so `"repo": "any"` is the way to see those alongside the -rest. - ## SDK 0.30 configuration Session configs select one native block. Put the first message inside it: @@ -254,9 +221,8 @@ npm run compile # single-binary build (bun) | Path | Purpose | | ----------------- | ------------------------------------------------ | -| `src/cli.tsx` | entry point; wires up the command tree | +| `src/cli.ts` | entry point; wires up the command tree | | `src/commands/` | one module per top-level command group | -| `src/ui/` | Ink components for interactive / streaming views | | `src/lib/` | API client, WebSocket client, config, constants | ### Releasing diff --git a/bun.lock b/bun.lock index a31a0d1..56ea5d5 100644 --- a/bun.lock +++ b/bun.lock @@ -6,23 +6,13 @@ "name": "@ellipsis/cli", "dependencies": { "@ellipsis-dev/sdk": "0.30.0", - "chalk": "^5.6.2", - "cli-table3": "^0.6.5", "commander": "^12.1.0", - "ink": "^7.1.1", - "marked": "^15.0.12", - "marked-terminal": "^7.3.0", - "react": "^19.2.0", - "strip-ansi": "^7.2.0", - "wrap-ansi": "^10.0.0", "ws": "^8.18.0", "yaml": "^2.9.0", }, "devDependencies": { "@types/node": "^22.5.0", - "@types/react": "^19.2.0", "@types/ws": "^8.5.12", - "react-devtools-core": "^6.1.2", "tsup": "^8.3.0", "tsx": "^4.19.0", "typescript": "^5.6.0", @@ -31,10 +21,6 @@ }, }, "packages": { - "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], - - "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], - "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.30.0", "", {}, "sha512-UfLdqiekpM9myblwFXamztC4+UtP2oU2xGtB0H6XCUbEEHHDvKrEzT4eotf/F4rvZcS9HMXvSOXC7APjym2CRw=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], @@ -147,14 +133,10 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], - "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], - "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/node": ["@types/node@22.20.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g=="], - "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], - "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="], @@ -173,80 +155,34 @@ "acorn": ["acorn@8.17.0", "", { "bin": "bin/acorn" }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], - "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], - - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], - "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], - "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - - "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], - "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "cli-boxes": ["cli-boxes@4.0.1", "", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="], - - "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], - - "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], - - "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], - - "cli-truncate": ["cli-truncate@6.1.1", "", { "dependencies": { "slice-ansi": "^9.0.0", "string-width": "^8.2.0" } }, "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw=="], - - "cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], - - "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], - - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], - "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], - "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="], - - "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], - "es-toolkit": ["es-toolkit@1.48.1", "", {}, "sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ=="], - "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": "bin/esbuild" }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - - "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], @@ -257,22 +193,6 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - - "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], - - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - - "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], - - "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], - - "ink": ["ink@7.1.1", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.3", "auto-bind": "^5.0.1", "chalk": "^5.6.2", "cli-boxes": "^4.0.1", "cli-cursor": "^4.0.0", "cli-truncate": "^6.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.45.1", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^9.0.0", "stack-utils": "^2.0.6", "string-width": "^8.2.0", "terminal-size": "^4.0.1", "type-fest": "^5.5.0", "widest-line": "^6.0.0", "wrap-ansi": "^10.0.0", "ws": "^8.20.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.2.0", "react": ">=19.2.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w=="], - - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - - "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], - "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], @@ -285,12 +205,6 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], - - "marked-terminal": ["marked-terminal@7.3.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "ansi-regex": "^6.1.0", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "node-emoji": "^2.2.0", "supports-hyperlinks": "^3.1.0" }, "peerDependencies": { "marked": ">=1 <16" } }, "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw=="], - - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -299,18 +213,8 @@ "nanoid": ["nanoid@3.3.15", "", { "bin": "bin/nanoid.cjs" }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], - "node-emoji": ["node-emoji@2.2.0", "", { "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", "emojilib": "^2.4.0", "skin-tone": "^2.0.0" } }, "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - - "parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], - - "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="], - - "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], - "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], @@ -327,58 +231,24 @@ "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], - "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], - - "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], - - "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], - "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], - "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], - "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - - "shell-quote": ["shell-quote@1.9.0", "", {}, "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA=="], - "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - - "skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="], - - "slice-ansi": ["slice-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="], - "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], - - "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], - - "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], - "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], @@ -403,16 +273,12 @@ "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], - "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], - "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], "vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": "vite-node.mjs" }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="], @@ -421,64 +287,20 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], - - "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], - "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yaml": ["yaml@2.9.0", "", { "bin": "bin.mjs" }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "yargs": ["yargs@16.2.2", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w=="], - - "yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], - - "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - - "@alcalzone/ansi-tokenize/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - - "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - - "cli-truncate/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], - - "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - - "ink/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], - "mlly/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], - "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "react-devtools-core/ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], - - "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - - "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": "bin/esbuild" }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], - "widest-line/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], - - "wrap-ansi/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], - - "cli-highlight/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], diff --git a/docs/RUN_STREAMING_SPEC.md b/docs/RUN_STREAMING_SPEC.md deleted file mode 100644 index fa71ce3..0000000 --- a/docs/RUN_STREAMING_SPEC.md +++ /dev/null @@ -1,144 +0,0 @@ -# Requirements: live run streaming for `agent run get --watch` - -**Status:** proposed — not yet implemented. -**Audience:** the engineer/agent implementing WebSocket streaming for the CLI. - -## 1. Background - -The CLI can start runs and read their state over the public REST API, but -it cannot stream a run's output. `agent run get --watch` exists today and gives a -**status-level** live view by polling `GET /agents/runs/{id}` until the run -reaches a terminal status (`completed`/`error`/`cancelled`/`stopped`). It shows -status transitions and the final summary — not the step-by-step output. - -**Crucially, the backend already streams steps live — just not over the public API.** The -dashboard consumes a WebSocket stream; this work is about re-exposing that same -stream on the public API for bearer-authenticated CLI clients. The bulk of the -machinery (step model, persistence, event bus) already exists and must be reused, -not reinvented. - -### How the existing dashboard stream works (source of truth to mirror) - -- **Endpoint:** `@router.websocket("/agents/runs/{run_id}/stream")` — - `agents_router.py:1200`, loop `_stream_run_loop` at `:1147`. -- **Frames:** an initial `{"type":"snapshot","run":...,"steps":[...]}`, then - `{"type":"steps_append","steps":[...]}`, `{"type":"run","run":...}`, and - `{"type":"heartbeat"}`. Socket closes when the run status is terminal. - `STREAM_RECONCILE_SECONDS = 10` forces a DB re-read even with no event. -- **Step model:** `AgentStep` (`src/models/agents/agent_step.py`) with a - monotonic `step_index: int` and `data: CCStep`. `CCStep` - (`src/models/agents/claude/cc_step.py`) is the typed Claude Code stream-json - message — a discriminated union of assistant (text/tool_use/thinking blocks + - usage), user (tool results incl. stdout/stderr), system-init, and result - (cost/duration). Stored in the `agent_steps` table, indexed on - `(agent_session_id, step_index)`. -- **Source + signal:** Claude Code runs with `--output-format stream-json`; the - sandbox runner parses each stdout line into a `CCStep`, assigns `step_index` - (`run_in_sandbox.py:221-257`), inserts an `AgentStep` row, and fires - `pg_notify('agent_events', {run_id, kind})` — **a pointer, never the data** - (`realtime/agent_events.py`). Each `public_api` process runs an `AgentEventBus` - (`realtime/agent_event_bus.py`) doing `LISTEN agent_events` on a dedicated - session-mode connection, fanning notifies to per-run `asyncio.Queue`s. The WS - handler wakes on a notify and **re-reads run+steps from the DB** (the DB is the - source of truth; NOTIFY is only a wake-up). -- **Cursor:** `step_index` is monotonic per session, but the protocol exposes - **no client cursor** — each (re)connect gets a full `snapshot`, then deltas via - a local `sent_steps` counter. Dropped notifies self-heal via reconcile. -- **Auth:** browsers can't set WS handshake headers, so an authenticated REST - call (`GET /agents/runs/{id}/stream-ticket`, `:1090`) mints a 60s HMAC ticket - (`realtime/stream_tickets.py`, signed with `FRONTEND_API_KEY`) passed as - `?ticket=`. The handler verifies signature+expiry, then re-checks run ownership - against the DB (close `4401`/`4403` on failure). - -## 2. Goal - -`agent run get --watch` streams a run's steps live, in real time, and falls -back to REST status-polling when streaming is unavailable. The same flag covers -both modes — no new top-level command. - -## 3. Server-side requirements (public API) - -**Re-export the existing stream; do not build a parallel one.** Reuse -`agent_steps`, `CCStep`, and the `AgentEventBus` exactly as the frontend stream -does. The public endpoint is a thin re-auth + re-encode of `_stream_run_loop`. - -1. **Endpoint:** `GET /agents/runs/{run_id}/stream`, upgraded to WebSocket. -2. **Auth — bearer, not ticket.** The CLI holds a public API bearer token, so resolve - it with the same `V1Auth` path as the REST API (Authorization header on the - handshake, which non-browser clients *can* set), authorizing the run's - customer. The 60s `?ticket=` dance is a browser workaround the CLI doesn't - need; don't require it. -3. **Frame protocol — reuse the existing shape:** `snapshot` (run + steps), - `steps_append` (new `AgentStep`s), `run` (run-row change), `heartbeat`, and a - terminal close. Each step serializes as the existing `AgentStep`/`CCStep` - JSON so CLI and dashboard share one schema. -4. **Add a real resume cursor.** Accept `?since=` (and/or a first - client message). On connect, send only steps with `step_index > since` - (skipping the full snapshot) — a CLI reconnect must resume incrementally, not - re-download every step. Absent `since`, behave like today (full snapshot). - Consider also adding `?since=` to the REST `GET /agents/runs/{id}/steps` - (`agents_router.py:1073`), which currently takes no cursor. -5. **Heartbeat / reconcile:** keep the existing heartbeat + 10s DB reconcile so - dropped connections are detectable and dropped notifies self-heal. -6. **Termination:** send the final `run`/terminal frame, then close normally. - For an already-terminal run, replay steps (honoring `since`) then close. - -## 4. Client-side requirements (this repo) - -1. `agent run get --watch` opens the public API stream and renders frames: - `snapshot`/`steps_append` → render each `CCStep` (assistant text + tool calls, - tool stdout/stderr, thinking if `--verbose`); `run` → status transitions; - terminal close → final summary. Exit 0 on a successful terminal status, - non-zero on error/failed terminal status. -2. **Reconnect with backoff**, resuming from the highest `step_index` seen via - `?since=`, so a dropped socket loses/duplicates nothing. -3. **Fallback:** if the WS can't connect (server without the endpoint, or a - `1003`/unsupported close), fall back to the existing REST status-poll - `watchRun()` with a one-line notice. `--watch` must keep working against a - backend that lacks streaming. -4. **`--json` with `--watch`:** emit one JSON object per frame (NDJSON). -5. Reuse/replace the `src/lib/ws.ts` + `src/ui/RunView.tsx` scaffolding. Fix - `ws.ts` error handling to surface a readable message, not `[object ErrorEvent]` - (the original bug came from connecting to a then-nonexistent endpoint). -6. Define a TS mirror of `CCStep` (or a pragmatic subset) in `src/lib/types.ts`, - matching the backend union. - -## 5. WebSocket close codes - -Mirror the backend's existing codes where possible: - -| Code | Meaning | -|------|---------| -| 1000 | normal — run reached a terminal state | -| 4401 | auth failed (bad/expired credential) | -| 4403 | authenticated but not authorized for this run | -| 1003 | streaming unsupported (client falls back to polling) | -| 1011 | server error | - -## 6. Acceptance criteria - -- Streaming a live run shows assistant output + tool stdout/stderr in near real - time, sharing the `AgentStep`/`CCStep` schema with the dashboard. -- Killing the socket mid-run and reconnecting with `?since=` - resumes with no lost or duplicated steps and without a full re-snapshot. -- `--watch` against a backend without the stream endpoint transparently falls back - to REST status-polling and still completes. -- `--json --watch` emits valid NDJSON, one frame per line. -- Unit tests for the client frame handler, the `since` resume cursor, and the - fallback trigger (mirror the fake-timer style in `test/auth.test.ts` / - `test/run.test.ts`). -- No `[object ErrorEvent]`; connection errors print a real message. - -## 7. Out of scope - -- Bidirectional control (stop/input). `run stop` is tracked separately and also - has no stream endpoint yet. -- Re-architecting the transport (NOTIFY + DB-as-source-of-truth stays). -- Multiplexing multiple runs over one socket. - -## 8. Key backend references - -- `src/public_api/routers/agents/agents_router.py` — WS `:1200`/`_stream_run_loop :1147`, REST steps `:1073`, stream-ticket `:1090`. -- `src/public_api/realtime/agent_events.py`, `agent_event_bus.py`, `postgres_listen_bus.py`, `stream_tickets.py`. -- `src/agents/engine/claude/run_in_sandbox.py:221-257`, `shared.py:45-94`. -- `src/models/agents/agent_step.py`, `src/models/agents/claude/cc_step.py`, `src/models/database/agents/agent_steps_row.py`. diff --git a/docs/SESSION_STREAMING.md b/docs/SESSION_STREAMING.md new file mode 100644 index 0000000..50528b6 --- /dev/null +++ b/docs/SESSION_STREAMING.md @@ -0,0 +1,77 @@ +# Session streaming: how `--watch` follows a session + +`agent session start --watch` and `agent session get --watch` follow a +session's output live until it reaches a terminal status. The stream is +read-only: the CLI never sends anything to the session. Stopping one is +`agent session stop`. + +The WebSocket client is `streamSession` from `@ellipsis-dev/sdk/stream`. This +repo owns only the transport adapter (`src/lib/stream.ts`) and the rendering +(`watchSessionStreaming` in `src/commands/session.ts`). + +## Endpoint + +``` +WS {ws-base}/v1/sessions/{id}/stream?protocol=6[&after_seq=N] +``` + +- The WebSocket base is `ELLIPSIS_WS_BASE` when set, else the resolved API + base with `http(s)` swapped for `ws(s)`. +- Auth is the bearer token on the handshake (`authorization: Bearer ...`), + plus the CLI user agent. The dashboard's ticket handshake is not used. +- `protocol` is `SESSION_STREAM_PROTOCOL_VERSION` from the SDK, so a protocol + bump ships with an SDK bump. +- `after_seq` is the highest record `feed_seq` the client has seen. It is sent + on reconnect so the server resumes after it instead of replaying history. A + first connect sends no `after_seq` and gets the full snapshot. + +## Frames + +The SDK parses each message and drops anything whose `type` is not one of the +seven below. Unknown frame types, and unknown `source`, `record_type`, or +`kind` values inside a record, are ignored by rule: additive server changes are +not a protocol break. The full frame schema is `schema/frames.schema.json` in +the SDK package. + +| Frame | Payload | What the watch log does with it | +| --- | --- | --- | +| `snapshot` | `session`, `messages`, `earliest_feed_seq`, `protocol` | prints the status word when it changes | +| `session` | `session`, the whole row, resent on any change | same: collapsed to status-word transitions | +| `records_append` | `records`, feed-ordered `SessionRecord`s | one line per transcript item, via `recordToItems` from `@ellipsis-dev/sdk/store` | +| `delta` | ephemeral partial output for a turn | skipped: the committed record supersedes it | +| `heartbeat` | `ts` | skipped: liveness only | +| `error` | `message` | printed to stderr; the watch ends with exit code 1 | +| `done` | none | ends the watch; the last seen status decides the exit code | + +`--json` with `--watch` prints one JSON object per frame (NDJSON) with the +same filtering: `heartbeat` and `delta` are dropped, and `snapshot` and +`session` frames are printed only when the status word changes. + +## Liveness, reconnect, fallback + +All of this is inside `streamSession`; the CLI configures none of it. + +- Heartbeat: 45 seconds without any frame closes the socket as an error, + which goes through the reconnect decision below. +- Reconnect: a retryable close or a socket error reconnects with `after_seq` + after a delay of 500 ms that doubles up to 8 s. `maxReconnects` is 5: once a + frame has been received, the fifth consecutive failure gives up; before any + frame has arrived, the second failure does, so a backend without the + endpoint fails fast. +- Close codes: `1000` is normal. `1008`, `4401`, and `4403` are auth failures + and throw `StreamAuthError`, which the CLI reports as an error with no + fallback. `1002` and `1003` mean the protocol is unsupported and give up at + once. Every other code is retried. +- Fallback: giving up throws `StreamUnavailableError`. The CLI prints + `live stream unavailable (...); falling back to status polling` on stderr + and polls `GET /v1/sessions/{id}` every 2 seconds, printing status + transitions until a terminal status (`watchSession`). `--watch --quiet` + takes this polling path directly, with no live output. + +## Exit code + +A watch exits 0 when the session ended in `completed`, `closed`, or `idle`, +and 1 otherwise (`exitCodeForStatus`). When a conversation closes, the +execution outcome (`lifecycle.last_execution_result.completion_reason`, for +example `budget_hit`) stands in for the lifecycle status, so a closed session +that hit its budget still exits 1. diff --git a/package.json b/package.json index 9ab02a6..0b7707d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "node": ">=22" }, "scripts": { - "dev": "tsx src/cli.tsx", + "dev": "tsx src/cli.ts", "build": "tsup", "check:versions": "bun scripts/check-versions.mjs", "typecheck": "tsc --noEmit", @@ -22,23 +22,13 @@ }, "dependencies": { "@ellipsis-dev/sdk": "0.30.0", - "chalk": "^5.6.2", - "cli-table3": "^0.6.5", "commander": "^12.1.0", - "ink": "^7.1.1", - "marked": "^15.0.12", - "marked-terminal": "^7.3.0", - "react": "^19.2.0", - "strip-ansi": "^7.2.0", - "wrap-ansi": "^10.0.0", "ws": "^8.18.0", "yaml": "^2.9.0" }, "devDependencies": { "@types/node": "^22.5.0", - "@types/react": "^19.2.0", "@types/ws": "^8.5.12", - "react-devtools-core": "^6.1.2", "tsup": "^8.3.0", "tsx": "^4.19.0", "typescript": "^5.6.0", diff --git a/scripts/compile.sh b/scripts/compile.sh index ee86e29..981bc00 100755 --- a/scripts/compile.sh +++ b/scripts/compile.sh @@ -3,4 +3,4 @@ set -euo pipefail cd "$(dirname "$0")/.." bun run check:versions -exec bun build src/cli.tsx --compile --outfile agent +exec bun build src/cli.ts --compile --outfile agent diff --git a/scripts/smoke-local.sh b/scripts/smoke-local.sh index e5ea705..67e47ae 100755 --- a/scripts/smoke-local.sh +++ b/scripts/smoke-local.sh @@ -35,7 +35,7 @@ cleanup() { } trap cleanup EXIT -run() { echo "+ agent $*"; npx tsx src/cli.tsx "$@"; echo; } +run() { echo "+ agent $*"; npx tsx src/cli.ts "$@"; echo; } echo "API base: $API_BASE" echo "Container: $CONTAINER" @@ -61,7 +61,7 @@ echo "Approving as customer: $CUSTOMER_ID" echo echo "== Starting login (device-code flow) ==" -npx tsx src/cli.tsx login --no-browser >"$LOGIN_OUT" 2>&1 & +npx tsx src/cli.ts login --no-browser >"$LOGIN_OUT" 2>&1 & LOGIN_PID=$! # Wait for the CLI to print the verification code (format XXXX-XXXX). @@ -108,7 +108,7 @@ run session list --limit 5 echo "== Logout should clear the token (next call 401s) ==" run logout -if npx tsx src/cli.tsx me 2>/dev/null; then +if npx tsx src/cli.ts me 2>/dev/null; then echo "UNEXPECTED: 'me' succeeded after logout" >&2 exit 1 fi diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 2659cb7..27cc60b 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -30,14 +30,14 @@ export ELLIPSIS_CONFIG_DIR="$CONFIG_DIR" cleanup() { rm -rf "$CONFIG_DIR"; } trap cleanup EXIT -run() { echo "+ agent $*"; npx tsx src/cli.tsx "$@"; echo; } +run() { echo "+ agent $*"; npx tsx src/cli.ts "$@"; echo; } echo "API base: $API_BASE" echo "Config dir: $CONFIG_DIR (temporary)" echo echo "== Logging in (approve the printed request, then this continues) ==" -npx tsx src/cli.tsx login --no-browser +npx tsx src/cli.ts login --no-browser echo "== Authenticated API calls ==" run me @@ -48,7 +48,7 @@ run session list --limit 5 echo "== Logout should clear the token (next call 401s) ==" run logout -if npx tsx src/cli.tsx me; then +if npx tsx src/cli.ts me; then echo "UNEXPECTED: 'me' succeeded after logout" >&2 exit 1 else diff --git a/skills/cli-conventions/SKILL.md b/skills/cli-conventions/SKILL.md index 5ec002a..8774111 100644 --- a/skills/cli-conventions/SKILL.md +++ b/skills/cli-conventions/SKILL.md @@ -79,7 +79,6 @@ One meaning per short flag, across the whole CLI. The reserved ones: | `-w` | `--watch` | block and stream | | `-m` | `--metadata` | repeatable key=value | | `-n` | `--tail ` | tail N entries | -| `-i` | `--interactive` | open a conversation instead of printing | Other rules: @@ -119,12 +118,6 @@ Integrations, Spend, Account. Groups live in `TOP_LEVEL_GROUPS` in `src/lib/help.ts`. **A new top-level command must be added to a group** or it falls through to "Other", which is the signal that someone forgot. -Every help page ends with the `agent help --interactive` hint, registered once -as an `afterAll` help text on the program in `src/lib/help.ts` (commander emits -that event up the ancestor chain, so subcommands inherit it). Do not re-add it -per command. Text added this way is not wrapped by commander, so it goes -through `wrapToHelpWidth` to match every other column. - ## Checklist for a new command 1. Singular name; plural registered via `alsoKnownAs`. diff --git a/skills/ellipsis/SKILL.md b/skills/ellipsis/SKILL.md index 0e1d2ff..1d87c79 100644 --- a/skills/ellipsis/SKILL.md +++ b/skills/ellipsis/SKILL.md @@ -392,10 +392,7 @@ agent automation run --input '{...}' # invoke an automation as agent session start --config-file agents/my_agent.yaml --watch agent session start --template ellipsis-helper --watch agent session get --watch # follow a running session -agent session connect # live view plus send messages agent session stop -agent session ide # browser IDE into the live sandbox -agent session port 3000 # preview a port the sandbox serves ``` With no config source, a bare `start` runs the bare ad-hoc config — an empty @@ -408,12 +405,10 @@ cache. `--detach` returns immediately. `--watch --quiet` prints only status transitions and the result, and either watch form exits `0` only when the session completes. -Search and audit what agents have done: +List and audit what agents have done: ```sh agent session list --limit 20 # --automation, --source, --author, --days, --since -agent session search "webhook retries" # transcripts, recaps, created PRs, similarity -agent session search "owner/repo#512" # finds the session that opened that PR agent session record # the stored transcript, one line per record agent session log -o session.jsonl # the complete archived log agent analytics reviewer --account-type bot # human versus bot PR and review activity @@ -647,10 +642,9 @@ npx skills add ellipsis-dev/cli If `ELLIPSIS_SANDBOX_ID` is set in the environment, you are the agent in an Ellipsis session. The `agent` CLI is pre-installed and pre-authenticated with a -session-scoped token, so you can start child sessions, search the team's session -history, read analytics, and upload screenshots as org-gated links -(`agent file upload shot.png`) with no login. `agent session connect` with no -id connects to the current session, via `ELLIPSIS_SESSION_ID`. +session-scoped token, so you can start child sessions, list the team's sessions, +read analytics, and upload screenshots as org-gated links +(`agent file upload shot.png`) with no login. That token is deliberately narrower than a human's. It can list variable names but not set or delete them, cannot delete a file, and cannot repoint an diff --git a/src/cli.tsx b/src/cli.ts similarity index 79% rename from src/cli.tsx rename to src/cli.ts index 3f5a4cb..03d3ee6 100644 --- a/src/cli.tsx +++ b/src/cli.ts @@ -23,7 +23,6 @@ import { registerHelp } from './commands/help' import { commandTypoMessage, looksLikeCommandTypo } from './lib/args' import { VERSION } from './lib/constants' import { configureCliHelp } from './lib/help' -import { canHostSessionsUi, defaultStartRequest, runSessionsUi } from './ui/launch' const program = new Command() @@ -58,16 +57,13 @@ registerAnalytics(program) registerPing(program) registerHelp(program) -// A bare `agent` opens the multi-session UI: the sidebar of your running -// sessions beside a new-session composer — nothing starts until you type a -// task and hit enter. (Headless callers with no TTY get the old behavior of -// an idle connected start via the shorthand below.) +// A bare `agent` prints the top-level help, the same page as `agent --help`. // // Any other invocation that isn't a known subcommand or a top-level -// help/version request is shorthand for `agent session start --connect ...`: +// help/version request is shorthand for `agent session start ...`: // `agent "fix the tests" --model ...` forwards the prompt and every trailing -// flag through to a fresh connected session, which opens in the same UI. -// `agent --help`, `agent --version`, `agent help`, and every subcommand +// flag through to a fresh session, which starts and prints its dashboard +// link. `agent --help`, `agent --version`, `agent help`, and every subcommand // dispatch unchanged. // // The exception is a single bare word (`agent sesion`): see @@ -87,11 +83,8 @@ const isTopLevel = first === '-V' || first === '--version' || (first !== undefined && topLevelCommands.has(first)) -if (first === undefined && canHostSessionsUi()) { - const { runAction } = await import('./lib/output') - await runAction(() => - runSessionsUi({ buildStartRequest: defaultStartRequest }), - ) +if (first === undefined) { + program.outputHelp() } else { if (!isTopLevel) { // One bare word is far more likely a mistyped command than a prompt, so @@ -101,7 +94,7 @@ if (first === undefined && canHostSessionsUi()) { console.error(commandTypoMessage(rest[0]!, suggestableCommands)) process.exit(1) } - process.argv.splice(2, 0, 'session', 'start', '--connect') + process.argv.splice(2, 0, 'session', 'start') } await program.parseAsync(process.argv) } diff --git a/src/commands/connect.ts b/src/commands/connect.ts deleted file mode 100644 index 682f3f2..0000000 --- a/src/commands/connect.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { Command } from 'commander' -import React from 'react' -import { render } from 'ink' -import { SessionTranscriptStore, seedTranscriptStore } from '@ellipsis-dev/sdk/store' -import { api } from '../lib/api' -import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config' -import { runAction } from '../lib/output' -import { sessionUrl } from '../lib/urls' -import { makeOpenSocket, resolveWsBase } from '../lib/stream' -import { applyDetectedThemeMode } from '../lib/terminalBackground' -import { ConnectApp } from '../ui/ConnectApp' -import { canHostSessionsUi, defaultStartRequest, runSessionsUi } from '../ui/launch' - -// `agent session connect [sessionId]` — the terminal window into a cloud -// session (documents/eng/SESSION_IDE.md §2.6, in the ellipsis monorepo). -// -// A pure API client: it renders the conversation so far from the stored -// transcript, follows new output live over the session WebSocket, and sends -// what you type through POST /sessions/{id}/messages — the same inbox that -// delivers webhook events to the agent's Claude Code stdin at the next turn -// boundary. It NEVER spawns or attaches a Claude Code process (a second -// writer on one CC session corrupts the transcript; the cloud worker is the -// single owner), so it works identically from a laptop and from inside the -// session's own sandbox, where ELLIPSIS_SESSION_ID makes the id optional. - -// Where the session id comes from, in precedence order: the positional -// argument, then the in-sandbox ELLIPSIS_SESSION_ID. Pure, for tests. -export function resolveConnectSessionId( - arg: string | undefined, - env: NodeJS.ProcessEnv = process.env, -): string { - const id = arg ?? env.ELLIPSIS_SESSION_ID - if (!id) { - throw new Error( - 'provide a session id (find one with `agent session list`); inside an ' + - 'Ellipsis sandbox the current session is used automatically', - ) - } - return id -} - -// Whether the composer can send to this session (and why not) — shared with -// the multi-session UI; re-exported so existing imports keep working. -import { connectability, sessionConfigName } from '../lib/sessions' -export { connectability } - -export function registerConnect(session: Command): void { - session - .command('connect [session-id]') - .description('Open a session: read the conversation, follow it live, send messages') - .option('--no-records', 'skip replaying prior records on open') - .option( - '--no-input', - 'follow read-only, never opening the composer (for non-interactive callers)', - ) - .addHelpText( - 'after', - `\nMessages go through the session inbox, so this is single-writer-safe and works -headless or from inside the session's own sandbox, where the id is optional. -Pass --no-input to follow read-only from a script or agent (no TTY needed). - -API: GET /v1/sessions/{id}, GET /v1/sessions/{id}/records, POST /v1/sessions/{id}/messages, WS /v1/sessions/{id}/stream`, - ) - .action(async (sessionId: string | undefined, opts: { records: boolean; input: boolean }) => { - await runAction(async () => { - const id = resolveConnectSessionId(sessionId) - // Interactive TTY connects open the multi-session UI (sidebar + - // chat, this session focused). Headless callers, --no-input, and - // --no-records keep the solo single-session renderer (the sidebar is - // useless without a keyboard, and the UI always renders history). - if (opts.records && opts.input && canHostSessionsUi()) { - await runSessionsUi({ - initialSessionId: id, - buildStartRequest: defaultStartRequest, - }) - return - } - await runConnect(id, opts.records, !opts.input) - }) - }) -} - -export async function runConnect( - sessionId: string, - showRecords: boolean, - readOnly = false, - // An extra opening notice from the caller — shown in the app instead of - // printed beforehand, which would land in scrollback behind the app. - startupNotice?: string, - // The automation name from the caller (e.g. `start --connect`); when - // absent it is derived from the fetched session. Shown in the footer meta - // line. - configName?: string, -): Promise { - const client = api() - const token = requireToken() - const openSocket = makeOpenSocket(token, resolveWsBase(resolveApiBase())) - - const [{ session }, me] = await Promise.all([ - client.sessions.get(sessionId), - client.identity(), - // Pick the palette for this terminal's background before the first frame. - applyDetectedThemeMode(), - ]) - const c = connectability(session) - // --no-input forces watch-only even when the session would accept messages. - const canSend = readOnly ? false : c.canSend - const reason = - readOnly && c.canSend ? 'read-only (--no-input): following without the composer' : c.reason - const notice = [startupNotice, reason].filter(Boolean).join(', ') || null - const url = sessionUrl(resolveAppBase(), me.customer_login, sessionId) - // The config identity for the footer meta line: the caller's resolved name - // first, then whatever the session itself carries. - const config = configName ?? sessionConfigName(session) - - // No scrollback preamble: the app owns the whole surface, Claude Code-style. - // The footer carries the session identity/status; a watch-only reason - // surfaces as the app's notice. - - // Seed ONE transcript store with the stored records and the fetched session, - // so the first paint is instant and streamSession resumes past the seeded - // cursor instead of replaying history. --no-records skips *rendering* the - // seeded history (minRenderFeedSeq), not re-streaming it. - const store = new SessionTranscriptStore() - const page = (await client.sessions.records(sessionId)).response - // The cast bridges the SDK's two generated flavors of the same wire shape: - // REST responses mark nullable fields optional, the frame types require - // them. Identical JSON either way. - seedTranscriptStore(store, { - session, - records: page.records, - messages: page.messages, - earliestFeedSeq: page.earliest_feed_seq, - } as Parameters[1]) - - // Written by the app when it exits because the conversation closed (terminal; - // nothing left to reconnect to), so the detach sign-off below stays honest. - const exitState = { closed: false } - // No screen-clearing dance here any more: the app prints its settled - // transcript INTO this terminal's scrollback (see ConnectApp's scrollback - // mode), so the conversation grows down the terminal from wherever the shell - // prompt left off, exactly like ordinary command output. Homing the cursor - // would throw away the scrollback the app now relies on. - const app = render( - React.createElement(ConnectApp, { - api: client, - sessionId, - store, - openSocket, - canSend, - minRenderFeedSeq: showRecords ? 0 : store.cursor, - sessionUrl: url, - initialNotice: notice, - // The session's one model, fixed at creation. - model: session.tokens?.model || null, - configName: config, - exitState, - }), - // Render every frame as it happens even without a TTY. Ink's default is to - // buffer and emit only the final frame off-TTY, which would silence the - // documented headless path (`--no-input` piped into a script or an agent) - // for the entire life of the session. - // exitOnCtrlC off: the app handles ctrl+c itself (first press interrupts a - // running turn, second quits), which ink's default teardown would preempt. - { interactive: true, exitOnCtrlC: false }, - ) - // Guard against the revoked-TTY spin. When the controlling terminal is torn - // down abruptly (terminal app force-quit/crash, SSH drop, login-session - // teardown) rather than closed gracefully, macOS revoke()s the tty: stdin's - // fd stays open but every poll fires immediately, so libuv busy-loops on - // kevent64 at 100% CPU forever. Ink won't exit on its own because the live - // WebSocket + timers keep the event loop alive, and the process is reparented - // to launchd (ppid 1). Only interactive (TTY) runs are exposed — a headless - // `--no-input` follow reads from a non-TTY stdin and legitimately may be - // orphaned inside a sandbox, so we leave it alone. - const detach = (): void => app.unmount() - let orphanWatch: ReturnType | undefined - if (process.stdin.isTTY) { - // Prompt teardown when the fd surfaces its failure as a stream event... - process.stdin.on('error', detach) - process.stdin.on('end', detach) - process.stdin.on('close', detach) - // ...and a backstop for the revoke case, where the error may never surface: - // reparenting to init is the unambiguous "our terminal is gone" signal. - orphanWatch = setInterval(() => { - if (process.ppid === 1) app.unmount() - }, 2000) - orphanWatch.unref() - } - try { - await app.waitUntilExit() - } finally { - if (orphanWatch) clearInterval(orphanWatch) - process.stdin.off('error', detach) - process.stdin.off('end', detach) - process.stdin.off('close', detach) - } - - if (canSend && !exitState.closed) { - // The session keeps running after a detach; hand back the exact command - // that re-opens this conversation. It prints under the app's last frame, - // like any other command's parting line. - console.log(`resume with: agent session connect ${sessionId}`) - } -} diff --git a/src/commands/help.ts b/src/commands/help.ts index e821417..a925371 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,44 +1,16 @@ import type { Command } from 'commander' -import { parse as parseYaml } from 'yaml' -import { api, APIError } from '../lib/api' -import { apiRoutes } from '../lib/help' -import { runAction } from '../lib/output' -import { repoFromCwd } from '../lib/git' -import { startConnect, startRequestFromConfig, withContextRepository } from './session' -import type { StartAgentSessionRequest } from '../lib/types' -// The template behind `agent help --interactive`. Kebab-case like every other -// slug in the registry; it is not served yet, so a 404 here is expected and -// gets its own message rather than a raw HTTP failure. -const HELPER_TEMPLATE_SLUG = 'ellipsis-helper' - -// Replaces commander's built-in `help` command so `--interactive` can hang off -// it. Everything the built-in did must keep working: `agent help` prints the -// top-level help, `agent help ` prints that subcommand's. Note the -// built-in only ever resolved ONE level ("help session start" printed -// session's help); this walks the whole path, which is a strict improvement. +// Replaces commander's built-in `help` command. Everything the built-in did +// must keep working: `agent help` prints the top-level help, `agent help +// ` prints that subcommand's. Note the built-in only ever resolved +// ONE level ("help session start" printed session's help); this walks the +// whole path, which is a strict improvement. export function registerHelp(program: Command): void { - apiRoutes( - program - .command('help') - .description('Show help for a command, or ask the help agent with --interactive'), - 'POST /v1/sessions with --interactive', - ) + program + .command('help') + .description('Show help for a command') .argument('[command...]', 'command to show help for (e.g. `session start`)') - .option( - '-i, --interactive', - 'start a cloud session with the Ellipsis help agent and open the conversation', - ) - .action(async (path: string[], opts: { interactive?: boolean }) => { - if (opts.interactive) { - if (path.length > 0) { - console.error('error: --interactive takes no command argument') - process.exitCode = 1 - return - } - await runAction(startHelperSession) - return - } + .action((path: string[]) => { const target = resolveCommandPath(program, path) if (!target) { console.error(`error: unknown command '${path.join(' ')}'`) @@ -63,28 +35,3 @@ export function resolveCommandPath(program: Command, path: string[]): Command | } return cmd } - -async function startHelperSession(): Promise { - const template = await api().templates.get(HELPER_TEMPLATE_SLUG) - let req: StartAgentSessionRequest = startRequestFromConfig( - parseYaml(template.yaml) as Record, - ) - // Same as `session start`: add the repo we're standing in to the sandbox - // checkout set so the helper can answer questions about this checkout. - const contextRepo = repoFromCwd(process.cwd()) - if (contextRepo) req = withContextRepository(req, contextRepo) - // No prompt: the helper opens idle and waits for the question (a promptless - // start is idle by definition since #6394). - - try { - const { session } = await api().sessions.start(req) - await startConnect(session, 'Ellipsis help agent') - } catch (err) { - if (err instanceof APIError && err.status === 404) { - throw new Error( - `the help agent is not available on this host yet (template "${HELPER_TEMPLATE_SLUG}" not found). Run \`agent template list\` to see what you can start.`, - ) - } - throw err - } -} diff --git a/src/commands/session.tsx b/src/commands/session.ts similarity index 90% rename from src/commands/session.tsx rename to src/commands/session.ts index d9bc3e9..7dc72cd 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.ts @@ -46,10 +46,8 @@ import type { } from '../lib/types' import { repoFromCwd } from '../lib/git' import { openBrowser } from '../lib/auth' -import { registerConnect, runConnect } from './connect' -import { canHostSessionsUi, defaultStartRequest, runSessionsUi } from '../ui/launch' import { readImageAttachment } from '../lib/images' -import { formatStepLine, oneLine, recordText } from '../lib/steps' +import { formatStepLine, recordText } from '../lib/steps' import { parseRepo, withSessionPrompt, @@ -78,18 +76,14 @@ const TERMINAL_STATUSES: ReadonlySet = new Set { @@ -210,22 +199,14 @@ export function registerSession(program: Command): void { } const promptText = promptArg ?? opts.prompt // At most one attach mode. --detach is the default made explicit; - // --watch blocks (live, or quiet with --quiet); --connect is interactive. - const modes = [ - opts.detach && '--detach', - opts.watch && '--watch', - opts.connect && '--connect', - ].filter(Boolean) + // --watch blocks (live, or quiet with --quiet). + const modes = [opts.detach && '--detach', opts.watch && '--watch'].filter(Boolean) if (modes.length > 1) { throw new Error(`provide at most one of ${modes.join(' / ')}`) } if (opts.quiet && !opts.watch) { throw new Error('--quiet only applies with --watch') } - // --connect takes over the terminal, so it can't emit the NDJSON stream. - if (opts.connect && opts.json) { - throw new Error('--connect is interactive and cannot be combined with --json') - } // The flat raw-session body: a SessionConfig plus run settings; // there is no base config to merge onto (a saved automation is // invoked with `agent automation run` instead). @@ -283,9 +264,7 @@ export function registerSession(program: Command): void { // Transparency for the environment resolution: say which environment // the server resolved when the session didn't name one (the agent - // config's own reference). The connect - // UI shows it in its footer meta line (anything printed before the - // app would land in scrollback); every other mode prints this note. + // config's own reference). let configNote: string | undefined const environmentSource = session.environment?.source if (session.environment?.id && environmentSource !== 'request') { @@ -297,25 +276,6 @@ export function registerSession(program: Command): void { configNote = configNote ? `${configNote}; ${note}` : note } - if (opts.connect) { - // A non-interactive config refuses the stream/messages surface, so - // a connect would fail; degrade to watching the output instead. - // The wire session carries no config blob, so interactivity comes - // from the same projection POST /messages enforces. - if (!session.lifecycle.prompting.enabled) { - if (configNote) console.log(configNote) - console.log( - 'this agent is not interactive; watching output instead of connecting', - ) - console.log(`✓ started session ${session.id}`) - await printSessionUrl(client, session.id) - await watchSessionStreaming(client, session.id, FALLBACK_POLL_INTERVAL_SECONDS, false) - return - } - await startConnect(session, undefined, sessionConfigName(session) ?? undefined) - return - } - if (!opts.json && configNote) console.log(configNote) if (opts.watch) { @@ -605,32 +565,6 @@ export function registerSession(program: Command): void { }) } -// `start --connect`: drop straight into the multi-session UI focused on the -// fresh session (or the solo connect when no TTY hosts the sidebar). The -// chat renders the sandbox lifecycle (creating sandbox → spawning agent -// process) as it happens and reports a terminal status reached before the -// sandbox ever ran (a preflight/budget gate), so there is nothing to wait -// for out here. -export async function startConnect( - session: AgentSession, - notice?: string, - // The start response's resolved config name, which the session itself does - // not carry; shown in the chat footer's meta line. - resolvedConfigName?: string, -): Promise { - const configName = resolvedConfigName ?? sessionConfigName(session) ?? undefined - if (canHostSessionsUi()) { - await runSessionsUi({ - initialSessionId: session.id, - initialConfigName: configName, - initialNotice: notice, - buildStartRequest: defaultStartRequest, - }) - return - } - await runConnect(session.id, true, false, notice, configName) -} - // `--watch` entry point: stream the session's output live over WebSocket, and // fall back to REST status polling if streaming is unavailable (e.g. a // backend without the endpoint). Identical UX either way — the same flag @@ -703,7 +637,7 @@ function renderFrameHuman(frame: StreamFrame, statusWord?: string): void { break case 'records_append': { // Raw records, rendered client-side (the semantic-relay philosophy): - // one line per transcript item, same shaping as `session connect`. + // one line per transcript item. // Lifecycle records render too (recordToItems shapes them through // lifecycleText): the startup narrative — scheduled, phase // transitions with cache tier + duration, setup output, ready — @@ -724,11 +658,11 @@ function renderFrameHuman(frame: StreamFrame, statusWord?: string): void { case 'done': break // handled by the caller default: - break // unknown frame types are ignored (protocol §3.6) + break // unknown frame types are ignored (see docs/SESSION_STREAMING.md) } } -// Exit 0 for a successful terminal status, non-zero otherwise (spec §4.1). +// Exit 0 for a successful terminal status, non-zero otherwise (see docs/SESSION_STREAMING.md). export function exitCodeForStatus(status: string): number { return ['completed', 'closed', 'idle'].includes(status) ? 0 : 1 } @@ -928,8 +862,8 @@ export async function resolveAuthorId(client: Ellipsis, login: string): Promise< ) } -// formatStepLine / recordText moved to lib/steps.ts (shared with `session -// connect`); re-exported here for existing importers and tests. +// formatStepLine / recordText live in lib/steps.ts; re-exported here for +// existing importers and tests. export { formatStepLine, recordText } // Pull one session-log segment's raw .jsonl.gz bytes from its presigned S3 URL diff --git a/src/lib/args.ts b/src/lib/args.ts index 94c4404..34a2072 100644 --- a/src/lib/args.ts +++ b/src/lib/args.ts @@ -2,8 +2,7 @@ import { InvalidArgumentError } from 'commander' // commander option coercions. These get called as (value, previous), so a bare // `parseInt` would treat `previous` as the radix — hence the explicit parsers. -// Kept out of the command modules so they're unit-testable without pulling in -// ink/React. +// Kept out of the command modules so they're unit-testable on their own. // Append a repeated `--flag a --flag b` option into an array. export function collect(value: string, previous: string[]): string[] { diff --git a/src/lib/chatItems.ts b/src/lib/chatItems.ts deleted file mode 100644 index c170b52..0000000 --- a/src/lib/chatItems.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - chatTurnsToItems as sdkChatTurnsToItems, - recordToItems, - claudePayload, - foldCosts, - type ChatTurn, - type TranscriptItem, -} from '@ellipsis-dev/sdk/store' -import type { SessionRecord } from './types' - -// The chat's transcript items: the SDK's connect layout (chatTurnsToItems — -// the SAME items the dashboard's chat renders: tool calls paired with their -// results, the sandbox story dropped, a wake settling in place, a failed turn -// carrying isError), with the SDK's middot wording swapped for commas on the -// notice lines — the CLI writes plain sentences, the same convention -// src/lib/steps.ts applies to the other SDK derivations. Everything downstream -// (folds, layout, rows, the scrollback flush) is unchanged. -export function chatTurnsToItems(turns: readonly ChatTurn[]): TranscriptItem[] { - return sdkChatTurnsToItems(turns).map((item) => - item.kind === 'notice' ? { ...item, text: item.text.replaceAll(' · ', ', ') } : item, - ) -} - -// Native Claude events keep their original payloads; the SDK derives the -// legacy display view used by the last-turn cost footer. -export function foldRecordCosts(records: readonly SessionRecord[]) { - return foldCosts(records.map(claudePayload).filter((payload) => payload !== null)) -} - -// Lifecycle and native bookkeeping records intentionally have no chat row. -// Unknown records still count, even when their producer or type looks familiar. -function isSilentRecord(record: SessionRecord): boolean { - switch (record.kind) { - case 'platform': - return true - case 'claude_sdk': - return record.payload.kind === 'system' || record.payload.kind === 'rate_limit' - case 'claude_code': - return ['system', 'rate_limit_event', 'conversation_reset'].includes( - record.payload.type, - ) - case 'codex': { - const event = record.payload - return ( - [ - 'thread.started', - 'turn.started', - 'turn.completed', - 'item.started', - 'item.updated', - ].includes(event.type) || - (event.type === 'item.completed' && event.item.type === 'todo_list') - ) - } - case 'codex_app_server': { - const event = record.payload - if (event.method === 'turn/completed') - return event.params.turn.status === 'completed' - if (event.method === 'error') return event.params.willRetry - if (event.method === 'item/completed') - return event.params.item.type === 'userMessage' - return true - } - default: - return false - } -} - -// Agent records that arrived and would render NOTHING — the signal for a -// payload shape this build cannot read (a harness change on the server, an -// out-of-date CLI). Without it such a record is invisible twice over: no row, -// and no hint that a row is missing. Init events are excluded: they are -// deliberately silent. A record the reader THROWS on must cost one count, not -// the whole transcript. Pure, for tests. -export function undisplayedRecordCount( - records: readonly SessionRecord[], - minRenderFeedSeq: number, -): number { - let undisplayed = 0 - for (const r of records) { - if (r.feed_seq <= minRenderFeedSeq || isSilentRecord(r)) continue - let rendered: TranscriptItem[] - try { - rendered = recordToItems(r, `s${r.feed_seq}`) ?? [] - } catch { - rendered = [] - } - if (rendered.length === 0) undisplayed++ - } - return undisplayed -} diff --git a/src/lib/config.ts b/src/lib/config.ts index e112050..54fe1e5 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -31,34 +31,12 @@ export interface Host { token?: string } -// How the interactive UI's session bar is scoped. Every field is optional; a -// missing one takes the SESSION_BAR_DEFAULTS value below. -export interface SessionBarConfig { - // Drop the session list entirely: the chat then never hands focus to it. - hidden?: boolean - // Only sessions that moved in the last N days; 0 means no age cutoff. - days?: number - // "cwd" lists only sessions on the repository the shell is in, falling back - // to every repository when the cwd is not one. "any" never scopes by repo. - repo?: 'cwd' | 'any' - // "unfinished" drops the sessions that completed, errored, or were stopped, - // leaving the conversations still going. "all" keeps them. - statuses?: 'all' | 'unfinished' - // Only sessions started these ways, e.g. ["cli", "web"]. An empty list - // means every source. Laptop sessions never appear whatever this says: there - // is nothing in the cloud to open. - sources?: string[] -} - // The config file (v2): a named set of hosts plus which one is active. Commands // resolve against the active host unless an env var / explicit arg overrides. -// UI preferences live at the top level, not per host: they describe the -// terminal in front of the user, which is the same whichever host is active. export interface CliConfig { version: 2 activeHost?: string hosts: Record - sessionBar?: SessionBarConfig } // The pre-hosts (v1) file shape — a single flat instance. Kept only so @@ -230,54 +208,6 @@ export function clearAllTokens(): void { saveConfig(cfg) } -// The session bar with nothing configured: scoped to the repository you are -// standing in and the last week, which is short enough to read at a glance -// without hiding a session you are likely to reopen. `repo: 'cwd'` falls back -// to every repository outside a repo, so the bar is never mysteriously empty. -// The default sources are the ones a person started; `react` and `cron` fire on -// their own and would bury the sessions you are actually working in. -export const SESSION_BAR_DEFAULTS: Required> & { - sources: string[] | undefined -} = { - hidden: false, - days: 7, - repo: 'cwd', - statuses: 'all', - sources: ['web', 'cli', 'mention'], -} - -export type ResolvedSessionBar = typeof SESSION_BAR_DEFAULTS - -const SESSION_SOURCES = ['react', 'web', 'api', 'cli', 'mention', 'cron'] - -// The session bar's settings, defaults filled in — set them under -// `"sessionBar"` in ~/.ellipsis/config.json. A value of the wrong type or -// outside its range takes the default rather than throwing: a typo in a -// preference should not stop the UI from opening. -export function sessionBar(): ResolvedSessionBar { - const raw = loadConfig().sessionBar - if (!raw || typeof raw !== 'object') return { ...SESSION_BAR_DEFAULTS } - const sources = Array.isArray(raw.sources) - ? raw.sources.filter((s) => SESSION_SOURCES.includes(s)) - : null - return { - hidden: raw.hidden === true, - days: - typeof raw.days === 'number' && isFinite(raw.days) && raw.days >= 0 - ? Math.floor(raw.days) - : SESSION_BAR_DEFAULTS.days, - repo: raw.repo === 'any' || raw.repo === 'cwd' ? raw.repo : SESSION_BAR_DEFAULTS.repo, - statuses: - raw.statuses === 'unfinished' || raw.statuses === 'all' - ? raw.statuses - : SESSION_BAR_DEFAULTS.statuses, - // An explicit [] would list nothing at all, which no one means; treat it as - // "every source" — the only way to ask for the automated ones too. Leaving - // the key out keeps the human-started default. - sources: sources === null ? SESSION_BAR_DEFAULTS.sources : sources.length > 0 ? sources : undefined, - } -} - // --- credential / URL resolution -------------------------------------------- // // Precedence (highest wins): explicit arg → environment → active host → default. diff --git a/src/lib/editing.ts b/src/lib/editing.ts deleted file mode 100644 index 1f3bb40..0000000 --- a/src/lib/editing.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Cursor and kill shortcuts shared by both text inputs (the chat composer and -// the new-session prompt): the readline bindings a terminal actually delivers -// — option/ctrl + ←/→ (and meta+b / meta+f, which is what iTerm sends for -// option+←/→) hop a word, ctrl+a / ctrl+e reach the line edges, -// option+backspace and ctrl+w kill a word, ctrl+u / ctrl+k kill to the line -// edges. cmd+←/→ never reaches the process (macOS terminals keep the command -// modifier for themselves), so ctrl+a / ctrl+e stand in for it. - -export type TextState = { text: string; cursor: number } - -// The subset of ink's key object these shortcuts read. -export interface EditKey { - ctrl: boolean - meta: boolean - leftArrow: boolean - rightArrow: boolean - backspace: boolean - delete: boolean -} - -const WORD_CHAR = /\S/ - -// The start of the word at or before the cursor: skip any whitespace to the -// left, then the run of word characters (newlines count as whitespace, so a -// hop can cross a line, exactly like a text editor's). -export function wordLeft(text: string, cursor: number): number { - let i = Math.max(0, Math.min(cursor, text.length)) - while (i > 0 && !WORD_CHAR.test(text[i - 1])) i-- - while (i > 0 && WORD_CHAR.test(text[i - 1])) i-- - return i -} - -// The end of the word at or after the cursor (mirror of wordLeft). -export function wordRight(text: string, cursor: number): number { - let i = Math.max(0, Math.min(cursor, text.length)) - while (i < text.length && !WORD_CHAR.test(text[i])) i++ - while (i < text.length && WORD_CHAR.test(text[i])) i++ - return i -} - -export function lineStart(text: string, cursor: number): number { - return cursor > 0 ? text.lastIndexOf('\n', cursor - 1) + 1 : 0 -} - -export function lineEnd(text: string, cursor: number): number { - const next = text.indexOf('\n', cursor) - return next < 0 ? text.length : next -} - -// The input state after an editing shortcut, or null when the keypress isn't -// one — the signal for the caller to keep walking its own key handling. -export function applyEditShortcut( - state: TextState, - ch: string, - key: EditKey, -): TextState | null { - if (!key.ctrl && !key.meta) return null - const { text, cursor } = state - if (key.leftArrow || (key.meta && (ch === 'b' || ch === 'B'))) { - return { text, cursor: wordLeft(text, cursor) } - } - if (key.rightArrow || (key.meta && (ch === 'f' || ch === 'F'))) { - return { text, cursor: wordRight(text, cursor) } - } - if (key.ctrl && ch === 'a') return { text, cursor: lineStart(text, cursor) } - if (key.ctrl && ch === 'e') return { text, cursor: lineEnd(text, cursor) } - if ((key.meta && (key.backspace || key.delete)) || (key.ctrl && ch === 'w')) { - const at = wordLeft(text, cursor) - return { text: text.slice(0, at) + text.slice(cursor), cursor: at } - } - if (key.ctrl && ch === 'u') { - const at = lineStart(text, cursor) - return { text: text.slice(0, at) + text.slice(cursor), cursor: at } - } - if (key.ctrl && ch === 'k') { - const at = lineEnd(text, cursor) - return { text: text.slice(0, cursor) + text.slice(at), cursor } - } - return null -} diff --git a/src/lib/help.ts b/src/lib/help.ts index 810cbc5..a76f321 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -27,18 +27,6 @@ const TOP_LEVEL_GROUPS: ReadonlyArray<{ title: string; commands: readonly string { title: 'Help', commands: ['help'] }, ] -// Closes out every help page, so a caller who can't find what they need has a -// next move. Kept here rather than in the help command so it can't drift. -const INTERACTIVE_HELP_HINT = - 'Use `agent help --interactive` to have an agent answer questions about the Ellipsis platform, or if you are stuck while building an agent.' - -// Commander wraps descriptions to the terminal width but passes addHelpText -// through verbatim, so wrap this ourselves or it renders as one long line. -function wrapToHelpWidth(text: string): string { - const width = new Help().helpWidth || 80 - return Help.prototype.wrap.call(new Help(), text, width, 0) -} - export function configureCliHelp(program: Command): void { program.configureHelp({ sortSubcommands: true, @@ -53,15 +41,6 @@ export function configureCliHelp(program: Command): void { return formatTopLevelHelp(cmd, helper) }, }) - // Registered on the program only: commander emits `afterAllHelp` up the - // ancestor chain, so this lands on every subcommand's help page too. It goes - // last, after each command's own `API:` line. Suppressed on `agent help - // --interactive`'s own page, where pointing at the flag you just read about - // is noise. - program.addHelpText('afterAll', ({ command }) => { - if (command.name() === 'help') return '' - return `\n${wrapToHelpWidth(INTERACTIVE_HELP_HINT)}` - }) } // `agent --help`: same layout commander produces, except the flat 20-command diff --git a/src/lib/images.ts b/src/lib/images.ts index 5e5760b..2fff371 100644 --- a/src/lib/images.ts +++ b/src/lib/images.ts @@ -3,8 +3,8 @@ import { basename } from 'node:path' import type { ImageAttachment } from './types' // Images on a message — the Claude Code paste model. `agent session start -// --image shot.png` and the connect composer's `/image shot.png` read the -// file here, and it rides the request inline (base64) as `images`; the server +// --image shot.png` reads the file here, and it rides the request inline +// (base64) as `images`; the server // appends one `[Image #N]` placeholder per image to the message body and the // model sees the picture as a content block on the turn that message opens. // diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts deleted file mode 100644 index 25a2ff8..0000000 Binary files a/src/lib/markdown.ts and /dev/null differ diff --git a/src/lib/output.ts b/src/lib/output.ts index e44bf2c..baa7df7 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -60,12 +60,6 @@ export function usdFromMillicents(millicents: number): string { return `$${(millicents / 100_000).toFixed(2)}` } -// Millicents -> USD as a number, for callers that do math/compare before -// formatting (e.g. the connect footer's monotonic live cost). -export function usdNumberFromMillicents(millicents: number): number { - return millicents / 100_000 -} - export function usd(amount: number): string { return `$${amount.toFixed(2)}` } diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index ef06ef4..6306184 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -1,141 +1,14 @@ -import { sessionStatusWord } from '@ellipsis-dev/sdk/stream' -import type { Session as FrameSession } from '@ellipsis-dev/sdk' -import { SESSION_BAR_DEFAULTS } from './config' -import { theme } from './theme' -import type { - AgentSession, - AgentSessionSource, - ListAgentSessionsQuery, - ModelManufacturer, - ModelRateCard, - StartAgentSessionRequest, - SupportedModel, -} from './types' +import type { AgentSession, StartAgentSessionRequest } from './types' -// Both REST sessions and start requests carry the prompt in the selected harness. -export function sessionPrompt(session: Pick): string | null | undefined { - return session.claude_code?.prompt ?? session.codex?.prompt -} +// Pure session helpers shared by the session commands. No I/O here. +// Both REST sessions and start requests carry the prompt in the selected harness. export function withSessionPrompt(req: StartAgentSessionRequest, prompt: string): StartAgentSessionRequest { return req.codex ? { ...req, codex: { ...req.codex, prompt } } : { ...req, claude_code: { ...req.claude_code, prompt } } } -// Pure session-model helpers shared by the connect command and the -// multi-session UI (SessionsApp). No I/O here — everything is testable. - -// THE selection marker, everywhere: the one character that says "you are here" -// — it replaces a sidebar row's status dot, a transcript line's gutter icon, -// and the focused composer's prompt. Always painted theme.cursor, the cyan that -// means nothing else, so the eye finds it instantly anywhere in the console. -// The thick right-arrow is reserved for selection alone; statuses are dots. -export const SELECTION_GLYPH = '▶' - -// Whether the composer can send to this session, and — when it can't — why. -// -// The server decides: `prompting` (protocol §4.1) is the same projection -// POST /messages enforces, so the composer appears exactly where a send would -// succeed and we never open an input whose first Enter 409s. Its `detail` is -// the server's curated sentence, so a new refusal reason needs no CLI release -// to read well. A session that came from a surface (a Slack/GitHub/Linear -// mention) is steered THERE, not here, because that surface is where the -// agent's answers post. -export function connectability(session: AgentSession): { - canSend: boolean - reason?: string -} { - if (session.lifecycle.prompting.enabled) return { canSend: true } - const detail = session.lifecycle.prompting.detail?.trim() - return { - canSend: false, - reason: detail - ? `${detail} Opening watch-only.` - : 'this session does not accept messages — opening watch-only', - } -} - -// The canonical lifecycle status for a session row. -export function rowStatusWord(session: AgentSession): string { - return sessionStatusWord(session as unknown as FrameSession) -} - -// Statuses in which the agent is actively doing something (the sidebar's -// "in flight" read; mirrors the chat's spinner statuses). -export function isActiveStatusWord(word: string): boolean { - return ['scheduled', 'starting', 'working', 'retrying', 'running', 'creating_sandbox'].includes( - word, - ) -} - -// The sidebar row's status marker: one dot, status told by color alone (the -// arrow shape belongs to the selection cursor): -// ● amber: in flight, ● bone: your move (waiting), ● dim: sleeping -// ● green: done/closed, ● red: failed, ● dim red: stopped/cancelled -export function rowGlyph(word: string): { glyph: string; color?: string; dim: boolean } { - if (isActiveStatusWord(word)) return { glyph: '●', color: theme.active, dim: false } - if (word === 'waiting') return { glyph: '●', color: theme.foreground, dim: false } - if (word === 'sleeping' || word === 'idle') return { glyph: '●', dim: true } - if (word === 'error' || word === 'failed') return { glyph: '●', color: theme.error, dim: false } - if (word === 'stopped' || word === 'cancelled') - return { glyph: '●', color: theme.error, dim: true } - // closed / completed and anything unrecognized settles as done. - return { glyph: '●', color: theme.success, dim: true } -} - -// The row's one-line description: what the session is doing right now (its -// live summary), else what it was asked to do (prompt), else where it came -// from. Whitespace collapsed; the caller truncates to the column. -export function rowDescription(session: AgentSession): string { - const summary = session.summary?.description - if (typeof summary === 'string' && summary.trim()) return oneLineText(summary) - const prompt = sessionPrompt(session) - if (typeof prompt === 'string' && prompt.trim()) return oneLineText(prompt) - const source = typeof session.source === 'string' ? session.source : null - return source ? `${source} session` : 'session' -} - -function oneLineText(text: string): string { - return text.replace(/\s+/g, ' ').trim() -} - -// The instant the session last did anything visible — what the row's age -// line counts from. -export function lastEventAt(session: AgentSession): string { - const last = session.lifecycle.timestamps.last_activity_at - if (typeof last === 'string' && last) return last - const msg = session.lifecycle.timestamps.last_message_at - if (typeof msg === 'string' && msg) return msg - return session.lifecycle.timestamps.updated_at -} - -// Compact age for the row's second line: "12s ago", "2m ago", "3h ago", -// "5d ago". Never negative. -export function shortAge(iso: string, now: Date = new Date()): string { - const seconds = Math.max(0, Math.floor((now.getTime() - Date.parse(iso)) / 1000)) - if (seconds < 60) return `${seconds}s ago` - const minutes = Math.floor(seconds / 60) - if (minutes < 60) return `${minutes}m ago` - const hours = Math.floor(minutes / 60) - if (hours < 24) return `${hours}h ago` - return `${Math.floor(hours / 24)}d ago` -} - -// The nav row's right-hand metadata: what the session cost and when it last -// moved. Spend is the server's millicent total, the same total the chat footer -// shows. No token count: it is a number you cannot act on from the launcher. -// A just-started session drops the empty spend rather than showing "$0.00". No -// source tag either: the nav lists cloud sessions only, so it would read the -// same on every row. -export function rowMeta(session: AgentSession, now: Date = new Date()): string { - const bits: string[] = [] - const millicents = session.cost?.total ?? 0 - if (millicents > 0) bits.push(`$${(millicents / 100_000).toFixed(2)}`) - bits.push(shortAge(lastEventAt(session), now)) - return bits.join(', ') -} - // The agent identity to show for a session: the automation's own name, else // the id of the automation it was started from. Both are absent for raw // sessions, which have nothing to name. @@ -145,135 +18,6 @@ export function sessionConfigName(session: AgentSession): string | null { return automation.config.ellipsis.name ?? automation.id ?? null } -// The sidebar's status bands, top to bottom: live conversations, then parked -// ones, then finished ones, with the dead ends last. -// -// `waiting` shares the top band with the in-flight statuses on purpose. A warm -// session crosses working → waiting → working on EVERY turn, so banding those -// apart would reorder the list on every agent response — exactly the churn -// created_at ordering exists to kill. Both mean "this conversation is live"; -// which side of a turn it's on is the row's dot color, not its position. -export function statusBand(word: string): number { - if (word === 'waiting' || isActiveStatusWord(word)) return 0 - if (word === 'sleeping' || word === 'idle') return 1 - if (['error', 'failed', 'stopped', 'cancelled'].includes(word)) return 3 - // closed / completed and anything unrecognized settles as done. - return 2 -} - -// Sidebar order: status band, then oldest-last within the band (newest first). -// -// Deliberately NOT "most recent event first": with several sessions in flight -// every agent response is a new event, so a recency sort permutes the list on -// every poll and the row you were reading walks out from under you. Birth -// order is fixed for the life of a session, so a row only ever moves when its -// status band changes — a handful of times, not once per turn. -// -// Stable for equal keys (equal band + equal created_at keeps input order). -export function sortSidebarSessions(sessions: readonly AgentSession[]): AgentSession[] { - const band = (s: AgentSession): number => statusBand(rowStatusWord(s)) - const born = (s: AgentSession): number => Date.parse(s.lifecycle.timestamps.created_at) || 0 - return [...sessions].sort((a, b) => band(a) - band(b) || born(b) - born(a)) -} - -// The sidebar list: the polled snapshot plus composer-spawned sessions the -// poll has not returned yet. A session can sit in both for one poll cycle -// (a list read that lands mid-create, then the create response), so it is -// deduped by id with the polled copy winning — that one is fresher. -export function mergeSidebarSessions( - polled: readonly AgentSession[], - local: readonly AgentSession[], -): AgentSession[] { - const byId = new Map() - for (const s of [...polled, ...local]) if (!byId.has(s.id)) byId.set(s.id, s) - return sortSidebarSessions([...byId.values()]) -} - -// ---------------------------- session bar scope --------------------------- - -// The list query behind the session bar, from the user's `sessionBar` settings. -// Age, repository, status, and source are all server-side filters, so the rows -// that come back are already the ones worth showing and the page is not spent -// on sessions the bar would drop. -// -// `repo: 'cwd'` outside a repository asks for every repository rather than a -// repo named "" — the bar is a way back into your work, so a shell in ~ should -// still show it. -export function sessionBarQuery( - bar: { - days: number - repo: 'cwd' | 'any' - statuses: 'all' | 'unfinished' - sources: string[] | undefined - }, - context: { authorId: number | null; detectedRepo: string | null }, -): ListAgentSessionsQuery { - const query: ListAgentSessionsQuery = { - author_id: context.authorId ?? undefined, - // Enough rows to band and scroll past a screenful, without paying for a page - // nobody scrolls to. - limit: SESSION_BAR_FETCH, - } - if (bar.days > 0) query.days = bar.days - if (bar.repo === 'cwd' && context.detectedRepo) query.repo = context.detectedRepo - if (bar.statuses === 'unfinished') query.unfinished = true - if (bar.sources) query.source = bar.sources as AgentSessionSource[] - return query -} - -// How many rows the session list fetches to fill its screen from. -export const SESSION_BAR_FETCH = 50 - -// The picker header's description of the bar's active filters — the answer to -// "where are the rest of my sessions?". Mirrors sessionBarQuery, with one -// exception: the default sources are not named, since "web/cli/mention" is -// what everyone sees and answers no question. null when there is nothing to say. -export function sessionBarFilterLabel( - bar: { - days: number - repo: 'cwd' | 'any' - statuses: 'all' | 'unfinished' - sources: string[] | undefined - }, - detectedRepo: string | null, -): string | null { - const clauses: string[] = [] - if (bar.repo === 'cwd' && detectedRepo) clauses.push(detectedRepo) - if (bar.days > 0) clauses.push(`last ${bar.days === 1 ? 'day' : `${bar.days} days`}`) - if (bar.statuses === 'unfinished') clauses.push('unfinished') - const sources = bar.sources?.join('/') - if (sources && sources !== SESSION_BAR_DEFAULTS.sources?.join('/')) { - clauses.push(`source ${sources}`) - } - if (clauses.length === 0) return null - return `filtering to ${clauses.join(', ')}` -} - -// The launcher's live filter: which sessions match the typed text. Matches -// the summary and the prompt case-insensitively so a session is findable by -// what it is doing or by what it was asked; an empty query matches all. -export function filterSessions( - sessions: readonly AgentSession[], - query: string, -): readonly AgentSession[] { - const q = query.trim().toLowerCase() - if (!q) return sessions - return sessions.filter((s) => { - const summary = typeof s.summary?.description === 'string' ? s.summary.description : '' - const prompt = typeof sessionPrompt(s) === 'string' ? sessionPrompt(s) : '' - return `${summary}\n${prompt}`.toLowerCase().includes(q) - }) -} - -// Attention transitions: a session that WAS in flight and now waits for a -// human (waiting/sleeping/idle) deserves the sidebar dot. Pure step function -// over consecutive poll snapshots. -export function attentionFlip(prevWord: string | undefined, nextWord: string): boolean { - if (prevWord === undefined) return false - if (!isActiveStatusWord(prevWord)) return false - return nextWord === 'waiting' || nextWord === 'sleeping' || nextWord === 'idle' -} - // --------------------------- start request shaping ------------------------- // POST /v1/sessions is flat: the request IS a SessionConfig plus run settings // (there is no base config and no merge; a saved automation is invoked with @@ -355,840 +99,3 @@ export function withContextRepository( if (repositories.includes(repo)) return req return { ...req, repositories: [...repositories, repo] } } - -// --------------------------- new-session picker --------------------------- - -// One row of a composer picker. `group` and `rate` are what the model list -// uses and the other two pickers leave unset: repositories and automations -// are flat lists of names with no vendor to group under and no price to quote. -export type ComposerModel = { - harness?: 'claude_code' | 'codex' - modelId?: string - id: string | null - label: string - // The heading this row sits under. Consecutive rows sharing a group print - // one heading between them; an unset group prints none. - group?: string | null - // The two price cells printed after the label, already formatted as dollars. - // Unset when there is no rate to quote (see modelRate). - rate?: { input: string; output: string } | null -} - -// The vendor groups, in the order their headings appear, and the names those -// headings carry. Both are copies of the dashboard's rate-card table -// (frontend ModelsRateCardTab + manufacturerLabel), so a model sits under the -// same vendor with the same spelling in the terminal as on the web. -// -// Ranked, not sorted: alphabetical would put OpenAI above Anthropic, and price -// would reshuffle the groups every time one rate card moves. A manufacturer -// the server adds before this build knows about it lands last, under its raw -// enum name, which is wrong-looking but never missing. -const MANUFACTURER_ORDER: readonly string[] = [ - 'anthropic', - 'openai', - 'zai', - 'minimax', - 'moonshot', -] -const MANUFACTURER_LABELS: Readonly> = { - anthropic: 'Anthropic', - openai: 'OpenAI', - zai: 'Z.ai', - minimax: 'MiniMax', - moonshot: 'Moonshot AI', -} - -function manufacturerLabel(manufacturer: ModelManufacturer | string): string { - return MANUFACTURER_LABELS[manufacturer] ?? manufacturer -} - -function manufacturerRank(manufacturer: ModelManufacturer | string): number { - const at = MANUFACTURER_ORDER.indexOf(manufacturer) - return at === -1 ? MANUFACTURER_ORDER.length : at -} - -// Rate-card millicents per 1M tokens → "$5", "$0.75". Whole dollars drop the -// ".00": at a glance "$5" is a price, where "$5.00" reads as a table cell. -export function rateDollars(millicents: number): string { - const dollars = millicents / 100_000 - return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}` -} - -// A model's price as two table cells: the two lanes that decide what a session -// costs, read and written. The three cache lanes are deliberately left out — -// five numbers on a picker row is a rate card, not a hint, and `agent model -// list` (plus the dashboard's Models tab) is where the full card belongs. Null -// when the server sent no card, which is the honest answer: a stale hardcoded -// price is worse than no price. -export function modelRate( - rate: ModelRateCard | null | undefined, -): { input: string; output: string } | null { - if (!rate) return null - return { - input: rateDollars(rate.input_millicents_per_1m_tokens), - output: rateDollars(rate.output_millicents_per_1m_tokens), - } -} - -// The composer's model list when GET /models is unavailable (an older -// server): the agent-selectable set as of this build, most expensive first. -// `null` id = let the server pick (DEFAULT_AGENT_MODEL). Labels are the raw -// model ids — the CLI speaks the API's vocabulary, not marketing names. Every -// id here is Anthropic-built, so the one heading is hardcoded; no rates, -// because a price this list can't refresh would go stale silently. -export const COMPOSER_MODELS: ReadonlyArray = [ - { id: null, label: 'Default' }, - { id: 'claude-fable-5', label: 'claude-fable-5', group: 'Anthropic' }, - { id: 'claude-opus-5', label: 'claude-opus-5', group: 'Anthropic' }, - { id: 'claude-opus-4-8', label: 'claude-opus-4-8', group: 'Anthropic' }, - { id: 'claude-sonnet-5', label: 'claude-sonnet-5', group: 'Anthropic' }, - { - id: 'claude-haiku-4-5-20251001', - label: 'claude-haiku-4-5-20251001', - group: 'Anthropic', - }, -] - -// The composer's model options from the server's list, grouped by who BUILT -// each model (MANUFACTURER_ORDER) and, inside a group, left in the server's -// order — which is most expensive first, so every group reads down from its -// flagship. Labels are raw model ids, each carrying its rate as price cells. -// -// The account default's row carries the null "let the server pick" id, so -// leaving the picker alone keeps the server as the one resolving the model. -// It sits in its vendor's group like any other row: it is one entry in one -// table, marked by being the checked one, not by a heading of its own. -// -// Only when NO model claims the flag does a separate "Default" row appear at -// the top, because then there is no id to attach the null pick to. -export function composerModelOptions(models: readonly SupportedModel[]): ComposerModel[] { - if (models.length === 0) return [...COMPOSER_MODELS] - const hasDefault = models.some((m) => m.is_default_agent_model) - const rows = models - // Stable, so the server's within-vendor ordering survives the regroup. - .slice() - .sort((a, b) => manufacturerRank(a.manufacturer) - manufacturerRank(b.manufacturer)) - .map((m) => ({ - id: (m.is_default_agent_model ? null : m.id) as string | null, - label: m.id, - harness: m.harness, - modelId: m.id, - group: manufacturerLabel(m.manufacturer), - rate: modelRate(m.rate_card), - })) - return hasDefault ? rows : [{ id: null, label: 'Default', group: null }, ...rows] -} - -// The default row inherits the account model for Claude. Codex's request -// requires a concrete model, so retain the catalog id even on its default row. -export function composerModelChoice( - option: ComposerModel | undefined, -): Pick { - return { - model: option?.id ?? (option?.harness === 'codex' ? option.modelId ?? null : null), - harness: option?.harness, - } -} - -// A picker's display rows: each group's heading, then the options under it. -// Headings are DECORATION — they carry no index, ↑/↓ never lands on one, and -// activating a row can't select one — so the list scrolls over these rows -// while the highlight stays an option index. A heading therefore scrolls away -// with its group instead of pinning to the top of the window, which is what -// keeps this a plain list and not a sticky-header layout. -export type ComposerPickerRow = - | { kind: 'group'; label: string } - | { kind: 'option'; at: number } - -export function composerPickerRows( - options: readonly ComposerModel[], -): ComposerPickerRow[] { - const rows: ComposerPickerRow[] = [] - let group: string | null = null - options.forEach((option, at) => { - const next = option.group ?? null - if (next !== null && next !== group) rows.push({ kind: 'group', label: next }) - group = next - rows.push({ kind: 'option', at }) - }) - return rows -} - -export const REPOSITORIES_HEADING = 'repositories' -export const VARIABLES_HEADING = 'variables' -export const ADD_VARIABLE_LABEL = '+ new' -export const COMPUTE_HEADING = 'compute' -export const IMAGE_HEADING = 'build' -export const HOOKS_HEADING = 'hooks' -export const MCP_SERVERS_HEADING = 'mcp servers' -export const ADD_MCP_SERVER_LABEL = '+ new' - -// The compute fields, in the order their rows appear. Each is an inline text -// input like a repo's branch row; blank = whatever the server resolves. -export const COMPUTE_FIELDS = ['cpu', 'memory', 'timeout'] as const -export type ComputeField = (typeof COMPUTE_FIELDS)[number] - -// What the custom section's compute rows hold, as typed. Strings even for cpu: -// these are input fields, and the override conversion is where parsing lives. -export type CustomCompute = Readonly> - -export const EMPTY_COMPUTE: CustomCompute = { cpu: '', memory: '', timeout: '' } - -// Cached build scripts. Longer scripts and build inputs can also be declared -// in an environment YAML; the pane preserves the selected build inputs. -export const IMAGE_FIELDS = ['build_base', 'after_checkout'] as const -export type ImageField = (typeof IMAGE_FIELDS)[number] - -export type CustomImage = Readonly> - -export const EMPTY_IMAGE: CustomImage = { build_base: '', after_checkout: '' } - -// The lifecycle hooks: `post_start` runs after the container starts (before -// any repo is cloned), `post_clone` after checkout, before the agent. Per-run -// scripts, never cached — same one-line inputs as image. -export const HOOK_FIELDS = ['post_start', 'post_clone'] as const -export type HookField = (typeof HOOK_FIELDS)[number] - -export type CustomHooks = Readonly> - -export const EMPTY_HOOKS: CustomHooks = { post_start: '', post_clone: '' } - -// The platform's built-in MCP servers an agent opts into by name, shown as -// checkboxes when the matching integration is connected (an unconnected one -// has no server to opt into). -export function builtInMcpServers(integrations: { - linear?: unknown - slack?: unknown -}): string[] { - return [ - ...(integrations.linear ? ['linear'] : []), - ...(integrations.slack ? ['slack'] : []), - ] -} - -// An MCP server added in the launcher's custom section. A built-in checkbox is -// a name alone; the "+ new server" form fills name plus exactly one of -// command (stdio — the harness spawns it in the sandbox) or url (remote). -// args/env/headers stay YAML-only: past a command line, define the server in -// an environment file. -export interface CustomMcpServer { - name: string - command: string | null - url: string | null - // The entry a seeded server came from, kept verbatim so a definition the - // launcher's three fields can't hold (args, env, headers) survives being - // carried through a custom run rather than being quietly flattened away. - raw?: unknown -} - -// A custom server in the shape the mcp_servers override takes: `{name}` opts -// into a built-in; a command line splits into command + args (the schema's -// stdio shape); a url is the remote shape. -export function mcpServerEntry(s: CustomMcpServer): unknown { - if (s.raw !== undefined) return s.raw - if (s.command) { - const [command, ...args] = s.command.split(/\s+/) - return args.length > 0 ? { name: s.name, command, args } : { name: s.name, command } - } - if (s.url) return { name: s.name, url: s.url } - return { name: s.name } -} - -// The form's commit check: a name is required, and command/url pick the server -// type so both at once is ambiguous. Both empty is fine — a bare name opts -// into a built-in. -export function validateMcpServer(s: CustomMcpServer): string | null { - if (!s.name.trim()) return 'name a server' - if (s.command && s.url) return 'fill command or url, not both' - return null -} - -// The set fields of a string-record section, for a merging object override. -export function fieldsOverride(fields: Readonly>): Record { - const out: Record = {} - for (const [key, value] of Object.entries(fields)) { - if (value.trim() !== '') out[key] = value.trim() - } - return out -} - -// The compute override from the typed fields: only the set ones, since an -// override's nested objects merge key by key (unlike its arrays). cpu parses -// to the number the schema wants; unparseable cpu is dropped rather than sent -// as a string the server would 400 on (the input row only admits digits, so -// this is belt and braces). -export function computeOverride(compute: CustomCompute): Record { - const out: Record = {} - const cpu = compute.cpu.trim() - if (cpu !== '' && !Number.isNaN(Number(cpu))) out.cpu = Number(cpu) - if (compute.memory.trim() !== '') out.memory = compute.memory.trim() - if (compute.timeout.trim() !== '') out.timeout = compute.timeout.trim() - return out -} - -// The built-in bare-sandbox preset's id. A sentinel, never sent: the launcher -// translates it into `environment: {}`, so the request says the sandbox -// outright rather than leaving it to omission. -export const EMPTY_ENVIRONMENT_ID = 'empty:builtin' -export const EMPTY_ENVIRONMENT_LABEL = 'empty' - -// The preset that appears at the end of the row, checked, once the pane no -// longer matches any saved environment. Also a sentinel: it names no -// environment on the wire — the pane's own lists are the whole message. -export const CUSTOM_ENVIRONMENT_ID = 'custom:builtin' -export const CUSTOM_ENVIRONMENT_LABEL = 'custom' - -// The launcher's configuration sections, printed open one after another -// under the preset row: the connected repositories, the MCP servers, the -// variables, then the image, hook and compute fields. -// -// Together the sections are the whole truth about the sandbox. Picking an -// environment seeds them (see environmentPane); editing any row is what makes -// the run "custom", and what the sections hold is what ships. So unchecking a -// repository the picked environment brought in actually drops that repository -// from the run, which the old additive section could not express. -export const PANE_SECTIONS = [ - 'repositories', - 'mcpServers', - 'variables', - 'image', - 'hooks', - 'compute', -] as const -export type PaneSection = (typeof PANE_SECTIONS)[number] - -export const PANE_SECTION_LABELS: Record = { - repositories: REPOSITORIES_HEADING, - mcpServers: MCP_SERVERS_HEADING, - variables: VARIABLES_HEADING, - image: IMAGE_HEADING, - hooks: HOOKS_HEADING, - compute: COMPUTE_HEADING, -} - -// One row of an open section. `hover` is the index ↑/↓ walks, local to the -// section — each section is its own flat walk. -export type EnvironmentPaneRow = - | { kind: 'repo'; fullName: string; hover: number } - | { kind: 'repoRef'; fullName: string; hover: number } - | { kind: 'variable'; name: string; hover: number } - | { kind: 'addVariable'; hover: number } - | { kind: 'compute'; field: ComputeField; hover: number } - | { kind: 'image'; field: ImageField; hover: number } - | { kind: 'hook'; field: HookField; hover: number } - | { kind: 'mcpServer'; name: string; hover: number } - | { kind: 'addMcpServer'; hover: number } - -// What activating a hovered row does, without the renderer having to know the -// section's shape. -export type EnvironmentPaneTarget = EnvironmentPaneRow extends infer R - ? R extends { hover: number } - ? Omit - : never - : never - -export interface EnvironmentPaneInput { - // The account's connected repositories ("owner/name"), one checkbox each; - // a checked one clones at its default branch unless a ref is typed. - repoNames: readonly string[] - // Which of those are in the run. A checked repo grows a `branch:` input row - // under it, where ↓ lands and typing sets the ref (blank = default branch). - checkedRepoNames: readonly string[] - // The account's stored secret names (values are write-only, so checking one - // ships a variable with no value and the sandbox resolves it at start), plus - // whatever the pane holds — a name from either side gets exactly one row. - secretNames: readonly string[] - variables: readonly CustomVariable[] - // The built-in MCP servers connected on this account, plus the pane's own - // servers whose names aren't among them. - builtInMcpServers: readonly string[] - mcpServers: readonly CustomMcpServer[] -} - -// One row per name, in the order the two lists give them: a stored secret the -// pane also carries is one row, not two. -function unionNames(first: readonly string[], second: readonly string[]): string[] { - const out = [...first] - for (const name of second) if (!out.includes(name)) out.push(name) - return out -} - -export function environmentSectionRows( - input: EnvironmentPaneInput, - section: PaneSection, -): EnvironmentPaneRow[] { - const rows: EnvironmentPaneRow[] = [] - let hover = 0 - if (section === 'repositories') { - // Empty until the repo fetch lands (or when the account has none) — the - // section then opens onto nothing. - for (const fullName of input.repoNames) { - rows.push({ kind: 'repo', fullName, hover: hover++ }) - if (input.checkedRepoNames.includes(fullName)) { - rows.push({ kind: 'repoRef', fullName, hover: hover++ }) - } - } - return rows - } - if (section === 'mcpServers') { - for (const name of unionNames( - input.builtInMcpServers, - input.mcpServers.map((s) => s.name), - )) { - rows.push({ kind: 'mcpServer', name, hover: hover++ }) - } - rows.push({ kind: 'addMcpServer', hover: hover++ }) - return rows - } - if (section === 'variables') { - for (const name of unionNames( - input.secretNames, - input.variables.map((v) => v.name), - )) { - rows.push({ kind: 'variable', name, hover: hover++ }) - } - rows.push({ kind: 'addVariable', hover: hover++ }) - return rows - } - if (section === 'image') { - for (const field of IMAGE_FIELDS) rows.push({ kind: 'image', field, hover: hover++ }) - return rows - } - if (section === 'hooks') { - for (const field of HOOK_FIELDS) rows.push({ kind: 'hook', field, hover: hover++ }) - return rows - } - for (const field of COMPUTE_FIELDS) rows.push({ kind: 'compute', field, hover: hover++ }) - return rows -} - -// Where a hover index lands, clamped to the section. -export function environmentSectionAt( - input: EnvironmentPaneInput, - section: PaneSection, - hover: number, -): EnvironmentPaneTarget { - const rows = environmentSectionRows(input, section) - const row = rows[Math.min(Math.max(0, hover), rows.length - 1)] - const { hover: _at, ...target } = row - return target as EnvironmentPaneTarget -} - -// Every section's rows in one flat walk, in PANE_SECTIONS order: the launcher -// prints every section open, so ↑/↓ move down one list rather than opening -// sections one at a time. `first` marks a section's first row, where its label -// prints. -export interface PaneWalkStep { - section: PaneSection - hover: number - first: boolean -} - -export function environmentPaneWalk(input: EnvironmentPaneInput): PaneWalkStep[] { - const walk: PaneWalkStep[] = [] - for (const section of PANE_SECTIONS) { - const rows = environmentSectionRows(input, section) - rows.forEach((row) => walk.push({ section, hover: row.hover, first: row.hover === 0 })) - } - return walk -} - -// What the pane holds: the next run's sandbox, whole. Seeded from the picked -// environment (environmentPane), then edited in place — and once edited it is -// what ships, so what you read here is what you get. -export interface EnvironmentPaneState { - repositories: readonly CustomRepository[] - variables: readonly CustomVariable[] - mcpServers: readonly CustomMcpServer[] - compute: CustomCompute - // Scripts keep their newlines here even though their rows are one line — the - // pane ships what it holds, so flattening for display must not reach the wire. - image: CustomImage - hooks: CustomHooks - rawHooks?: NonNullable>['hooks'] -} - -export const EMPTY_PANE: EnvironmentPaneState = { - repositories: [], - variables: [], - mcpServers: [], - compute: EMPTY_COMPUTE, - image: EMPTY_IMAGE, - hooks: EMPTY_HOOKS, -} - -// The pane with `repo` ("owner/name") checked, added only when absent — how -// the pane shows the checkout the entry point's base request adds to every -// environment (see withContextRepository). -export function paneWithRepository( - pane: EnvironmentPaneState, - repo: string | null, -): EnvironmentPaneState { - if (!repo || pane.repositories.some((r) => r.fullName === repo)) return pane - return { ...pane, repositories: [...pane.repositories, { fullName: repo, ref: null }] } -} - -// How an MCP server entry names itself, across the shapes the config admits: a -// bare string opts into a built-in, an object carries its name. -export function mcpServerName(server: unknown): string { - if (typeof server === 'string') return server - const name = (server as { name?: unknown })?.name - return typeof name === 'string' ? name : '' -} - -// An environment entry's repository as the connected list names it. A YAML entry -// may omit `owner` ("name: ellipsis"), and the same repository is then one row, -// not two — so a bare name resolves against the connected repositories. Only an -// unambiguous match counts: two owners with the same repo name would be a guess. -export function resolveRepoFullName( - fullName: string, - repoNames: readonly string[], -): string { - if (fullName.includes('/') || repoNames.includes(fullName)) return fullName - const matches = repoNames.filter((name) => name.slice(name.indexOf('/') + 1) === fullName) - return matches.length === 1 ? matches[0] : fullName -} - -// A saved environment's config as the pane's starting state. Every field the -// pane can show, resolved to the strings its rows edit; anything the config -// leaves unset stays blank, which reads as "whatever the server resolves". -function hookScript(value: string | { run: string } | null | undefined): string { - return typeof value === 'string' ? value : value?.run ?? '' -} - -export function environmentPane( - config: - | { - repositories?: readonly { owner?: string | null; name: string; ref?: string | null }[] - variables?: readonly { name: string; value?: string | null }[] - mcp_servers?: readonly unknown[] - compute?: { cpu?: number | null; memory?: unknown; timeout?: unknown } | null - hooks?: NonNullable>['hooks'] | null - } - | null - | undefined, - // The connected repositories, so an entry that omitted its owner lands on the - // row it belongs to instead of growing one of its own. - repoNames: readonly string[] = [], -): EnvironmentPaneState { - if (!config) return EMPTY_PANE - const compute = config.compute - return { - repositories: (config.repositories ?? []).map((r) => ({ - fullName: resolveRepoFullName(r.owner ? `${r.owner}/${r.name}` : r.name, repoNames), - ref: r.ref ?? null, - })), - variables: (config.variables ?? []).map((v) => ({ name: v.name, value: v.value ?? null })), - mcpServers: (config.mcp_servers ?? []) - .map((s) => ({ - name: mcpServerName(s), - command: typeof s === 'string' ? null : ((s as { command?: string }).command ?? null), - url: typeof s === 'string' ? null : ((s as { url?: string }).url ?? null), - raw: s, - })) - .filter((s) => s.name !== ''), - compute: { - cpu: compute?.cpu != null ? String(compute.cpu) : '', - memory: typeof compute?.memory === 'string' ? compute.memory : '', - timeout: typeof compute?.timeout === 'string' ? compute.timeout : '', - }, - ...(config.hooks ? { rawHooks: config.hooks } : {}), - image: { - build_base: hookScript(config.hooks?.build_base), - after_checkout: hookScript(config.hooks?.after_checkout), - }, - hooks: { - post_start: config.hooks?.post_start ?? '', - post_clone: config.hooks?.post_clone ?? '', - }, - } -} - -// Whether the pane still says what the environment it was seeded from says. The -// moment it doesn't, the run is "custom": no saved environment is checked and -// the pane ships in full. -export function paneEquals(a: EnvironmentPaneState, b: EnvironmentPaneState): boolean { - return JSON.stringify(paneKey(a)) === JSON.stringify(paneKey(b)) -} - -// The pane compared field by field, with the list orders normalized — a repo -// checked and unchecked again is the same sandbox even if it moved in the list. -function paneKey(p: EnvironmentPaneState): unknown { - return { - repositories: [...p.repositories] - .map((r) => `${r.fullName}@${r.ref ?? ''}`) - .sort(), - variables: [...p.variables].map((v) => `${v.name}=${v.value ?? ''}`).sort(), - mcpServers: [...p.mcpServers] - .map((s) => `${s.name}|${s.command ?? ''}|${s.url ?? ''}`) - .sort(), - compute: p.compute, - image: p.image, - hooks: p.hooks, - rawHooks: p.rawHooks, - } -} - -// A script flattened to the one line its row is: newlines and runs of space -// collapse. Display only — paneEquals and the wire see the raw value. -export function oneLine(s: string): string { - return s.replace(/\s+/g, ' ').trim() -} - -// How many lines of a script row print before the rest is elided. A Dockerfile -// or setup script is often long, and the pane sits above everything else in the -// launcher, so the row states its shape rather than its whole contents. -export const SCRIPT_ROW_LINES = 5 - -// A script as its row prints it: the lines, capped at SCRIPT_ROW_LINES with a -// count of what was left off, unless the row is open (then all of them). -// -// `truncated` is what the row appends — never silently dropped, since a hidden -// line is a hidden instruction to the sandbox. -export function scriptRowLines( - value: string, - expanded: boolean, -): { lines: string[]; hidden: number } { - const lines = value.split('\n') - if (expanded || lines.length <= SCRIPT_ROW_LINES) return { lines, hidden: 0 } - return { lines: lines.slice(0, SCRIPT_ROW_LINES), hidden: lines.length - SCRIPT_ROW_LINES } -} - -// How a variable reads in the custom section: the name alone when the sandbox -// resolves its value from stored secrets, otherwise the value as typed. The -// VARIABLES heading already says what these are, so a valueless row needs no -// note of its own. -export function variableRowLabel(name: string, value: string | null | undefined): string { - return value === undefined || value === null ? name : `${name}=${value}` -} - -// A variable the launcher's custom section adds on top of the picked -// environment. A null value means "resolve this name from the account's stored -// secrets", the same reading `agent variable set NAME` and the environment YAML -// give it. -export interface CustomVariable { - name: string - value: string | null -} - -// A repository the launcher's custom section adds on top of the picked -// environment, by full "owner/name". A null ref means the default branch, the -// same reading the environment YAML gives an entry with no `ref`. -export interface CustomRepository { - fullName: string - ref: string | null -} - -// How a checked repository's branch row reads: the typed ref, else the repo's -// default branch as the resting value. -export function repositoryRefLabel( - ref: string | null | undefined, - defaultBranch: string | null | undefined, -): string | null { - return ref ?? defaultBranch ?? null -} - -// The composer's picks, as the launcher reports them. `model` null = that row -// was never touched, so the server resolves it (the account's default model). -// -// `environment` is one of the three things POST /v1/sessions can be told — -// what you see is what you get, the request always names it: -// - `named`: a saved environment, by id, substituted wholesale. The pane still -// matches it, so re-stating its lists could only say them worse. -// - `empty`: `{}`, the bare sandbox. -// - `custom`: the pane, shipped whole as the environment object. Every list -// in it replaces the resolved one, so nothing the pane doesn't show can -// reach the run. -export type ComposerEnvironment = - | { kind: 'named'; id: string } - | { kind: 'empty' } - | { kind: 'custom'; pane: EnvironmentPaneState } - -export interface ComposerChoices { - harness?: 'claude_code' | 'codex' - environment: ComposerEnvironment - model: string | null -} - -// A variable in the shape an environment override takes: `value` omitted (not -// null) when the name resolves from stored secrets, since the config schema -// treats an absent value as the secret lookup. -function variableEntry(v: CustomVariable): { name: string; value?: string } { - return v.value === null ? { name: v.name } : { name: v.name, value: v.value } -} - -// A repository in the shape an environment override takes: owner and name -// split, `ref` omitted (not null) for the default branch, since the config -// schema treats an absent ref as "the repo's default". -function repositoryEntry(r: CustomRepository): { owner?: string; name: string; ref?: string } { - const slash = r.fullName.indexOf('/') - const entry: { owner?: string; name: string; ref?: string } = - slash === -1 - ? { name: r.fullName } - : { owner: r.fullName.slice(0, slash), name: r.fullName.slice(slash + 1) } - if (r.ref !== null) entry.ref = r.ref - return entry -} - -// The pane as the environment object POST /v1/sessions takes: the lists -// always (empty included — an absent list would read as "unset" where the -// pane means "none"), the scalar blocks only when a field is set. -export function paneEnvironment(pane: EnvironmentPaneState): Record { - const environment: Record = { - repositories: pane.repositories.map(repositoryEntry), - variables: pane.variables.map(variableEntry), - mcp_servers: pane.mcpServers.map(mcpServerEntry), - } - const compute = computeOverride(pane.compute) - if (Object.keys(compute).length > 0) environment.compute = compute - const hooks = { ...pane.rawHooks, ...fieldsOverride(pane.hooks) } - for (const field of IMAGE_FIELDS) { - const script = pane.image[field].trim() - const previous = pane.rawHooks?.[field] - if (script) hooks[field] = typeof previous === 'object' && previous !== null ? { ...previous, run: script } : script - else delete hooks[field] - } - for (const field of HOOK_FIELDS) { - if (!pane.hooks[field].trim()) delete hooks[field] - } - if (Object.keys(hooks).length > 0) environment.hooks = hooks - return environment -} - -// The entry point's base request with the launcher's picks layered on. The -// request body IS the session config, so the picks land as its own keys. -// -// The base's `repositories` (the checkout the CLI is standing in) rides along -// with a default, named or empty environment: the server adds it to whichever -// resolves. A custom pane already shows that repository as a checked row (or -// an unchecked one, which is the point), so the key is dropped and the pane -// alone says what is cloned. -export function applyComposerChoices( - base: StartAgentSessionRequest, - choices: ComposerChoices, -): StartAgentSessionRequest { - const req: StartAgentSessionRequest = { ...base } - const type = choices.harness ?? (req.codex ? 'codex' : 'claude_code') - const prompt = sessionPrompt(req) - if (choices.model || choices.harness) { - if (type === 'codex') { - const model = choices.model ?? req.codex?.model - if (!model) throw new Error('select a model for the Codex harness') - req.codex = { ...req.codex, ...(prompt != null ? { prompt } : {}), model } - delete req.claude_code - } else { - req.claude_code = { ...req.claude_code, ...(prompt != null ? { prompt } : {}), ...(choices.model ? { model: choices.model } : {}) } - delete req.codex - } - } - const env = choices.environment - if (env.kind === 'named') req.environment = env.id - else if (env.kind === 'empty') req.environment = {} as StartAgentSessionRequest['environment'] - else if (env.kind === 'custom') { - req.environment = paneEnvironment(env.pane) as StartAgentSessionRequest['environment'] - delete req.repositories - } - return req -} - -// "NAME=value" / "NAME" as typed into the custom section's name field, or an -// error to show in place. A bare name resolves from stored secrets (null -// value); an empty value after the equals is a real empty string, which is a -// legitimate thing to set a variable to. -export function parseVariableEntry(input: string): CustomVariable | { error: string } { - const text = input.trim() - if (!text) return { error: 'name a variable' } - const eq = text.indexOf('=') - const name = (eq === -1 ? text : text.slice(0, eq)).trim() - if (!name) return { error: 'name a variable' } - // The sandbox exports these into a shell, so the name has to be a legal - // shell identifier or the export silently does nothing. - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { - return { error: `"${name}" is not a valid variable name` } - } - return { name, value: eq === -1 ? null : text.slice(eq + 1) } -} - -// Where a synced environment's definition lives, for its option row: -// "owner/name/path/to/file.yaml @ sha1234". Only what the API already gave us — -// no source_details (an API-managed environment) means null, and the repo id -// resolves to a name only if the connected-repos list holds it. -export function environmentSourceLabel( - e: { - source_details?: { repo_id: number; path: string } | null - last_synced_commit_sha?: string | null - }, - repoNamesById: ReadonlyMap, -): string | null { - const src = e.source_details - if (!src) return null - const repo = repoNamesById.get(src.repo_id) - if (!repo) return null - const sha = e.last_synced_commit_sha ? ` @ ${e.last_synced_commit_sha.slice(0, 7)}` : '' - return `${repo}/${src.path}${sha}` -} - -// One preset in the launcher's ENVIRONMENT row: a saved environment, or the -// built-in empty sandbox. `note` is what the row under the presets says about -// the picked one — the file it syncs from. -export interface EnvironmentPreset { - id: string - name: string - note: string | null -} - -// The presets, in the order the row prints them: the saved environments in -// the order the API lists them, then `empty`. There is no organization -// default to lead with — the server has none, and the request names its -// environment outright. -export function environmentPresets( - environments: readonly { id: string; name: string }[], - sourceLabels: ReadonlyMap = new Map(), -): EnvironmentPreset[] { - return [ - ...environments.map((e) => ({ id: e.id, name: e.name, note: sourceLabels.get(e.id) ?? null })), - { id: EMPTY_ENVIRONMENT_ID, name: EMPTY_ENVIRONMENT_LABEL, note: null }, - ] -} - -// Which preset an untouched launcher stands on: the first saved environment, -// else `empty` — always the row at the head. Undefined while the list is -// still loading: no preset is checked and enter waits. -export function restingPresetIndex( - presets: readonly EnvironmentPreset[], - loaded: boolean, -): number | undefined { - return loaded && presets.length > 0 ? 0 : undefined -} - -// ------------------------------- layout --------------------------------- - -// Which slice of the session cells renders when the list overflows the -// nav (or a dropdown its pane): a window of `capacity` cells keeping -// `selected` in frame, preferring to fill from the start. -export function sidebarSlice( - count: number, - capacity: number, - selected: number, -): { start: number; end: number } { - if (count <= capacity) return { start: 0, end: count } - const cap = Math.max(1, capacity) - let start = Math.min(Math.max(0, selected - Math.floor(cap / 2)), count - cap) - if (selected < start) start = selected - return { start, end: start + cap } -} - -// Which slice of the session list the vertical nav shows: the highlight walks -// down to the second-to-last visible row and parks there while the list -// scrolls under it, so there is always one row of lookahead. Only the true -// end of the list lets the highlight sit on the last row. -export function navSlice( - count: number, - capacity: number, - selected: number, -): { start: number; end: number } { - if (count <= capacity) return { start: 0, end: count } - const cap = Math.max(2, capacity) - const start = Math.min(Math.max(0, selected - (cap - 2)), count - cap) - return { start, end: start + cap } -} diff --git a/src/lib/steps.ts b/src/lib/steps.ts index 49c97d6..91d7db6 100644 --- a/src/lib/steps.ts +++ b/src/lib/steps.ts @@ -44,9 +44,8 @@ export function deriveSandboxState( } } -// Record-rendering helpers shared by `session records` and `session connect` -// (moved out of commands/session.tsx so connect.ts can use them without an -// import cycle; session.tsx re-exports them for compatibility). +// Record-rendering helpers for `session record` and the `--watch` log +// (commands/session.ts re-exports them for compatibility). // A content block of a Claude Code stream event, typed loosely: the CLI only // extracts display text and names, never interprets the payload. diff --git a/src/lib/terminalBackground.ts b/src/lib/terminalBackground.ts deleted file mode 100644 index 018f705..0000000 --- a/src/lib/terminalBackground.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { applyThemeMode, type ThemeMode } from './theme' - -// Which palette to render: ask the terminal what its background is, the same -// way Claude Code's "auto" theme does. Order of trust: -// -// 1. OSC 11 — the terminal reports its actual background color. Supported -// by every mainstream emulator (iTerm2, Terminal.app, kitty, WezTerm, -// Ghostty, Windows Terminal, VS Code); tmux passes it through. -// 2. COLORFGBG — a legacy env hint ("15;0" = light-on-dark) set by rxvt and -// a few others. Stale after a mid-session theme change, but better than -// guessing. -// 3. dark — the brand's main mode and the safe default when nothing answers. -// -// Called once at each UI entry point, BEFORE the first Ink render, so no frame -// ever paints in the wrong palette and nothing needs to re-render on the -// answer. - -// OSC 11 reply: `\x1b]11;rgb:RRRR/GGGG/BBBB` terminated by BEL or ST, where -// each channel is 1-4 hex digits scaled to 16 bits. -const OSC_REPLY = /\x1b\]11;rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})/i - -function channelToUnit(hex: string): number { - return parseInt(hex, 16) / (16 ** hex.length - 1) -} - -export function modeFromOscReply(reply: string): ThemeMode | null { - const m = OSC_REPLY.exec(reply) - if (!m) return null - const [r, g, b] = [m[1]!, m[2]!, m[3]!].map(channelToUnit) as [number, number, number] - // Perceived luminance (Rec. 601). The cut is the midpoint: a background - // brighter than half is a light theme. - return 0.299 * r + 0.587 * g + 0.114 * b > 0.5 ? 'light' : 'dark' -} - -// COLORFGBG is "fg;bg" or "fg;default;bg"; the LAST field is the background's -// ANSI-16 index. 7 and 15 are the whites; everything else is dark or unknown. -export function modeFromColorFgBg(value: string | undefined): ThemeMode | null { - const bg = value?.trim().split(';').pop() - if (!bg || !/^\d+$/.test(bg)) return null - return bg === '7' || bg === '15' ? 'light' : 'dark' -} - -function queryOsc11(timeoutMs = 200): Promise { - return new Promise((resolve) => { - const { stdin, stdout } = process - if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== 'function') { - resolve(null) - return - } - const wasRaw = stdin.isRaw - let buffer = '' - let settled = false - const finish = (mode: ThemeMode | null): void => { - if (settled) return - settled = true - clearTimeout(timer) - stdin.off('data', onData) - stdin.setRawMode(wasRaw) - // Ink attaches its own stdin handling after this; leave the stream - // paused so a bare detection doesn't hold the process open. - stdin.pause() - resolve(mode) - } - const onData = (chunk: Buffer): void => { - buffer += chunk.toString('latin1') - const mode = modeFromOscReply(buffer) - if (mode) finish(mode) - else if (buffer.length > 64) finish(null) - } - const timer = setTimeout(() => finish(null), timeoutMs) - stdin.setRawMode(true) - stdin.resume() - stdin.on('data', onData) - stdout.write('\x1b]11;?\x1b\\') - }) -} - -// Detect and apply, once, at a UI entry point. Never throws — a terminal that -// answers strangely just keeps the dark default. -export async function applyDetectedThemeMode(): Promise { - let mode: ThemeMode | null = null - try { - mode = await queryOsc11() - } catch { - mode = null - } - mode ??= modeFromColorFgBg(process.env.COLORFGBG) - mode ??= 'dark' - applyThemeMode(mode) - return mode -} diff --git a/src/lib/theme.ts b/src/lib/theme.ts deleted file mode 100644 index ad24927..0000000 --- a/src/lib/theme.ts +++ /dev/null @@ -1,123 +0,0 @@ -import chalk from 'chalk' - -// The Ellipsis brand palette — the CLI's one source of color. -// -// These hexes are COPIES of brand/tokens.json in the ellipsis monorepo (the -// canonical source). This repo can't reach that file, so when a brand color -// changes there, it has to be re-copied here by hand. -// -// The CLI carries BOTH brand modes and picks one at startup by asking the -// terminal for its background (OSC 11, then COLORFGBG, then dark — see -// lib/terminalBackground.ts). Dark is the default because it is the brand's -// main mode and the safe guess when the terminal won't say. -// -// TEXT emphasis is carried by brightness, not by hue: brand ink #175173 scores -// 1.79:1 on a dark surface, so no copy is ever set in it — dark emphasis is bone -// against stone. The one thing that IS brand ink in both modes is `cursor`, and -// only because nothing it paints is read as text: a ▶, a left edge, a bar. Those -// are shapes you locate, not words, and a located shape survives a contrast a -// sentence would not. -// -// EXACTLY ONE SURFACE IS PAINTED: the composer's (`inputSurface` below). The CLI -// used to paint a canvas behind everything and lift panels onto it, which worked -// while the app owned every cell of a fixed frame. It no longer does — the -// transcript is printed into the terminal's own scrollback, where a row is never -// repainted, so a fill there outlives the frame that drew it and stale bands -// survive a resize or a shorter frame with nothing able to clean them up. -// -// The composer is the exception because it is the one region that is ALWAYS -// repainted and NEVER flushed: it lives in the live frame for the whole session, -// so its fill is redrawn on every frame and disappears with the app. Transcript -// rows, the header, and list highlights are all either printed or sized around -// printed rows, so they carry no fill. -// -// One rule survives from the canvas days, and it still matters: every glyph takes -// a colour from this file. Ink leaves a `` with no `color` prop on the -// terminal's DEFAULT foreground, so a hardcoded assumption either way breaks one -// theme; `dimColor` on its own is that bug plus an \x1b[2m, so secondary copy -// takes `muted`, never a bare `dimColor`. (dim is fine ON TOP of an explicit -// colour, where it only shades a known hue.) - -export interface Palette { - // Type. `foreground` is body copy and doubles as the accent; `muted` is - // every secondary string (meta, hints, timestamps) — and, since the rule - // above rules out a bare `dimColor`, it is also how a quiet line reads - // quiet, while staying legible on its canvas. - foreground: string - muted: string - - // The ▶ cursor, the composer's left edge, and the bar down your own messages — - // which, with no highlight bar to fall back on, is the ONLY thing that says - // "you are here". Brand ink in both modes: it is the accent the web apps use - // for exactly this (interactive, never status), and it is a hue no status owns - // (green = done, amber = working, red = failed). - cursor: string - - // Status. - success: string - error: string - // In-flight. brand/tokens.json has no dedicated "working" color; this is - // syntaxLiteral, the warm amber, which is the only brand hue that reads as - // activity without colliding with success green or error red. - active: string - - // Syntax, for rendered markdown in a transcript. Same values the web apps - // use for code blocks, so a snippet reads the same in the CLI as in the docs. - syntaxLiteral: string - syntaxString: string -} - -// brand/tokens.json `dark` values. muted is 7.4:1 on the brand charcoal. -export const darkPalette: Palette = { - foreground: '#f0efe9', - muted: '#a8a59c', - cursor: '#175173', - success: '#4ebc7b', - error: '#e5544b', - active: '#d9bd8d', - syntaxLiteral: '#d9bd8d', - syntaxString: '#c8c6bc', -} - -// brand/tokens.json `light` values. active mirrors dark by borrowing -// syntaxLiteral. -export const lightPalette: Palette = { - foreground: '#1c1b17', - muted: '#706f66', - cursor: '#175173', - success: '#10b981', - error: '#dc2626', - active: '#8a6d2a', - syntaxLiteral: '#8a6d2a', - syntaxString: '#56544b', -} - -// The live palette. MUTATED IN PLACE by applyThemeMode so every existing -// `theme.foreground` call site follows the mode with no plumbing; the mode is -// set once at startup, before the first render, and never after. -export const theme: Palette = { ...darkPalette } - -// The composer's fill — the app's ONE painted surface (see the note above for -// why it is the only one that can be). The brand panel step, neutralized: chalk -// sends any hex whose channels differ to the 6x6x6 colour cube, whose darkest -// step above black is rgb(95,95,95), so the authored warm dark #262523 paints -// as a MID GREY slab on a terminal that does 256 colours but not truecolor -// (Terminal.app, tmux without RGB, mosh, conhost). Equal channels route to the -// greyscale ramp instead, where a near-black stays near-black — and, in light -// mode, a near-white sand (brand border #e3e1d8) stays near-white. Truecolor -// terminals lose only the warmth, which is invisible at this brightness. -export let inputSurface = chalk.level >= 3 ? '#262523' : '#252525' - -export type ThemeMode = 'light' | 'dark' - -export function applyThemeMode(mode: ThemeMode): void { - Object.assign(theme, mode === 'light' ? lightPalette : darkPalette) - inputSurface = - mode === 'light' - ? chalk.level >= 3 - ? '#e3e1d8' - : '#e4e4e4' - : chalk.level >= 3 - ? '#262523' - : '#252525' -} diff --git a/src/lib/types.ts b/src/lib/types.ts index 42d897f..108987a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -7,12 +7,7 @@ // (query-option bags it assembles before calling, and the loosely-typed GitHub // user it caches on disk) are declared here. -import type { - components, - Ellipsis, - SessionRecord as SessionRecordFrame, - SessionMessage as SessionMessageFrame, -} from '@ellipsis-dev/sdk' +import type { components, Ellipsis, SessionRecord as SessionRecordFrame } from '@ellipsis-dev/sdk' type S = components['schemas'] @@ -21,18 +16,15 @@ type S = components['schemas'] export type AgentSession = S['Session'] export type AgentSessionSource = S['SessionSource'] export type AgentSessionStatus = S['SessionLifecycleStatus'] -export type SessionPrompting = S['SessionPrompting'] // The frames flavor, not `S['SessionRecord']`: the spec marks defaulted fields // optional, but on the wire the server always serializes every field, and the // SDK's transcript store types its inputs this way. Using it here keeps records // flowing from REST straight into the store without a cast at each call site. export type SessionRecord = SessionRecordFrame -export type SessionMessage = SessionMessageFrame export type ListSessionRecordsResponse = S['SessionRecordsListResponse'] export type ListAgentSessionsResponse = S['SessionsListResponse'] export type StartAgentSessionRequest = NonNullable[0]> export type SessionResponse = S['SessionResponse'] -export type SendSessionMessageRequest = S['SendSessionMessageRequest'] export type ImageAttachment = S['ImageAttachment'] export type SessionLogSegment = S['SessionLogSegment'] export type GetSessionLogResponse = S['GetSessionLogResponse'] @@ -67,9 +59,6 @@ export type ListAgentTemplatesResponse = S['AgentTemplatesListResponse'] // --------------------------------- models --------------------------------- -export type ModelManufacturer = S['ModelManufacturer'] -export type ModelRateCard = S['ModelRateCardApi'] -export type SupportedModel = S['Model'] export type GetSupportedModelsResponse = S['ModelsListResponse'] // -------------------------------- reviews --------------------------------- @@ -135,7 +124,6 @@ export type SlackIntegrationSummary = S['SlackIntegrationSummary'] export type LinearIntegrationSummary = S['LinearIntegrationSummary'] export type JiraIntegrationSummary = S['JiraIntegrationSummary'] export type SentryOrganizationSummary = S['SentryOrganizationSummary'] -export type RepositorySummary = S['GithubRepository'] export type GithubMemberSummary = S['GithubMember'] export type SlackMemberSummary = S['SlackMember'] export type SlackChannelSummary = S['SlackChannel'] @@ -179,27 +167,6 @@ export type CliAuthPollStatus = // generated methods' parameter objects; they exist so command modules can name // and pass around a query without importing the SDK's inline parameter types. -export interface ListAgentSessionsQuery { - // An automation id or name: only sessions that automation started. - agent?: string - source?: AgentSessionSource[] - days?: number - start?: string - end?: string - limit?: number - // A GitHub account id (`agent github members`); scopes the list to sessions - // attributed to that developer. The CLI resolves it from a --author login. - author_id?: number - // "owner/name" or a bare repository name. Sessions that name their - // repository only inside their automation — dashboard starts, cron runs, - // handoffs — do not match. - repo?: string - // Keep only the conversations still going (live or sleeping), dropping the - // ones that completed, errored, or were stopped. A session parked between - // turns counts as unfinished. - unfinished?: boolean -} - export interface ListFilesQuery { // Scope to one run's uploads. session_id?: string diff --git a/src/lib/urls.ts b/src/lib/urls.ts index 28a0916..3959d40 100644 --- a/src/lib/urls.ts +++ b/src/lib/urls.ts @@ -31,13 +31,3 @@ export function cliAuthUrl(appBase: string, userCode: string): string { export function appLoginUrl(appBase: string): string { return `${appBase}/login` } - -// Wrap `label` in an OSC 8 hyperlink so it opens `url` on click in terminals -// that support it (iTerm2, VS Code, WezTerm, kitty, GNOME); degrades to plain -// text elsewhere. Only emitted to a TTY — piped output stays free of escapes. -export function hyperlink(url: string, label: string, isTty = process.stdout.isTTY): string { - if (!isTty) return label - const OSC = ']8;;' - const ST = '\\' - return `${OSC}${url}${ST}${label}${OSC}${ST}` -} diff --git a/src/marked-terminal.d.ts b/src/marked-terminal.d.ts deleted file mode 100644 index 0d646a2..0000000 --- a/src/marked-terminal.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -// marked-terminal ships no types. The CLI uses one export, and marked's -// `use()` takes an opaque extension object, so the shape only needs to be -// nominal. -declare module 'marked-terminal' { - export function markedTerminal(options?: Record): unknown -} diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx deleted file mode 100644 index f67986b..0000000 --- a/src/ui/ConnectApp.tsx +++ /dev/null @@ -1,1584 +0,0 @@ -import { sessionPrompt } from '../lib/sessions' -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from 'react' -import { Box, Static, Text, useApp, useInput, useStdin, useStdout } from 'ink' -import { - streamSession, - sessionStatusWord, - StreamUnavailableError, - type OpenSocket, -} from '@ellipsis-dev/sdk/stream' -import { - awaitingAgentPhase, - collapseToolRuns, - deliveredUnechoedSends, - groupRecordsToChatTurns, - humanDuration, - lastLines, - pendingToolCalls, - recordSlice, - sandboxSummary, - statusActivityText, - type SandboxState, - type SessionTranscriptStore, -} from '@ellipsis-dev/sdk/store' -import { deriveSandboxState } from '../lib/steps' -import { chatTurnsToItems, foldRecordCosts, undisplayedRecordCount } from '../lib/chatItems' -import { errorDetail } from '../lib/api' -import type { Ellipsis, Session, SessionRecord } from '@ellipsis-dev/sdk' -import { hyperlink } from '../lib/urls' -import { usdNumberFromMillicents } from '../lib/output' -import { applyEditShortcut } from '../lib/editing' -import { readImageAttachment, withImagePlaceholders, type StagedImage } from '../lib/images' -import { CTRL_C_QUIT_HINT, useCtrlCQuit } from './ctrlC' -import { fitLines, visibleWidth } from '../lib/markdown' -import { SELECTION_GLYPH } from '../lib/sessions' -import { inputSurface, theme } from '../lib/theme' -import { - commandArgument, - completedText, - isCommandInput, - matchCommands, - resolveCommand, - type SlashCommand, -} from './commands' -import { MAX_IMAGES_PER_MESSAGE } from '../lib/images' -import { VERSION } from '../lib/constants' -import { - activityRows, - BRANCH_GLYPH, - BRANCH_TEXT_PAD, - contentWidth, - GUTTER_COLS, - itemRows, - layOutItems, - LIVE_GLYPH, - MESSAGE_PAD, - NEST_INDENT, - pendingMessageRows, - settledItemKeys, - settledRowCount, - spacerRow, - spanColor, - USER_BAR, - type RowSpan, - type TranscriptRow, -} from './transcriptRows' - -// The interactive `agent session connect` UI, modelled on Claude Code: a -// committed transcript that groups tool calls with their results and spaces -// messages apart — live activity (✻ Running/Generating) rendered on the -// transcript block it describes — above a footer with a composer that echoes -// what you send. Rendering shape lives in @ellipsis-dev/sdk/store (pure); this -// component owns the data flow, the composer, and the colours. -// -// ONE VIEW of the transcript: settled rows are handed to , which prints -// them ONCE into the terminal's own scrollback and never repaints them. That is -// what makes wheel/trackpad scrolling, select/copy and clickable links work -// natively — they are the terminal's, not reimplementations. The price is that a -// printed row is frozen: it cannot re-wrap or re-fold. Only the unsettled tail -// plus the footer repaint. -// -// There used to be a second, windowed view on the alternate screen (ctrl+r) that -// bought back repaintability — entry navigation, folding, app-read scrolling. It -// cost a scroll/anchor/selection machine bigger than the chat itself for a screen -// that duplicated what the terminal's own scrollback already shows, so it is -// gone. Long bodies stay clamped, and their full text is up in the scrollback. -// -// Data flow: ONE SessionTranscriptStore (pre-seeded by the caller with the -// stored records + session, so the first paint is instant) is fed by the -// SDK's streamSession — records arrive PUSHED as records_append frames, the -// session/messages frames carry status + the open inbox, and ephemeral -// `delta` frames overlay the in-progress response token-by-token. Everything -// on screen derives from the store snapshot; there is no REST refresh loop. -// If the stream is unavailable (old backend, blocked socket), a REST poll -// feeds the SAME store through synthetic frames, so the UI is identical -// either way. - -export interface ConnectAppProps { - api: Ellipsis - sessionId: string - // The one transcript store, pre-seeded with the fetched records + session. - store: SessionTranscriptStore - // The bearer-door socket factory (lib/stream.ts makeOpenSocket). - openSocket: OpenSocket - // Keyed, open sessions accept messages (show the composer); single-shot / - // closed / --no-input sessions follow read-only and exit when the stream ends. - canSend: boolean - // Records at or below this feed_seq are not RENDERED (--no-records skips - // replaying history on screen without re-streaming it). 0 renders everything. - minRenderFeedSeq: number - // The clickable dashboard link for this session - // (app.ellipsis.dev/{login}?session={id}), shown in the footer status line. - sessionUrl: string - // A one-line caveat shown as the app's opening notice (e.g. "watch-only: - // this conversation is closed"). null for the normal connect. - initialNotice?: string | null - // The session's model (backend tokens_model, fixed at creation), shown in - // the footer meta line. - model?: string | null - // The session's automation (resolved name, falling back to the config - // id), shown in the footer meta line when the session has one. - configName?: string | null - // Written (not read) by the app: set true when the app exits because the - // conversation closed, so the caller skips the "detached — still running" - // sign-off (the session is not still running). - exitState?: { closed: boolean } - // ---- hosting (the multi-session UI) ---- - // Whether the app owns the keyboard. The host keeps exactly one input handler - // active (session picker or chat); default true for the solo app. - focused?: boolean - // Hand focus to the host's session picker. Fired by esc, and by ↓ at the - // bottom edge (the composer's last line, or a watch-only follow). Absent in - // the solo app, which has no picker to open. - onFocusNav?: () => void - // Print a header above this session's first flushed row. Set by the host when - // this chat REPLACES another session's chat: both print into the same - // scrollback, one under the other, so without a break the second reads as a - // continuation of the first. Absent for the first chat of the process, which - // has nothing above it. - scrollbackBreak?: boolean - // Terminal outcomes, reported to the host INSTEAD of exiting the app: - // 'closed' = the conversation closed; 'preflight' = the session died - // before it became connectable; 'ended' = a watch-only stream finished. - // Absent in the solo app, which exits the Ink render instead. - onDone?: (reason: 'closed' | 'preflight' | 'ended') => void -} - -// Surface statuses in which something is actively happening — drives the -// spinner. `waiting` (turn done, warm, your move) and `sleeping` (parked) are -// deliberately NOT here: the spinner stops and the footer reads the calm state -// instead of a misleading "running". -function isWorkingStatus(status: string): boolean { - return ['scheduled', 'starting', 'working', 'retrying'].includes(status) -} - -// Text rows inside the composer panel, before its 1-cell pad above and below. -// One row: the input grows as you type past it, and the rows it isn't using -// belong to the conversation. -const COMPOSER_INTERIOR_ROWS = 1 - -// Horizontal breathing room inside the composer panel. With the panel inset by -// MESSAGE_PAD and its accent bar taking one column, this lands the composer's -// text in the same column as a transcript message's text. -const COMPOSER_PAD_X = 1 - -// The startup block's entry key — it is one block, not a transcript item, so it -// owns a fixed key rather than a feed_seq one. -const SANDBOX_KEY = 'sandbox' - -// Gap between the footer's status fields — whitespace, no glyph. -const META_SEP = ' ' - -// Half the period of the live ⏺ pulse: the glyph dims for this long, then -// brightens for this long. ~1.4s a cycle — slow enough to read as breathing -// rather than flashing, and it lands off the 1s duration tick so the two -// don't visibly beat against each other. -const PULSE_MS = 700 - -// Columns the composer's text actually gets: the pane's message pad on both -// sides, the panel's horizontal pad on both sides, then the accent bar down its -// left edge. -function composerTextCols(cols: number): number { - return Math.max(8, cols - MESSAGE_PAD * 2 - COMPOSER_PAD_X * 2 - 1) -} - -// One local send awaiting server acknowledgement: messageId is null while the -// POST is in flight, then the created SessionMessage's id (protocol v2 §4.2) — -// the chip retires the moment the store acknowledges that id (a messages -// frame, or the transcript user-echo record's session_message_id -// back-reference), and the server's own pending row takes over. -type QueuedSend = { text: string; messageId: string | null } - -export function ConnectApp(props: ConnectAppProps): React.ReactElement { - const { api, sessionId, store, openSocket, canSend } = props - const { exit } = useApp() - const { isRawModeSupported } = useStdin() - const { stdout } = useStdout() - - // Terminal size, tracked across resizes. It bounds the LIVE region — the - // unsettled tail, the composer and the meta line — rather than sizing a window - // the whole conversation has to fit in: the settled transcript above has been - // handed to the terminal, which is what makes the wheel work, and it is the - // terminal's business how tall that is now. - const [termRows, setTermRows] = useState(stdout?.rows ?? 24) - const [termCols, setTermCols] = useState(stdout?.columns ?? 80) - useEffect(() => { - if (!stdout) return - const onResize = (): void => { - setTermRows(stdout.rows) - setTermCols(stdout.columns) - } - stdout.on('resize', onResize) - return () => { - stdout.off('resize', onResize) - } - }, [stdout]) - - // The app always owns the terminal now (there is no pane host left), so the - // resting view is SCROLLBACK: the settled transcript is flushed ONCE into the - // terminal's own scrollback () and only the live tail + composer - // repaint. Wheel/trackpad scrolling, select/copy and clickable links are then - // the terminal's own, which is the whole point. - const rows = termRows - const cols = termCols - const focused = props.focused ?? true - // A row left for the shell cursor and the caller's sign-off line. - const bottomSlack = 1 - - // Terminal outcomes: reported to the host when there is one (the app stays - // mounted; the host decides what to show), otherwise exit the Ink render. - // The callback rides a ref so `finish` (and everything memoized on it, the - // stream pump above all) stays stable even when the host passes a fresh - // closure every render — a changing pump identity would abort the socket. - const onDoneRef = useRef(props.onDone) - onDoneRef.current = props.onDone - const hasHost = props.onDone != null - const finish = useCallback( - (reason: 'closed' | 'preflight' | 'ended'): void => { - if (onDoneRef.current) onDoneRef.current(reason) - else exit() - }, - [exit], - ) - - // The store snapshot is the single source of truth for everything streamed. - const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot) - const statusWord = snapshot.session ? sessionStatusWord(snapshot.session) : 'starting' - // Bridge the gap between a send and the server's status flip: treat the - // session as working until the next status transition lands. - const [sendPending, setSendPending] = useState(false) - useEffect(() => { - setSendPending(false) - }, [statusWord]) - const working = isWorkingStatus(statusWord) || sendPending - - const [elapsed, setElapsed] = useState(0) - const [notice, setNotice] = useState(props.initialNotice ?? null) - // How many undisplayable records you've already been told about, so the - // footer warning clears on your next send and only returns if MORE arrive. - const [undisplayedSeen, setUndisplayedSeen] = useState(0) - // The slash-command menu's highlighted index. The menu itself is derived from - // what is typed (see `menu` below) rather than stored — it is open exactly when - // the line starts with `/` and something still matches — so this is the only - // state it needs, and it resets to the top whenever the matches change. - const [menuIndex, setMenuIndex] = useState(0) - // The composer's text and caret position (0..text.length), one state so - // rapid keypresses between renders can't desync them. Left/right move the - // caret, up/down walk the lines of a multi-line input like a normal text - // editor. - const [composer, setComposer] = useState({ text: '', cursor: 0 }) - // Local ✦ lines in the transcript, for things the CLIENT did that the record - // log will never carry: so far, /stop. It belongs in the conversation because - // it is an event in the conversation — the notice bar above the composer is - // for transient status, and scrolls away with nothing to show you asked. - const [chatNotes, setChatNotes] = useState([]) - // Messages you've sent that the server hasn't acknowledged yet — shown - // IMMEDIATELY as dim rows at the bottom of the transcript, so a send always - // appears in the chat the moment you hit enter. From the first - // acknowledgement on (a messages frame or the user-echo record carrying the - // id), the server's own pending rows (serverQueued) are the queued truth, - // and once the agent consumes the message its echo record lands as the - // real (full-colour) transcript row. - const [queued, setQueued] = useState([]) - // Images attached with /image, waiting for the next send — the Claude Code - // paste model, minus the paste (a terminal cannot carry image bytes, so the - // file path stands in). Sent inline on that message and cleared with it. - const [staged, setStaged] = useState([]) - - // Whether the sandbox ever reached a connectable state, so a terminal status - // *before* that (a preflight/budget gate) is reported as a failure, not idle. - const everRunning = useRef(isWorkingStatus(statusWord) && statusWord !== 'scheduled') - useEffect(() => { - if (['working', 'waiting'].includes(statusWord)) everRunning.current = true - }, [statusWord]) - - const streaming = useRef(false) - const polling = useRef(false) - const abort = useRef(new AbortController()) - // Guard so the closed-conversation teardown runs once no matter which signal - // lands first (session frame, poll, stream outcome). - const closingDown = useRef(false) - - // The committed transcript: the SDK's shared ChatTurn grouping (the same - // turns the dashboard's chat renders — tool calls paired with results, - // failed turns marked), mapped onto the terminal's item vocabulary. - // store.chatTurns() memoizes on the records array identity; --no-records - // (minRenderFeedSeq > 0) groups a filtered slice instead, since turns don't - // carry feed_seq. - // The sandbox story is excluded: it renders as the one-line progress block - // up top, not as transcript rows. Each turn's closing duration/cost summary - // is dropped too — the footer carries the session's spend. - const { items, undisplayed } = useMemo(() => { - const turns = - props.minRenderFeedSeq > 0 - ? groupRecordsToChatTurns( - snapshot.records.filter((r) => r.feed_seq > props.minRenderFeedSeq), - ) - : store.chatTurns() - const items = chatTurnsToItems(turns) - // Client-side notes land at the end: they describe what you just did, so - // they belong under everything the server has sent so far. - for (const note of chatNotes) { - items.push({ key: note.key, kind: 'notice', text: note.text, spaceBefore: true }) - } - return { - items, - undisplayed: undisplayedRecordCount(snapshot.records, props.minRenderFeedSeq), - } - }, [snapshot.records, props.minRenderFeedSeq, chatNotes, store]) - - // Footer spend: the server's ledger total (the session frame's four cost - // columns — the billing authority, resent on every cost tick) with the - // CC-result fold as the last-turn readout and older-backend fallback (§6: - // record folding is display-only). - const cost = useMemo( - () => foldRecordCosts(snapshot.records), - [snapshot.records], - ) - const serverCostUsd = snapshot.session - ? usdNumberFromMillicents(snapshot.session.cost?.total ?? 0) - : null - - // The sandbox startup timeline, derived from the lifecycle records of the - // latest start (a session_starting wake/retry or sandbox_starting record - // resets it, so a wake tells a fresh story): a live headline, plus ONE FLAT - // LOG of every milestone and every line of build/setup output, newest last. - // While the session comes up the block shows the tail of that log; once ready - // it collapses to a static one-line summary of the start, and → while - // highlighted re-opens the log to re-read it. The block persists after startup - // as the durable trace (the sandbox_ready transcript notice is suppressed - // below in its favour). - const sandbox = useMemo( - () => deriveSandboxState(recordSlice(snapshot.records), props.minRenderFeedSeq), - [snapshot.records, props.minRenderFeedSeq], - ) - // Bodies of the server's PENDING inbox messages — the durable queued signal. - const serverQueued = useMemo( - () => snapshot.messages.filter((m) => m.status === 'pending').map((m) => m.body), - [snapshot.messages], - ) - - // Retire local chips the store has acknowledged (by SessionMessage id): once - // an id shows up in a messages frame or a transcript user-echo record, the - // server's own rows are the truth for that send. - useEffect(() => { - setQueued((prev) => { - const remaining = prev.filter( - (q) => q.messageId === null || !snapshot.acknowledgedMessageIds.has(q.messageId), - ) - return remaining.length === prev.length ? prev : remaining - }) - }, [snapshot.acknowledgedMessageIds]) - - // A closed conversation is over — nothing can ever be sent or received - // again (a send would 409) — so leave one dim notice as the sign-off and - // exit instead of sitting at the composer. The server flushes the final - // records before the closing session frame, so there is nothing to fetch. - const finishClosed = useCallback((): void => { - if (closingDown.current) return - closingDown.current = true - if (props.exitState) props.exitState.closed = true - setNotice('conversation closed') - finish('closed') - }, [finish, props.exitState]) - useEffect(() => { - if (statusWord === 'closed') finishClosed() - }, [statusWord, finishClosed]) - - // REST fallback when the stream is unavailable: poll the records + session - // and feed the SAME store through synthetic frames (the REST rows are the - // same wire shapes) — the cursor dedupes, the UI can't tell the difference. - const startPollFallback = useCallback((): void => { - if (polling.current || abort.current.signal.aborted) return - polling.current = true - const tick = async (): Promise => { - try { - // No cursor: the store drops records at or below its own feed_seq, - // so a full re-read is deduped rather than re-rendered. - const [page, { session }] = await Promise.all([ - api.sessions.records(sessionId).then((p) => p.response), - api.sessions.get(sessionId), - ]) - if (page.records.length) { - // Inbox state (message_received/delivered/requeued) rides the record - // feed: the store folds it as records land. REST and frame types - // differ in optional defaults but carry the same serialized values. - store.ingest({ type: 'records_append', records: page.records as SessionRecord[] }) - } - store.ingest({ type: 'session', session: session as Session }) - } catch { - // Transient fetch failure — the next tick retries. - } - } - void tick() - const timer = setInterval(() => void tick(), 3000) - abort.current.signal.addEventListener('abort', () => clearInterval(timer), { - once: true, - }) - }, [api, sessionId, store]) - - // Keep the socket attached across reconnects/resume. A keyed session going - // terminal is not the end of the conversation — it idles between turns — so - // we report idle and re-attach on the next send. Watch-only sessions exit - // when the stream ends. - const pump = useCallback((): void => { - if (streaming.current || abort.current.signal.aborted) return - streaming.current = true - streamSession({ - sessionId, - openSocket, - afterSeq: store.cursor, - onFrame: store.ingest, - signal: abort.current.signal, - }) - .then((outcome) => { - if (abort.current.signal.aborted) return - setSendPending(false) - if (outcome.type === 'error') setNotice(`stream error: ${outcome.message}`) - // A terminal failure before the sandbox ever ran is a preflight/budget - // gate — there's no conversation to attend, so report it and exit. - if ( - outcome.type === 'done' && - !everRunning.current && - ['failed', 'cancelled', 'stopped'].includes(outcome.status) - ) { - setNotice(`session ${outcome.status} before it became connectable`) - if (!hasHost) process.exitCode = 1 - finish('preflight') - return - } - // The warm loop can end by closing the conversation (a closing event's - // final turn, or an ephemeral session finishing): that's terminal, not - // an idle nap — tear down instead of inviting a doomed send. - if (outcome.type === 'done' && outcome.status === 'closed') { - finishClosed() - return - } - if (canSend) { - if (outcome.type === 'done') setNotice('agent idle — send a message to wake it') - } else { - finish('ended') - } - }) - .catch((err: unknown) => { - if (abort.current.signal.aborted) return - if (err instanceof StreamUnavailableError) { - // No socket (old backend, blocked port): fall back to REST polling, - // silently — the store keeps filling, so the fallback is invisible. - startPollFallback() - return - } - setNotice(`stream error: ${(err as Error).message}`) - if (!canSend) finish('ended') - }) - .finally(() => { - streaming.current = false - }) - }, [canSend, finish, finishClosed, hasHost, openSocket, sessionId, startPollFallback, store]) - - useEffect(() => { - pump() - const controller = abort.current - return () => controller.abort() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pump]) - - // Tick an elapsed-seconds counter while the agent works — a steady progress - // read alongside the live token counter, and the sole liveness cue during a - // long tool call (which streams no assistant deltas). Reset each turn. - useEffect(() => { - if (!working) return - setElapsed(0) - const t = setInterval(() => setElapsed((e) => e + 1), 1000) - return () => clearInterval(t) - }, [working]) - - // The tool calls executing right now (an unmatched tool_use in the committed - // transcript — see pendingToolCalls), with a per-burst seconds ticker so a - // long Bash call reads "Running Bash(pytest…)… (34s)" instead of dead air. - const pendingTools = useMemo(() => pendingToolCalls(items), [items]) - // Which silence a started-but-quiet turn is in ('boot': Claude Code is - // still starting in the sandbox; 'turn': the warm agent between records), - // for the fallback live line's label. - const awaitingAgent = useMemo(() => awaitingAgentPhase(recordSlice(snapshot.records)), [snapshot.records]) - - // Sends the agent took mid-gap: delivered to the agent but its user-echo - // transcript record hasn't landed yet (the echo can lag by a whole sandbox - // wake). Rendered as full-colour user rows until the echo replaces them. - const acceptedSends = useMemo( - () => deliveredUnechoedSends(recordSlice(snapshot.records)), - [snapshot.records], - ) - - // Every in-flight send, oldest pipeline stage last, at the transcript's - // bottom edge: 'accepted' (delivered, awaiting its echo record — full - // colour), 'queued' (the server's pending inbox — dim), 'sending' (the - // POST is in flight — dim), 'cancelled' (taken by a turn that died without - // answering it — see deliveredUnechoedSends). Local chips are multiset- - // subtracted by text so a send never renders twice during the - // received-record handoff window. - const inFlightSends = useMemo(() => { - const counts = new Map() - for (const m of serverQueued) counts.set(m, (counts.get(m) ?? 0) + 1) - const extras: string[] = [] - for (const q of queued) { - const n = counts.get(q.text) ?? 0 - if (n > 0) counts.set(q.text, n - 1) - else extras.push(q.text) - } - return [ - ...acceptedSends.map((m) => ({ - key: m.id, - text: m.body, - state: m.cancelled ? ('cancelled' as const) : ('accepted' as const), - })), - ...serverQueued.map((text, i) => ({ key: `sq${i}`, text, state: 'queued' as const })), - ...extras.map((text, i) => ({ key: `lq${i}`, text, state: 'sending' as const })), - ] - }, [acceptedSends, serverQueued, queued]) - - // The session's opening prompt, shown as a queued row while the sandbox comes - // up. A prompt given at creation is NOT an inbox message yet — the worker - // inserts it as turn 0's message once Claude Code is running in the sandbox, - // which can be minutes later — so without this the chat sits empty and the - // message you just sent is nowhere on screen. - // - // It retires on the first message_received record: from there the inbox rows - // (queued → delivered → the echo) are the truth for the same text, so the two - // never both render. That record is also what keeps an OLD session's original - // prompt out of the chat — its turn-0 message_received is in the feed, even - // when --no-records hides the transcript itself. - const pendingPrompt = useMemo(() => { - if (items.length > 0) return null - if (snapshot.records.some((r) => r.record_type === 'message_received')) return null - const prompt = snapshot.session ? sessionPrompt(snapshot.session) : undefined - return typeof prompt === 'string' && prompt.trim() ? prompt : null - }, [items.length, snapshot.records, snapshot.session]) - - // Whether a send is waiting on the agent — a queued row breathes while it - // waits, like a running tool does. - const sendsWaiting = - pendingPrompt !== null || - inFlightSends.some((q) => q.state === 'queued' || q.state === 'sending') - - // The heartbeat behind every live ⏺ mark: one timer for the whole app, so - // each pulsing glyph breathes in step instead of drifting out of phase. It - // runs only while something is actually in flight — a still ⏺ on a settled - // transcript would be a lie, and an idle interval would wake the render loop - // for nothing. Reset on the way in so a new turn starts bright. - const [pulseOn, setPulseOn] = useState(true) - const pulsing = working || sendsWaiting - useEffect(() => { - if (!pulsing) { - setPulseOn(true) - return - } - const t = setInterval(() => setPulseOn((on) => !on), PULSE_MS) - return () => clearInterval(t) - }, [pulsing]) - const [toolElapsed, setToolElapsed] = useState(0) - const pendingToolKey = pendingTools.length > 0 ? pendingTools[0].key : null - useEffect(() => { - if (pendingToolKey == null) return - setToolElapsed(0) - const t = setInterval(() => setToolElapsed((e) => e + 1), 1000) - return () => clearInterval(t) - }, [pendingToolKey]) - - const submit = useCallback( - (raw: string): void => { - const text = raw.trim() - setComposer({ text: '', cursor: 0 }) - // An empty line sends nothing — unless pictures are waiting, in which - // case the message is the pictures (an image-only paste). - if (!text && staged.length === 0) return - setUndisplayedSeen(undisplayed) - // A leading slash claims the line for the CLI. An unknown one is REFUSED, - // not forwarded: a typo'd command sent on as prose is a message you did - // not mean to send, and the agent cannot tell it from one you did. - if (isCommandInput(text)) { - const command = resolveCommand(text) - if (!command) { - setNotice(`✗ no such command: ${text.split(/\s/)[0]}, type / to see them`) - return - } - if (command.id === 'exit') { - exit() - return - } - if (command.id === 'sessions') { - if (props.onFocusNav) props.onFocusNav() - else setNotice('no other sessions here, this is a single-session connect') - return - } - if (command.id === 'image') { - const path = commandArgument(text) - if (!path) { - setNotice('usage: /image — attaches the file to your next message') - return - } - if (staged.length >= MAX_IMAGES_PER_MESSAGE) { - setNotice(`✗ at most ${MAX_IMAGES_PER_MESSAGE} images per message`) - return - } - try { - const image = readImageAttachment(path) - const next = [...staged, image] - setStaged(next) - setNotice( - `${next.map((s, i) => `[Image #${i + 1}] ${s.name}`).join(', ')} — sends with your next message`, - ) - } catch (err) { - setNotice(`✗ ${err instanceof Error ? err.message : String(err)}`) - } - return - } - } - // /stop is the one command that talks to the server, so it rides the async - // path below with the sends. - const stopping = resolveCommand(text)?.id === 'stop' - void (async () => { - try { - if (stopping) { - const { session: s } = await api.sessions.stop(sessionId) - setNotice(null) - setChatNotes((prev) => [ - ...prev, - { - key: `note${prev.length}`, - text: `Stopped the agent (${s.lifecycle.status}). The conversation is saved. Send a message to pick it back up.`, - }, - ]) - return - } - // Show the message as queued, then post it. The POST returns the - // created SessionMessage (protocol v2 §4.2): stamp the chip with its - // id so the first messages frame / user-echo record carrying that id - // retires it in favour of the server's own row. The chip reads as - // the server stores it — the text, then one `[Image #N]` placeholder - // per attached image — so it reconciles with that row by text. - const images = staged.map((s) => s.attachment) - const shown = withImagePlaceholders(text, images.length) - setStaged([]) - setQueued((prev) => [...prev, { text: shown, messageId: null }]) - setNotice(null) - const { message: created } = await api.sessions.sendMessage(sessionId, { - message: text, - images, - }) - setQueued((prev) => { - let stamped = false - return prev.map((q) => { - if (!stamped && q.text === shown && q.messageId === null) { - stamped = true - return { text: q.text, messageId: created.id } - } - return q - }) - }) - setSendPending(true) - pump() - } catch (err) { - setQueued((prev) => { - const j = prev.findIndex((q) => q.messageId === null) - return j < 0 ? prev : [...prev.slice(0, j), ...prev.slice(j + 1)] - }) - setNotice(`✗ ${errorDetail(err)}`) - } - })() - }, - [api, exit, pump, sessionId, staged, undisplayed, props.onFocusNav], - ) - - // The composer renders whenever sending is possible. - const composerVisible = canSend && isRawModeSupported - const inputActive = composerVisible && focused - - // The slash-command menu: the commands the typed line still matches. Derived, - // not stored, so it cannot get out of step with the input — it is open exactly - // while the line starts with `/` and something matches, and typing a character - // that matches nothing closes it (the line is then just prose, and submit will - // say so). - const menu = useMemo( - () => (composerVisible ? matchCommands(composer.text) : []), - [composerVisible, composer.text], - ) - const menuOpen = menu.length > 0 - // Clamped rather than stored-and-corrected: the matches shrink as you type, and - // an index left pointing past the end would highlight nothing. - const menuAt = Math.min(menuIndex, Math.max(0, menu.length - 1)) - // Back to the top whenever the match set changes, so the highlight is on the - // best match for what you have typed rather than wherever it was left. - useEffect(() => { - setMenuIndex(0) - }, [menu.length]) - const complete = useCallback((command: SlashCommand): void => { - const text = completedText(command) - setComposer({ text, cursor: text.length }) - }, []) - - // The rendered transcript lines, in order: consecutive tool activity folds - // into "Ran N …" notices, so a turn's work reads as one line instead of a - // wall of calls. - const visible = useMemo(() => { - const pendingKeys = new Set(pendingTools.map((t) => t.key)) - const base = pendingKeys.size ? items.filter((i) => !pendingKeys.has(i.key)) : items - return collapseToolRuns(base) - }, [items, pendingTools]) - - const infraActivity = statusActivityText(statusWord) - // The startup story has settled: the start finished and nothing is live. This - // is when the block collapses to its static one-line summary. - const sandboxSettled = (sandbox?.done ?? false) && !infraActivity - // How the pane's rows are divided: the footer (notice + composer + meta - // line) is fixed, the chat window takes the rest, and the top padding is the - // give — it shrinks, to nothing if it must, so the frame always fits. - // - // Fitting is not cosmetic: an over-tall frame scrolls ink's render region and - // smears stale rows up the terminal. So the window's budget is whatever is - // left AFTER the footer, never a floor that could exceed the pane. - // ctrl+c interrupts the turn, then quits: the first press sends the same - // /stop the composer's command does, the second exits. Active whenever this - // pane owns the keyboard, watch-only follows included (nothing to stop there, - // but ctrl+c still has to be the way out). - const ctrlCArmed = useCtrlCQuit( - isRawModeSupported && focused && (composerVisible || !hasHost), - () => { - if (working && canSend) submit('/stop') - }, - ) - // Armed, the meta line below the composer becomes the ctrl+c prompt (it - // says what a second press does), and the arm expires after a few seconds. - const shownNotice = notice - const { viewBudget, composerRows, noticeRows, menuRows } = useMemo(() => { - // Both wrapping parts of the footer are measured as the rows they will - // actually OCCUPY, not as the newlines they contain: a notice ("stream - // error: …") and a typed paragraph both wrap, and counting either as one - // row means the footer quietly outgrows the space reserved for it. - const fixed = 1 /* meta line */ + 1 /* footer margin */ - // What the wrapping parts share. Each takes what it needs and yields the - // rest, in priority order: the chat window always keeps a row, then the - // composer, and the notice gives up its extra rows first (it truncates — - // the important half of "stream error: …" is the front). - let free = rows - bottomSlack - fixed - // A pane with no room for chat + composer + notice drops the notice - // entirely: overflowing the frame would smear the whole app. - const noticeRows = shownNotice - ? Math.max(0, Math.min(fitLines(shownNotice, cols).length, free - 2)) - : 0 - free -= noticeRows - // The command menu, one row per match, budgeted like everything else: it - // renders INSIDE the frame, so rows it takes are rows the chat yields. In a - // terminal with no room for it the list is cut short rather than pushing the - // composer off the bottom. - const menuRows = Math.max(0, Math.min(menu.length, free - 3)) - free -= menuRows - // The composer: its interior grows with the input, plus the 1-cell pad above - // and below. In a terminal too short for all of it the pad goes, then the - // interior shrinks toward a single row. - const typedRows = fitLines(composer.text, composerTextCols(cols)).length - const wanted = Math.max(COMPOSER_INTERIOR_ROWS, typedRows) + 2 - const composerRows = composerVisible ? Math.max(1, Math.min(wanted, free - 1)) : 0 - return { - viewBudget: Math.max(1, free - composerRows), - composerRows, - noticeRows, - menuRows, - } - }, [rows, cols, bottomSlack, composerVisible, composer.text, shownNotice, menu.length]) - - // The live tail: the in-progress response and the one activity line under - // it. Three distinct, factual signals — never whimsy — each rendered on the - // block it describes, not above the composer: - // - `generating`: the model is streaming tokens (delta frames flowing) — - // the ✻ line under the streamed prose, with elapsed + token count. - // - a running tool: a committed tool call awaits its result — a ✻ line - // attached to the burst it belongs to ("Ran 2 shell commands" then the - // live third), naming the tool and ticking its own timer. `hug` drops the - // spacer so it reads as part of that burst. - // - the fallback: a turn is in flight but nothing else says so — the - // harness-boot dead air (~15-20s before Claude Code's first event) and a - // running turn's between-records lull. Without it a send looks like the - // app hung. Gated on the TURN, not the session status: a bare interactive - // session reads 'working' while it waits for your first message, and - // narrating that would claim work that isn't happening. - // (`infraActivity` is the fourth signal, and lives in the startup block at - // the top, where a startup message belongs.) - const liveTail = useMemo(() => { - const liveText = snapshot.liveText - const liveTokens = snapshot.liveOutputTokens - const generating = statusWord === 'working' && (liveText !== '' || liveTokens != null) - const runningTool = statusWord === 'working' && !generating && pendingTools.length > 0 - // Whether the activity line hugs the block above it: when the trailing fold - // ("Ran N …", key grp:*) is the same burst the pending call belongs to. - const last = visible[visible.length - 1] - const hug = - last != null && - (last.key.startsWith('grp:') || last.kind === 'tool' || last.kind === 'tool_result') - if (generating) { - return { - text: liveText, - label: 'Generating…', - tick: 'elapsed' as const, - suffix: liveTokens != null ? `${formatTokens(liveTokens)} tokens` : '', - // The ⏺ line sits directly under the prose it describes. - hug: liveText !== '', - nested: false, - } - } - if (runningTool) { - const label = - pendingTools.length === 1 - ? `Running ${pendingTools[0].text}${pendingTools[0].detail ?? ''}…` - : `Running ${pendingTools.length} tool calls (${[...new Set(pendingTools.map((t) => t.text))].join(', ')})…` - // A running tool call sits flat, exactly where its ⎿ result will land: - // every row stands on its own (layOutItems), so the live line never - // jumps a level when the result arrives. - return { - text: '', - label, - tick: 'tool' as const, - suffix: '', - hug, - nested: false, - } - } - if (working && !infraActivity && (awaitingAgent !== null || sendPending)) { - return { - text: '', - label: `${awaitingAgent === 'boot' ? 'Starting the agent' : 'Working'}…`, - tick: 'elapsed' as const, - suffix: '', - hug: false, - nested: false, - } - } - return { text: '', label: '', tick: 'elapsed' as const, suffix: '', hug: false, nested: false } - }, [ - snapshot.liveText, - snapshot.liveOutputTokens, - statusWord, - pendingTools, - visible, - working, - infraActivity, - awaitingAgent, - sendPending, - ]) - - // EVERY row the chat window can show, top to bottom: the startup block, the - // committed transcript, then the tail that only exists while a turn is live - // (accepted sends, the streaming response, the activity line, queued sends). - // The tail rides in the same list rather than rendering below the window, so - // it can't overflow the frame. - const allRows = useMemo(() => { - const out: TranscriptRow[] = [] - if (infraActivity || sandbox) { - out.push(...sandboxRows({ sandbox, infraActivity, settled: sandboxSettled, cols })) - } - // Tool activity is nested under the message that produced it (layOutItems - // decides what hangs off what), so a call and its result read as work the - // agent did mid-message rather than as turns of their own. - for (const placed of layOutItems(visible)) { - out.push( - ...itemRows(placed.item, cols, { - indent: placed.nested ? NEST_INDENT : 0, - nested: placed.nested, - attach: placed.attach, - }), - ) - } - // Sends the agent has TAKEN (delivered, echo record still in flight): - // full-colour barred rows ABOVE the live activity — the running turn is the - // response to THIS message, so its stream belongs below it. - for (const q of inFlightSends.filter((q) => q.state === 'accepted')) { - out.push( - ...pendingMessageRows(q.key, q.text, cols, { - gutter: USER_BAR, - gutterColor: theme.cursor, - bold: true, - }), - ) - } - if (liveTail.text) { - out.push(...pendingMessageRows('live', liveTail.text, cols, { gutter: '' })) - } - if (liveTail.label) { - out.push( - ...activityRows( - 'live:act', - liveTail.label, - liveTail.tick, - liveTail.suffix, - cols, - liveTail.hug, - liveTail.nested, - ), - ) - } - // The session's opening prompt while it is still only a start request: the - // same queued row a mid-session send gets, so the message you sent is on - // screen from the first frame. - if (pendingPrompt) { - out.push( - ...pendingMessageRows('prompt', pendingPrompt, cols, { - gutter: USER_BAR, - gutterColor: theme.cursor, - dim: true, - right: 'queued', - }), - ) - } - for (const q of inFlightSends.filter((q) => q.state !== 'accepted')) { - out.push( - // Every send keeps the sender's bar, waiting or not — it is your - // message either way. The right-hand label carries the state. - ...pendingMessageRows(q.key, q.text, cols, { - gutter: USER_BAR, - gutterColor: theme.cursor, - dim: true, - right: q.state === 'sending' ? 'sending' : q.state === 'queued' ? 'queued' : 'cancelled', - }), - ) - } - return out - }, [ - infraActivity, - sandbox, - sandboxSettled, - visible, - inFlightSends, - pendingPrompt, - liveTail, - cols, - ]) - - // ---- the scrollback split ---- - // Rows whose content is FINAL go to : printed once, into the - // terminal's own scrollback, never repainted. Everything after them is the - // live region, repainted each frame. See settledItemKeys for what "final" - // means and why it is decided per message rather than per turn. - // - // The startup block is final once it has settled (it collapses to its static - // summary then, so the flushed copy is the one that lasts). It sits at the - // top of the list, so nothing below it can flush while it is still moving. - const settledKeys = useMemo(() => { - const keys = settledItemKeys(visible, working) - if (sandboxSettled) keys.add(SANDBOX_KEY) - return keys - }, [visible, working, sandboxSettled]) - // The rows already handed to , held APPEND-ONLY in a ref, and the - // entries they covered. Both are needed, and neither can be replaced by - // re-slicing allRows each frame: - // - // * prints `items.slice(printedCount)` and re-syncs printedCount - // from items.length. So the list may only GROW, and the rows already in it - // may never change. Hand it a shorter list and it re-prints everything. - // * The rows on the terminal are FROZEN TEXT, wrapped at the width they were - // printed at. allRows re-wraps on resize, so a re-slice would hand - // different rows for the same content and print them again. What - // was flushed is history: it is kept verbatim, and a resize re-wraps only - // the live region below. (Claude Code's scrollback has the same artifact — - // printed output does not reflow.) - // - // So an entry is flushed ONCE, keyed by entryKey, and its rows are kept as they - // were built. Seeded with the session break when this chat replaces another - // one's, so the rule prints above the first row of history, not after it. - const flushedRows = useRef( - props.scrollbackBreak ? [sessionBreakRow(sessionId, cols)] : [], - ) - const flushedEntries = useRef>(new Set()) - { - const settledRows = allRows.slice(0, settledRowCount(allRows, settledKeys)) - const fresh = settledRows.filter((r) => !flushedEntries.current.has(r.entryKey)) - if (fresh.length > 0) { - flushedRows.current = [...flushedRows.current, ...fresh] - for (const row of fresh) flushedEntries.current.add(row.entryKey) - } - } - const staticRows = flushedRows.current - // The live region: everything not yet flushed, capped to the rows the frame - // has for it. Capped from the FRONT (keep the newest) because an over-tall - // live frame scrolls ink's render region and smears stale rows up the - // terminal — and nothing is lost, since these rows flush as they settle. - const liveRows = useMemo(() => { - const rest = allRows.filter((r) => !flushedEntries.current.has(r.entryKey)) - return rest.length > viewBudget ? rest.slice(rest.length - viewBudget) : rest - // flushedEntries is a ref mutated above during this same render, so the - // filter always sees the boundary this frame just committed to. - }, [allRows, viewBudget, staticRows]) - - const insertAtCursor = useCallback((ch: string): void => { - setComposer(({ text, cursor }) => ({ - text: text.slice(0, cursor) + ch + text.slice(cursor), - cursor: cursor + ch.length, - })) - }, []) - - useInput( - (ch, key) => { - // The command menu is the INNERMOST modal, so it takes its keys before - // anything else: ↑/↓ walk it, tab/enter complete the highlighted command, - // esc dismisses it by clearing the slash that opened it. Everything else - // (typing, editing, ctrl+*) falls through to the composer below, which is - // what keeps the menu a suggestion rather than a mode you get stuck in. - if (menuOpen) { - if (key.upArrow || key.downArrow) { - const next = key.upArrow ? menuAt - 1 : menuAt + 1 - // Wraps, because the list is short enough that walking off one end - // meaning "go to the other" is faster than reversing direction. - setMenuIndex((next + menu.length) % menu.length) - return - } - if (key.tab) { - complete(menu[menuAt]) - return - } - // Enter COMPLETES rather than submits while the line is still a partial - // command ("/tr"), and submits once it names one exactly ("/transcript"): - // otherwise enter on a highlighted suggestion would refuse the very - // command the menu is pointing at. - if (key.return && !resolveCommand(composer.text)) { - complete(menu[menuAt]) - return - } - if (key.escape) { - setComposer({ text: '', cursor: 0 }) - return - } - } - if (key.escape) { - // Hosted: hand focus to the session nav (stopping is the composer's - // /stop command). Solo: esc-while-working keeps meaning stop. - if (props.onFocusNav) props.onFocusNav() - else if (working) submit('/stop') - return - } - // Below here are the composer's own keys. Watch-only (no composer): ↓ - // leaves for the session nav; everything else is inert. - if (!composerVisible) { - if (key.downArrow && props.onFocusNav) props.onFocusNav() - return - } - if (key.return) { - submit(composer.text) - return - } - // Word/line jumps and kills (option+←/→, ctrl+a/e/w/u/k, …) before the - // plain arrow handling below, which moves by one character. - const edited = applyEditShortcut(composer, ch, key) - if (edited) { - setComposer(edited) - return - } - if (key.upArrow) { - // Up inside a multi-line input climbs a line; on line 1 there is - // nowhere to go — the transcript above is the terminal's scrollback, - // which its own keys and wheel move. - const up = cursorLineUp(composer.text, composer.cursor) - if (up !== null) setComposer((c) => ({ ...c, cursor: up })) - return - } - if (key.downArrow) { - // Down inside a multi-line input walks a line; down on the last - // line moves focus to the host's session nav (the bar below). - const down = cursorLineDown(composer.text, composer.cursor) - if (down !== null) setComposer((c) => ({ ...c, cursor: down })) - else if (props.onFocusNav) props.onFocusNav() - return - } - if (key.leftArrow) { - setComposer((c) => ({ ...c, cursor: Math.max(0, c.cursor - 1) })) - return - } - if (key.rightArrow) { - setComposer((c) => ({ ...c, cursor: Math.min(c.text.length, c.cursor + 1) })) - return - } - if (key.backspace || key.delete) { - setComposer(({ text, cursor }) => - cursor > 0 - ? { text: text.slice(0, cursor - 1) + text.slice(cursor), cursor: cursor - 1 } - : { text, cursor }, - ) - return - } - if (key.ctrl || key.meta) return - if (ch) insertAtCursor(ch) - }, - { isActive: inputActive }, - ) - - // The persistent footer status line: status, running spend, model, - // session id (the dashboard link), automation (when the session has - // one), CLI version. Per-step costs live on the transcript's metadata - // column, so the footer carries the total alone. Command hints live in - // --help. The total prefers the server's ledger figure (live via the - // session frames' cost columns, climbing mid-turn); the CC-derived result - // total is the fallback against older backends. - const totalStr = `$${(serverCostUsd ?? cost.total ?? 0).toFixed(2)}` - // Sized to fit by construction: Ink measures the OSC-8 hyperlink's - // invisible URL bytes as width, so a linked id only ships when the whole - // line — escape bytes included — fits the pane; otherwise the line renders - // as plain text, cut to the pane with a … if even that overflows. Never - // rely on Ink wrapping/truncating this line: it's budgeted at exactly one - // row. - const metaParts = (id: string): string[] => [ - statusWord, - `${totalStr} total`, - ...(props.model ? [props.model] : []), - id, - ...(props.configName ? [props.configName] : []), - `v${VERSION}`, - ] - // Narrow panes shed fields from the right (version, then config, session - // id, model) and keep the status and the spend. - const allParts = metaParts(sessionId) - let kept = allParts.length - while (kept > 2 && allParts.slice(0, kept).join(META_SEP).length >= cols) kept-- - const plain = allParts.slice(0, kept).join(META_SEP) - const linked = metaParts(hyperlink(props.sessionUrl, sessionId)) - .slice(0, kept) - .join(META_SEP) - // Records that arrived but rendered nothing take the whole line: a shape this - // build can't read means the transcript is lying about what was said, which - // outranks the spend and the session id. It holds the line until the next - // send, and a further drop brings it back with the new count. - const undisplayedNew = Math.max(0, undisplayed - undisplayedSeen) - const dropWarning = - undisplayedNew > 0 - ? `${undisplayedNew} ${undisplayedNew === 1 ? 'event' : 'events'} could not be displayed, this CLI may be out of date` - : null - const body = ctrlCArmed ? CTRL_C_QUIT_HINT : (dropWarning ?? plain) - const pad = Math.max(0, Math.floor((cols - body.length) / 2)) - // Ink measures the hyperlink's invisible URL bytes as width, so the link - // only ships when padding plus escape bytes still fit the row; the padding - // gives way first, then the link itself. - const linkedPad = - ctrlCArmed || dropWarning ? -1 : Math.min(pad, cols - 1 - linked.length) - const metaLine = - linkedPad >= 0 - ? `${' '.repeat(linkedPad)}${linked}` - : `${' '.repeat(pad)}${body.length < cols ? body : `${body.slice(0, Math.max(0, cols - 1))}…`}` - return ( - // Hosted panes pin BOTH dimensions: without the width the root sizes to - // its widest child (the unwrapped meta line) and smears rows across the - // terminal; without the fixed height an over-estimated transcript slice - // grows the root past the pane and the host clips the footer off. Fixed, - // the squeeze resolves inside (the overflow-hidden viewport absorbs it). - - {/* The settled transcript, printed once into the terminal's own - scrollback and never repainted — which is what makes the wheel, the - trackpad, and select/copy work natively above the live frame. */} - - {(row) => ( - // Flushed rows are frozen: no live tick, no pulse — both repaint, and - // a printed row can't. `seconds` is 0 and `pulseOn` true so a row that - // WAS live prints in its settled state. - - )} - - {/* The live region: the unsettled tail of the same flat row list — the - startup block while it runs, the streaming response, in-flight sends, - the live activity lines. It is sized to its content and grows downward, - so it must not claim the terminal's leftover height. */} - - {liveRows.map((row) => ( - - ))} - - {/* flexShrink=0: when a mis-estimated transcript slice overflows the - fixed pane, the squeeze lands on the (overflow-hidden) viewport - above, never on the composer/meta rows. */} - - {/* The notice is budgeted at its wrapped height (noticeRows) and - pinned to it, so an unbounded one (a stream error, an API error - detail) can't grow the frame past the pane. */} - {shownNotice && noticeRows > 0 && ( - - {shownNotice} - - )} - {/* The slash-command menu, directly above the input it completes: one - row per match, the highlighted one wearing the same cyan ▶ as every - other selection in the app. It sits inside the budgeted rows - (menuRows), so a long list is cut rather than pushing the composer - off the bottom of the frame. */} - {menuRows > 0 && ( - - {menu.slice(0, menuRows).map((command, i) => ( - - - {i === menuAt ? SELECTION_GLYPH : ' '}{' '} - {`/${command.name}`} - {` ${command.detail}`} - - - ))} - - )} - {/* The composer. It is pinned to the rows budgeted for it and clips, so a - terminal too short for the whole input scrolls it rather than painting - the overflow across the meta line. */} - {composerVisible && ( - = COMPOSER_INTERIOR_ROWS + 2 ? 1 : 0} - paddingX={COMPOSER_PAD_X} - > - {/* One parent Text so a multi-line input flows as a single block - (sibling Texts in a row Box would render as columns). The - caret is the inverse cell at the cursor, hidden while the pane - itself is unfocused (the sidebar has the keyboard). A caret - sitting on a newline renders as an inverse space at that line's - end. The key remounts the node on every content change: ink - reuses the previous measurement when nested text mutates in - place, and the stale (narrower) width wraps the caret onto the - border row below. */} - {/* The explicit colour on the parent is what the bare text children - below inherit, and it gives the inverse caret a known pair of - colours to swap. */} - - {composer.text.slice(0, composer.cursor)} - {focused && ( - - {composer.cursor < composer.text.length && - composer.text[composer.cursor] !== '\n' - ? composer.text[composer.cursor] - : ' '} - - )} - {composer.cursor < composer.text.length - ? focused && composer.text[composer.cursor] !== '\n' - ? composer.text.slice(composer.cursor + 1) - : composer.text.slice(composer.cursor) - : ''} - - - )} - {metaLine} - - - ) -} - -// Compact token count for the live footer: 1400 -> "1.4k", 900 -> "900". -function formatTokens(n: number): string { - return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n) -} - -// Where the caret lands after ↑ in the composer: the same column on the -// previous line (clamped to that line's length, text-editor style), or null -// when the caret is already on the first line — there is nowhere further up to -// go. Pure, for tests. -export function cursorLineUp(text: string, cursor: number): number | null { - const lineStart = cursor > 0 ? text.lastIndexOf('\n', cursor - 1) + 1 : 0 - if (lineStart === 0) return null - const col = cursor - lineStart - const prevStart = lineStart >= 2 ? text.lastIndexOf('\n', lineStart - 2) + 1 : 0 - const prevLen = lineStart - 1 - prevStart - return prevStart + Math.min(col, prevLen) -} - -// Where the caret lands after ↓: the same column on the next line (clamped), -// or null when already on the last line. Pure, for tests. -export function cursorLineDown(text: string, cursor: number): number | null { - const nextNewline = text.indexOf('\n', cursor) - if (nextNewline < 0) return null - const lineStart = cursor > 0 ? text.lastIndexOf('\n', cursor - 1) + 1 : 0 - const col = cursor - lineStart - const nextStart = nextNewline + 1 - const nextEnd = text.indexOf('\n', nextStart) - const nextLen = (nextEnd < 0 ? text.length : nextEnd) - nextStart - return nextStart + Math.min(col, nextLen) -} - -// The rule printed into scrollback above a session that replaced another in the -// same terminal: two conversations otherwise run together, and the second's -// opening line reads as the first's next turn. Names the session it opens, since -// that is the thing the reader needs to know about the text below it. -function sessionBreakRow(sessionId: string, cols: number): TranscriptRow { - const label = ` ${sessionId} ` - const rule = '─'.repeat(Math.max(0, contentWidth(cols) - visibleWidth(label))) - return { - id: `break:${sessionId}`, - entryKey: `break:${sessionId}`, - spans: [ - { text: label, dim: true }, - { text: rule, dim: true }, - ], - } -} - -// The startup block as screen rows, in TWO forms. -// -// While the agent comes up it is LIVE — the "Connected" opener, a ticking -// headline, and the tail of the startup log under it: -// ✦ Connected to ellipsis.dev -// -// ⏺ Starting cloud agent… 12s -// ✓ Preparing image, incremental build, 3.4s -// ⏺ Running setup… -// npm install -// -// Once it settles it becomes a STATIC record of one past event — a cloud agent -// was started — compressed the way a tool call is, and it never moves again: -// ✦ Cloud agent session on ellipsis.dev -// ⎿ Sandbox ready in 42s -// -// Live status (asleep/awake) deliberately stays OUT of it: the block narrates -// the start, and a session that fell asleep an hour later says so on its own -// transcript line and in the footer. A header that rendered live status told an -// old session's reader "Session asleep" as its opening line, which is not what -// happened first. -function sandboxRows(o: { - sandbox: SandboxState | null - infraActivity: string | null - settled: boolean - cols: number -}): TranscriptRow[] { - const { sandbox, infraActivity, settled, cols } = o - const key = SANDBOX_KEY - const rows: TranscriptRow[] = [] - const width = contentWidth(cols) - // Every row of the block reserves the standard gutter, so its marks line up - // with every other entry's. - const line = (spans: RowSpan[], extra: Partial = {}): void => { - rows.push({ id: `${key}:r${rows.length}`, entryKey: key, spans, ...extra }) - } - // The opening line. Plain text — an OSC 8 hyperlink here gets broken by ink's - // wrapping and swallows the label; the clickable dashboard link lives in the - // footer meta line. Live it reads as an address ("Connected to…"); settled it - // names the event the block stands for. - rows.push(spacerRow(key, `${key}:hdr-top`)) - rows.push({ - id: `${key}:hdr`, - entryKey: key, - // The ✦ rides the GUTTER, like every other row's mark. Baked into the text - // span it sits one gutter-width right of every glyph below it, which reads - // as a stray indent on the app's very first line. - gutter: { text: '✦', dim: true }, - spans: [ - { - text: fit( - settled ? 'Cloud agent session on ellipsis.dev' : 'Connected to ellipsis.dev', - width, - ), - bold: true, - }, - ], - }) - if (settled) { - // The whole start, compressed to ONE branch line under the opener — the - // same idiom as a collapsed tool run ("Ran 3 shell commands"). No elapsed - // clock, no live glyph, nothing that can change after the fact. - line( - [{ text: fit(sandboxSummary(sandbox), width - NEST_INDENT - BRANCH_TEXT_PAD), dim: true }], - { gutter: { text: BRANCH_GLYPH, dim: true }, indent: NEST_INDENT, textPad: BRANCH_TEXT_PAD }, - ) - } else { - rows.push(spacerRow(key, `${key}:hdr-sp`)) - // A live status word overrides a stale headline: on a wake the status flips - // before the new session_starting record lands. - const headline = `${(!sandbox || sandbox.done ? (infraActivity ?? 'Starting cloud agent') : sandbox.headline).replace(/…$/, '')}…` - line([{ text: fit(headline, width - 18), dim: true }], { - gutter: { text: LIVE_GLYPH, color: theme.foreground }, - // While starting, the headline pulses and carries the elapsed clock. - tick: 'elapsed' as const, - pulse: true, - }) - } - - // The log, ONE level under the opener: no phase tree, no per-phase tails, no - // drilling. While the agent is coming up you see the last SANDBOX_LOG_ROWS - // lines of everything that has happened — including build and setup output, - // which is the whole point of showing it — headed by a count of what scrolled - // past. Once it settles the block collapses to its summary line; the log - // itself stays up in the scrollback, where it was printed. - if (!sandbox) return rows - const show = settled ? [] : lastLines(sandbox.log, SANDBOX_LOG_ROWS) - const hidden = sandbox.log.length - show.length - if (show.length > 0 && hidden > 0) { - line([ - { text: ' ' }, - { text: `… +${hidden} earlier line${hidden === 1 ? '' : 's'}`, dim: true }, - ]) - } - for (const entry of show) { - // A milestone still open pulses; output lines and closed milestones are - // plain dim trace. Marks stay in one column, so the log reads as a list. - const live = entry.kind === 'step' && !sandbox.sandboxDone - const mark: RowSpan = - entry.kind === 'failed' - ? { text: '✗', color: theme.error } - : entry.kind === 'output' - ? { text: ' ' } - : live - ? { text: LIVE_GLYPH, color: theme.foreground, pulse: true } - : { text: '✓', color: theme.success }; - line( - [ - { text: ' ' }, - mark, - { text: ' ' }, - { - text: fit(entry.text, width - 5), - color: entry.kind === 'failed' ? theme.error : undefined, - dim: entry.kind !== 'failed', - }, - ], - live ? { pulse: true } : {}, - ) - } - return rows -} - -// A single line, truncated to `width` visible columns — the startup block's -// lines are structural (indent + mark + label), so an over-long one is cut -// rather than reflowed onto a row the layout didn't account for. -function fit(text: string, width: number): string { - return fitLines(text, Math.max(4, width))[0] ?? '' -} - -// Lines of the startup log the block shows: the last ten, which is enough to -// watch an image build or a setup hook make progress without the block taking -// over the chat window. Anything older is counted in the "… +N earlier lines" -// head above them. -const SANDBOX_LOG_ROWS = 10 - -// One screen row. Exactly one terminal line by construction: the text was -// pre-fitted to the pane (see transcriptRows), and wrap="truncate" is the -// belt-and-braces guarantee — a row that wrapped would push every row below it -// down and misalign the rows the frame budgeted for. -const RowLine = React.memo(function RowLine({ - row, - cols, - seconds, - pulseOn, -}: { - row: TranscriptRow - cols: number - // The row's ticking duration, resolved here so the once-a-second tick - // repaints this line instead of rebuilding the transcript's rows. - seconds: number - // The shared pulse phase (see PULSE_MS). Only a pulsing row reads it, so - // the blink repaints the live lines and leaves the rest of the window alone. - pulseOn: boolean -}): React.ReactElement { - // A clamped body says how much it cut, and nothing more: there is no key that - // un-clamps it, and the full text is up in the scrollback where it printed. - const spans: RowSpan[] = row.clampedLines - ? [{ text: `… +${row.clampedLines} lines`, dim: true }] - : row.spans - // Every span, gutter mark and right-hand readout below paints an explicit - // brand colour: `spanColor` resolves the pulse's off beat, a `dim` span and a - // span with no colour of its own onto real hexes, so nothing on the row is - // left to the terminal's own palette. See its comment in transcriptRows. - const markColor = (span: RowSpan): string => spanColor(span, pulseOn) - // The right-hand metadata column reads as plain prose — "23s, 4 tokens", no - // parentheses and no interpuncts. It is a readout, not an aside. - const right = row.tick - ? { text: [humanDuration(seconds), row.right?.text].filter(Boolean).join(', '), dim: true } - : row.right - // height=1 is load-bearing: a blank separator row has no text, and ink - // collapses an empty Box to zero height — the row would silently vanish, - // leaving the frame short of the rows it budgeted for. - // - // No background: a row prints into the terminal's scrollback and is never - // repainted, so any fill it carries is permanent and cannot be kept in step - // with a frame that resizes or shrinks. Everything structural is carried by - // the gutter glyph and foreground colour instead. - return ( - - - {row.indent ? : null} - - {/* The row's sender glyph. A live row's mark pulses by DIMMING on the - off beat: the glyph itself never changes, so the column holds still - and the eye reads a heartbeat rather than a character swapping in - and out. */} - - {row.gutter?.text ?? ''} - - - {/* A ⎿ item's extra breathing room, on every row of it so a wrapped - result stays aligned under its first line. */} - {row.textPad ? : null} - - - {spans.map((span, i) => ( - - {span.text} - - ))} - - - {right && ( - - - {right.text} - - - )} - - - ) -}) diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx deleted file mode 100644 index 52afd11..0000000 --- a/src/ui/SessionsApp.tsx +++ /dev/null @@ -1,1853 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Box, Text, useInput, useStdin, useStdout } from 'ink' -import type { OpenSocket } from '@ellipsis-dev/sdk/stream' -import { SessionTranscriptStore, seedTranscriptStore } from '@ellipsis-dev/sdk/store' -import type { Ellipsis } from '@ellipsis-dev/sdk' -import { errorDetail } from '../lib/api' -import type { - AgentSession, - RepositorySummary, - SavedEnvironment, - StartAgentSessionRequest, - SupportedModel, -} from '../lib/types' -import { applyEditShortcut } from '../lib/editing' -import { CTRL_C_QUIT_HINT, useCtrlCQuit } from './ctrlC' -import { sessionUrl } from '../lib/urls' -import { - ADD_MCP_SERVER_LABEL, - ADD_VARIABLE_LABEL, - applyComposerChoices, - builtInMcpServers, - attentionFlip, - paneWithRepository, - composerModelOptions, - composerModelChoice, - composerPickerRows, - connectability, - CUSTOM_ENVIRONMENT_ID, - CUSTOM_ENVIRONMENT_LABEL, - EMPTY_ENVIRONMENT_ID, - EMPTY_PANE, - environmentPane, - environmentPaneWalk, - environmentPresets, - environmentSectionAt, - environmentSectionRows, - environmentSourceLabel, - PANE_SECTION_LABELS, - paneEquals, - restingPresetIndex, - parseVariableEntry, - repositoryRefLabel, - scriptRowLines, - variableRowLabel, - type ComposerChoices, - type ComposerEnvironment, - type ComposerModel, - type CustomMcpServer, - type CustomVariable, - type EnvironmentPaneState, - type PaneSection, - type PaneWalkStep, - validateMcpServer, - rowDescription, - rowGlyph, - rowMeta, - rowStatusWord, - sessionConfigName, - filterSessions, - navSlice, - sessionBarQuery, - SELECTION_GLYPH, - mergeSidebarSessions, -} from '../lib/sessions' -import type { ResolvedSessionBar } from '../lib/config' -import { inputSurface, theme } from '../lib/theme' -import { ConnectApp } from './ConnectApp' - -// The multi-session UI — what a bare `agent`, `agent "prompt"`, and `agent -// session connect ` all open. Two screens, both on the primary buffer: -// -// * the LAUNCHER: a compact inline block — one prompt box holding the -// configuration rows and the input, the latest sessions under it. Enter in -// the box starts a session; enter on a session row opens its chat. It is -// short by design, so ink repaints it in place like any live frame; no -// alternate screen, no full-height frame. -// * the CHAT (ConnectApp) — owns the terminal outright. Its settled -// transcript is printed into the terminal's real scrollback, so the -// wheel, the trackpad and select/copy are the terminal's own. -// -// esc in the chat clears the screen and returns to the launcher; enter on a -// session replaces the launcher with its chat. -// Exactly one useInput handler is active at a time. -// -// Liveness: ONE WebSocket — the focused session's, owned by its ConnectApp — -// plus a 5s REST poll of the session list for the launcher. Transcript stores -// are cached per visited session for the process lifetime, so hopping back -// repaints instantly and the stream resumes past the cached cursor. - -const SESSIONS_POLL_MS = 5_000 -// The launcher clock driving the "12s ago" age tags. -const AGE_TICK_MS = 5_000 -// How many session rows the launcher shows; the highlight parks two rows from -// the bottom and the list scrolls under it (navSlice). -const LIST_ROWS = 5 - -// Everything an open picker's option row prints before its label: the row -// indent, the selection cell and its space, then the "[x] " checkbox. What the -// price column has to clear on the left. -const OPTION_GUTTER = ' '.length + 2 + '[x] '.length - -// The unit the price columns are quoted in, printed once on their column head. -const RATE_UNIT = ' per 1M' - -export interface SessionsAppProps { - api: Ellipsis - openSocket: OpenSocket - // app.ellipsis.dev base + the customer login, for per-session dashboard links. - appBase: string - customerLogin: string - // My GitHub login for the launcher's closing "@me in account" line; null on - // an API-key credential, which has no GitHub user behind it. - ghLogin: string | null - // My GitHub account id — the launcher lists sessions attributed to me. null - // (e.g. an API-key credential) lists the whole account's sessions. - authorId: number | null - // The repo detected from the cwd's origin remote ("owner/name"), which is - // what the server's default resolution checks out — shown as the launcher's - // resting Repository value. null when the cwd isn't an enrolled repo. - detectedRepo: string | null - // Open focused on this session (connect / prompt shorthand); undefined - // opens on the launcher (a bare `agent`). - initialSessionId?: string - // The start response's resolved config name for the initial session, and a - // caveat to show in its chat (watch-only reasons ride connectability). - initialConfigName?: string - initialNotice?: string - // Which sessions the launcher lists. `hidden` drops the list entirely. - // Set under "sessionBar" in the config file. - sessionBar: ResolvedSessionBar - // Builds the start request for a composer-spawned session (the entry point - // owns repository detection and defaults). - buildStartRequest: (prompt: string) => StartAgentSessionRequest -} - -// Everything the chat pane needs for one session, cached across hops. -type ChatEntry = { - store: SessionTranscriptStore - canSend: boolean - notice: string | null - model: string | null - configName: string | null - url: string -} - -type MainPane = { type: 'launcher' } | { type: 'chat'; sessionId: string } - -export function SessionsApp(props: SessionsAppProps): React.ReactElement { - const { api, openSocket, appBase, customerLogin, authorId, sessionBar } = props - const hideList = sessionBar.hidden - const { isRawModeSupported } = useStdin() - const { stdout, write } = useStdout() - - const [termCols, setTermCols] = useState(stdout?.columns ?? 80) - useEffect(() => { - if (!stdout) return - const onResize = (): void => { - setTermCols(stdout.columns) - } - stdout.on('resize', onResize) - return () => { - stdout.off('resize', onResize) - } - }, [stdout]) - const width = Math.max(20, termCols - 1) - - // ---------------------------- session list data --------------------------- - - const [sessions, setSessions] = useState([]) - const [polledOnce, setPolledOnce] = useState(false) - // Sessions whose status flipped active → waiting since last viewed: the - // "an agent is blocked on you" dot. Cleared when the row is opened. - const [attention, setAttention] = useState>(new Set()) - const lastWords = useRef(new Map()) - // Composer-spawned sessions the poll may not return yet (created < poll - // lag, or attributed differently); merged into the list until it does. - const [localSessions, setLocalSessions] = useState([]) - - // The last API failure from any background call (the poll, the launcher's - // pickers). Those calls have no output of their own, so without this a broken - // route or a dead token just shows an empty list. Rendered on the launcher's - // status line. - const [apiError, setApiError] = useState(null) - const reportApiError = useCallback((label: string, err: unknown): void => { - setApiError(`${label}: ${errorDetail(err)}`) - }, []) - - const poll = useCallback(async (): Promise => { - try { - const listed = ( - await api.sessions.list( - sessionBarQuery(sessionBar, { authorId, detectedRepo: props.detectedRepo }), - ) - ).items - setAttention((prev) => { - const next = new Set(prev) - for (const s of listed) { - const word = rowStatusWord(s) - if (attentionFlip(lastWords.current.get(s.id), word)) next.add(s.id) - lastWords.current.set(s.id, word) - } - return next.size === prev.size ? prev : next - }) - setSessions(listed) - setLocalSessions((prev) => prev.filter((l) => !listed.some((s) => s.id === l.id))) - setPolledOnce(true) - setApiError(null) - } catch (err) { - // Keep the previous list (the next tick retries), but say so: a poll that - // fails every tick is a broken session list, not a blip. - reportApiError('sessions', err) - } - }, [api, authorId, reportApiError, sessionBar, props.detectedRepo]) - - // The poll only feeds the launcher's rows and attention dots; with the list - // hidden there is nothing on screen it could update. - useEffect(() => { - if (hideList) return - void poll() - const t = setInterval(() => void poll(), SESSIONS_POLL_MS) - return () => clearInterval(t) - }, [poll, hideList]) - - // The age tags tick on their own clock (nothing else re-renders idle rows). - const [, setAgeTick] = useState(0) - useEffect(() => { - const t = setInterval(() => setAgeTick((n) => n + 1), AGE_TICK_MS) - return () => clearInterval(t) - }, []) - - const rows = useMemo( - () => mergeSidebarSessions(sessions, localSessions), - [localSessions, sessions], - ) - - // --------------------------------- panes ---------------------------------- - - const [mainPane, setMainPane] = useState( - props.initialSessionId - ? { type: 'chat', sessionId: props.initialSessionId } - : { type: 'launcher' }, - ) - - // ------------------------------ chat entries ------------------------------ - - const [entries, setEntries] = useState>(new Map()) - // Sessions whose chat has already printed into this terminal's scrollback. - // A chat mounting after any other output prints a rule naming itself first, - // so two conversations in one scrollback don't run together (see - // ConnectApp's scrollbackBreak). - const shownChats = useRef>(new Set()) - const [loadError, setLoadError] = useState(null) - const loading = useRef(new Set()) - - // Seed a transcript store exactly like the solo connect, so the first - // paint is instant and the stream resumes past the seeded cursor. - const loadEntry = useCallback( - async (sessionId: string, configName?: string, notice?: string): Promise => { - if (loading.current.has(sessionId)) return - loading.current.add(sessionId) - setLoadError(null) - try { - const [{ session }, page] = await Promise.all([ - api.sessions.get(sessionId), - api.sessions.records(sessionId).then((p) => p.response), - ]) - const store = new SessionTranscriptStore() - // Same cast as runConnect: REST marks nullable fields optional, the - // frame types require them. Identical JSON either way. - seedTranscriptStore(store, { - session, - records: page.records, - messages: page.messages, - earliestFeedSeq: page.earliest_feed_seq, - } as Parameters[1]) - const c = connectability(session) - const entry: ChatEntry = { - store, - canSend: c.canSend, - notice: [notice, c.reason].filter(Boolean).join(', ') || null, - model: session.tokens?.model || null, - configName: configName ?? sessionConfigName(session), - url: sessionUrl(appBase, customerLogin, sessionId), - } - setEntries((prev) => new Map(prev).set(sessionId, entry)) - } catch (err) { - setLoadError(errorDetail(err)) - loading.current.delete(sessionId) - return - } - loading.current.delete(sessionId) - }, - [api, appBase, customerLogin], - ) - - // Load the focused session's entry on demand (initial focus included). - useEffect(() => { - if (mainPane.type !== 'chat') return - if (entries.has(mainPane.sessionId)) return - void loadEntry( - mainPane.sessionId, - mainPane.sessionId === props.initialSessionId ? props.initialConfigName : undefined, - mainPane.sessionId === props.initialSessionId ? props.initialNotice : undefined, - ) - }, [mainPane, entries, loadEntry, props.initialSessionId, props.initialConfigName, props.initialNotice]) - - // ----------------------------- new session flow --------------------------- - - const [starting, setStarting] = useState(false) - const [startError, setStartError] = useState(null) - - // The launcher's picker options, fetched once when it first shows: the - // saved environments and the selectable models. A models failure (an older - // server without GET /models) leaves the list empty and the launcher falls - // back to its built-in set. - const [environments, setEnvironments] = useState(null) - const [secretNames, setSecretNames] = useState(null) - const [repos, setRepos] = useState(null) - const [models, setModels] = useState(null) - // The built-in MCP server names available on this account (from the - // connected integrations); [] until the fetch lands or when none are. - const [builtInServers, setBuiltInServers] = useState([]) - const pickersLoading = useRef(false) - useEffect(() => { - if (mainPane.type !== 'launcher' || pickersLoading.current) return - pickersLoading.current = true - void api.environments - .list() - .then((rows) => setEnvironments(rows.environments)) - .catch((err) => { - setEnvironments([]) - reportApiError('environments', err) - }) - void api.secrets - .list() - .then((r) => setSecretNames(r.secrets.map((s) => s.name))) - .catch((err) => { - setSecretNames([]) - reportApiError('variables', err) - }) - void api.integrations.github - .repos() - .then((r) => setRepos(r.repositories)) - .catch((err) => { - setRepos([]) - reportApiError('repositories', err) - }) - void api.integrations - .list() - .then((r) => setBuiltInServers(builtInMcpServers(r))) - .catch((err) => { - reportApiError('integrations', err) - }) - void api.account.models - .list() - .then((r) => setModels(r.models)) - .catch((err) => { - setModels([]) - reportApiError('models', err) - }) - }, [mainPane.type, api, reportApiError]) - - const startSession = useCallback( - async (prompt: string, choices: ComposerChoices): Promise => { - setStarting(true) - setStartError(null) - try { - const req = applyComposerChoices(props.buildStartRequest(prompt), choices) - const { session } = await api.sessions.start(req) - lastWords.current.set(session.id, rowStatusWord(session)) - setLocalSessions((prev) => [session, ...prev]) - setMainPane({ type: 'chat', sessionId: session.id }) - // Seed the entry from the start response's resolved config identity. - void loadEntry(session.id, sessionConfigName(session) ?? undefined) - } catch (err) { - setStartError(errorDetail(err)) - } finally { - setStarting(false) - } - }, - [api, loadEntry, props], - ) - - const openSession = useCallback((sessionId: string): void => { - setAttention((prev) => { - if (!prev.has(sessionId)) return prev - const next = new Set(prev) - next.delete(sessionId) - return next - }) - setMainPane({ type: 'chat', sessionId }) - }, []) - - // Leaving a chat wipes the screen and the scrollback. The chat's settled rows - // were printed into the terminal's own scrollback (), so they cannot - // be un-printed: without this the launcher would paint under half a - // transcript. Clearing also empties shownChats, so the next chat opens on a - // clean screen with no session-break rule above it. - const toLauncher = useCallback((): void => { - write('\x1b[2J\x1b[3J\x1b[H') - shownChats.current.clear() - setMainPane({ type: 'launcher' }) - }, [write]) - const refreshOnDone = useCallback((): void => { - void poll() - }, [poll]) - - // ctrl+c quits from the launcher and from a chat still loading — a live - // chat owns its own, where the first press also interrupts the running turn. - const chatMounted = mainPane.type === 'chat' && entries.has(mainPane.sessionId) - const armed = useCtrlCQuit(!chatMounted && isRawModeSupported) - - // ------------------------------- rendering -------------------------------- - - if (mainPane.type === 'chat') { - const entry = entries.get(mainPane.sessionId) - if (!entry) { - return ( - - - {loadError ? `✗ ${loadError}` : `loading ${mainPane.sessionId}…`} - - {armed ? CTRL_C_QUIT_HINT : 'esc: back'} - - - ) - } - // Anything already printed above (an earlier chat, the launcher's rows) - // means this transcript is arriving under other output, so it opens with a - // rule naming itself. Recorded before the render so the flag is stable for - // this mount: the ref is what makes the FIRST chat of the process print no - // break. - const needsBreak = shownChats.current.size > 0 && !shownChats.current.has(mainPane.sessionId) - shownChats.current.add(mainPane.sessionId) - return ( - // The chat, owning the terminal: no box around it, so its settled - // transcript prints into the terminal's own scrollback and the wheel and - // select/copy are the terminal's. - - ) - } - - // Short by design: it is the launcher's last row, so it states who you are - // rather than re-announcing the connection the rest of the block implies. - const whoLine = props.ghLogin - ? `@${props.ghLogin} in ${customerLogin}` - : `${customerLogin} (api key)` - - return ( - void startSession(text, choices)} - onOpenSession={openSession} - rawMode={isRawModeSupported} - /> - ) -} - -// Swallows everything except esc and ← — the keyboard owner for the loading -// placeholder, either of which returns to the launcher. -function EscOnlyInput({ - active, - rawMode, - onEsc, -}: { - active: boolean - rawMode: boolean - onEsc: () => void -}): React.ReactElement | null { - useInput( - (_ch, key) => { - if (key.escape || key.leftArrow) onEsc() - }, - { isActive: active && rawMode }, - ) - return null -} - -// The prompt row's persistent label, upper-cased in render like every other -// row's, and what its empty input says: enter is a real start. -const PROMPT_LABEL = 'Prompt' -const PROMPT_HINT = 'Enter to start a cloud session...' - -// The two configuration headers under the prompt box, and the column their -// values (the preset row, the model pick) start in. -const ENVIRONMENT_LABEL = 'Environment' -const MODEL_LABEL = 'Model' -const HEADER_LABEL_WIDTH = ENVIRONMENT_LABEL.length + 3 - -// The environment field rows sit two columns in from the headers, each with -// its section's label in a column of its own, so the values read down one -// edge whatever section they belong to. -const FIELD_INDENT = ' ' -const FIELD_LABEL_WIDTH = Math.max(...Object.values(PANE_SECTION_LABELS).map((l) => l.length)) + 3 - -// The variable form's placeholders, each stating what leaving that field empty -// means, in the field it applies to. -const NAME_PLACEHOLDER = 'MY_TOKEN' -const VALUE_PLACEHOLDER = 'leave empty to pull from secrets' - -// The prompt box's interior padding, matching the chat composer's. -const PROMPT_PAD_X = 2 - -// Where the launcher's cursor is, top to bottom: the prompt row, the -// environment preset row, one of the environment field rows (by its index in -// the flat walk), the model row, or a session row (by id, so the poll -// re-sorting under the cursor doesn't move the highlight). -type LauncherCursor = - | { kind: 'prompt' } - | { kind: 'presets' } - | { kind: 'pane'; at: number } - | { kind: 'model' } - | { kind: 'list'; id: string } - -// Adding a variable in the custom section: a two-field form, name over value, -// walked with ↑/↓ — `field` is where the caret sits. Enter on either field -// commits, so a variable whose value comes from stored secrets is name + enter. -type VariableEditor = { - field: 'name' | 'value' - name: string - value: string - error: string | null -} - -// Adding an MCP server: three fields walked with ↑/↓. A bare name opts into a -// built-in; command makes it a stdio server, url a remote one (one or the -// other — validateMcpServer). Enter commits from any field, esc backs out. -type ServerEditor = { - field: 'name' | 'command' | 'url' - name: string - command: string - url: string - error: string | null -} - -const SERVER_EDITOR_FIELDS = ['name', 'command', 'url'] as const - -// A script field opened for editing: a real multi-line caret, since a Dockerfile -// or a setup script is written in lines. Arrows move within the text, enter -// inserts a newline, esc commits and collapses the row back to its summary. -type ScriptEditor = { - section: 'image' | 'hooks' - field: string - text: string - cursor: number -} - - -// The launcher: the prompt box on top, the next session's configuration under -// it, history under that — -// -// | ▶ PROMPT: Enter to start a cloud session... -// -// ENVIRONMENT ▸staging◂ ci empty -// acme/api/envs/staging.yaml @ abc1234 -// repositories [x] acme/api -// branch: main -// [ ] acme/web -// mcp servers [x] linear -// [ ] slack -// + new -// variables [x] API_TOKEN -// + new -// image dockerfile_append: -// setup: | -// npm ci -// hooks post_start: -// post_clone: -// compute cpu: 4 -// memory: 16GB -// timeout: -// MODEL claude-opus-5 -// -// RECENT SESSIONS: @me in account -// ● latest session $0.20, 2m ago -// ● … (LIST_ROWS rows; scrolls) -// -// ONE input, two readings: what you type into the prompt row is the next -// session's prompt AND a live filter over the list below, so finding old work -// and starting new work are the same gesture. Enter starts a session with the -// text (empty text starts it idle). ↓ walks down through the configuration — -// the preset row, every environment field, the model row — and on into the -// list, where enter opens a session. ↑ walks back up. -// -// The ENVIRONMENT row is the saved environments, laid out as presets: ←/→ on -// that row moves the pick, and the field rows under it are what the picked -// one resolves to — every section open, nothing behind a dropdown. Together -// the field rows are the whole truth about the sandbox. Editing any of them -// is what makes the run custom: no preset is checked, a `custom` preset -// appears at the end of the row and takes the check, and from then on the -// fields' own lists are what ship. Picking a preset again reseeds them and -// `custom` goes away. That is why unchecking a repository the preset brought -// in actually drops it: the fields replace the resolved lists rather than -// adding to them (see applyComposerChoices). -// -// The first saved environment is what an untouched launcher stands on; -// `empty` (the bare sandbox) closes the row. There is no organization default: -// what is checked is what the request names. The repository the -// CLI is standing in shows checked under every preset, since the request adds -// it to whichever environment resolves. -// -// A checkbox row toggles with enter (or →/space). The branch and compute rows -// are one-line inputs edited by typing on them; blank = whatever the server -// resolves. A script field (image, hooks) prints as a YAML block scalar over -// its own lines, capped at SCRIPT_ROW_LINES with a count of what is hidden. -// Enter opens it into a real multi-line editor — arrows move within the text, -// enter is a newline, esc commits — because a Dockerfile is written in lines, -// not on one. -// -// The MODEL row still opens a dropdown: a model is a name, not a thing to -// read out. Typing anywhere outside an input row returns the cursor to the -// prompt. -// -// A saved automation is not a launcher pick: an automation runs exactly as -// defined and takes no prompt (`agent automation run`), while the launcher is -// the prompt. The picks here are the raw session's own settings — the same -// flat body `agent session start -e --model ...` speaks. -function Launcher({ - width, - whoLine, - focused, - starting, - error, - armed, - environments, - secretNames, - repos, - builtInServers, - models, - detectedRepo, - sessions, - polledOnce, - attention, - hideList, - onSubmit, - onOpenSession, - rawMode, -}: { - width: number - whoLine: string - focused: boolean - starting: boolean - error: string | null - armed: boolean - // null while loading; [] when the account has none / the fetch failed. - environments: SavedEnvironment[] | null - models: SupportedModel[] | null - // The account's stored variable names, checkable in the variables section. - secretNames: string[] | null - // The account's connected repositories, checkable in the repositories section. - repos: RepositorySummary[] | null - // The built-in MCP server names available on this account. - builtInServers: string[] - // The cwd's repo ("owner/name") — added to every start's checkout set, so it - // shows checked under every preset. - detectedRepo: string | null - sessions: readonly AgentSession[] - polledOnce: boolean - attention: ReadonlySet - hideList: boolean - onSubmit: (text: string, choices: ComposerChoices) => void - onOpenSession: (id: string) => void - rawMode: boolean -}): React.ReactElement { - const [text, setText] = useState('') - const [textCursor, setTextCursor] = useState(0) - const [cursor, setCursor] = useState({ kind: 'prompt' }) - // null = the preset row is untouched: the first saved environment (or - // `empty`) is checked and the request names it. The list arrives async, so - // there is no index to seed this with at mount. - const [presetPick, setPresetPick] = useState(null) - // null = the model row is untouched, so it tracks whichever row carries the - // server-resolved pick (see modelIdx). The list arrives async, so there is no - // index to seed this with at mount. - const [modelPick, setModelPick] = useState(null) - // The model dropdown's state: where its highlight sits (an option index). - // null = closed. - const [openModel, setOpenModel] = useState<{ hover: number } | null>(null) - // The field rows' backing state: this run's sandbox, whole. null = - // untouched, so the rows show (and send) whatever the picked preset - // resolves to; the first edit seeds it and from then on it is the truth. - const [pane, setPane] = useState(null) - // The "+ new server" form's state, or null while closed. - const [serverEditor, setServerEditor] = useState(null) - // The open variable form, or null. `field` is which of the two steps is - // being typed; `name`/`value` hold what has been typed so far. - const [editor, setEditor] = useState(null) - // The open script field's editor, or null while every script row is collapsed. - const [scriptEditor, setScriptEditor] = useState(null) - - // Each synced environment's note names the file it came from — only when - // the API already gave us the pieces (source_details + the repo list). - const environmentSources = useMemo(() => { - const byId = new Map((repos ?? []).map((r) => [r.id, r.full_name])) - const labels = new Map() - for (const e of environments ?? []) { - const label = environmentSourceLabel(e, byId) - if (label) labels.set(e.id, label) - } - return labels - }, [environments, repos]) - const presets = useMemo( - () => environmentPresets(environments ?? [], environmentSources), - [environments, environmentSources], - ) - // The checked preset: the explicit pick, else the resting one — undefined - // while the list is still loading, so nothing is checked that the request - // would not send. - const presetIdx = - presetPick !== null - ? Math.min(presetPick, presets.length - 1) - : restingPresetIndex(presets, environments !== null) - const pickedPreset = presetIdx === undefined ? undefined : presets[presetIdx] - // What the picked preset resolves to, as field rows, with the detected - // repository checked in (the request adds it to every environment). This is - // what the rows show until edited, and what "custom" is measured against. - const connectedRepoNames = useMemo(() => (repos ?? []).map((r) => r.full_name), [repos]) - const seededPane = useMemo(() => { - const id = pickedPreset?.id - const base = - id === undefined || id === EMPTY_ENVIRONMENT_ID - ? EMPTY_PANE - : environmentPane( - (environments ?? []).find((e) => e.id === id)?.environment, - connectedRepoNames, - ) - return paneWithRepository(base, detectedRepo) - }, [environments, pickedPreset, connectedRepoNames, detectedRepo]) - // The rows as the run would use them: the edits if there are any, else the - // picked preset's own values. - const shownPane = pane ?? seededPane - // Once the rows no longer say what the preset they were seeded from says, - // no preset is checked and the rows are what ship. - const isCustom = pane !== null && !paneEquals(pane, seededPane) - // The preset row as printed: the presets, plus `custom` at the end once the - // rows have diverged. `custom` is where the check sits then. - const presetRow = useMemo<{ id: string; name: string }[]>( - () => - isCustom - ? [...presets, { id: CUSTOM_ENVIRONMENT_ID, name: CUSTOM_ENVIRONMENT_LABEL }] - : presets, - [presets, isCustom], - ) - const checkedPresetIdx = isCustom ? presets.length : presetIdx - // Picking a preset drops the rows' edits, since the rows are a reading of - // whatever preset is checked. - const pickPreset = (at: number): void => { - if (presets.length === 0) return - setPresetPick(Math.min(Math.max(0, at), presets.length - 1)) - setPane(null) - } - // The server's selectable set (GET /models); before it lands — and on an - // older server that has no such route — the built-in fallback list. - const modelOptions = useMemo(() => composerModelOptions(models ?? []), [models]) - // The checked model row: the explicit pick, else the account default's row - // (the one carrying the null id), else the first. - const modelIdx = useMemo(() => { - if (modelPick !== null) return Math.min(modelPick, modelOptions.length - 1) - const at = modelOptions.findIndex((o) => o.id === null) - return at === -1 ? 0 : at - }, [modelPick, modelOptions]) - // Editing any row seeds the pane from what is on screen, so the first - // keystroke doesn't silently drop the rest of the environment. - const editPane = (edit: (p: EnvironmentPaneState) => EnvironmentPaneState): void => { - setPane((prev) => edit(prev ?? seededPane)) - } - // Whether a repository is in the run, and at which ref. - const repositoryFor = (fullName: string) => - shownPane.repositories.find((r) => r.fullName === fullName) - // The rows' shape, shared by their navigation and their renderer. - const paneInput = useMemo( - () => ({ - // The connected repositories, plus any the picked preset named that - // aren't among them — the rows are the whole truth, so a repo that will - // be cloned has to be visible (and uncheckable) even if it is no longer - // connected. - repoNames: [ - ...connectedRepoNames, - ...shownPane.repositories - .map((r) => r.fullName) - .filter((name) => !connectedRepoNames.includes(name)), - ], - // A checked repo grows its branch input row, so ↓ can land on it. - checkedRepoNames: shownPane.repositories.map((r) => r.fullName), - secretNames: secretNames ?? [], - variables: shownPane.variables, - builtInMcpServers: builtInServers, - mcpServers: shownPane.mcpServers, - }), - [connectedRepoNames, secretNames, shownPane, builtInServers], - ) - // Every field row, top to bottom, as ↑/↓ walk them. - const walk = useMemo(() => environmentPaneWalk(paneInput), [paneInput]) - // Whether a variable of this name is in the run, and at which value. - const variableFor = (name: string): CustomVariable | undefined => - shownPane.variables.find((v) => v.name === name) - // Enter (or →) on a field row: a repo, server or variable toggles; a - // variable already in the run re-opens for editing; the add buttons open a - // form; a script field opens its multi-line editor. The branch and compute - // rows are one-line inputs — enter there is a no-op, typing is what edits - // them. - const activateRow = (section: PaneSection, hover: number): void => { - const row = environmentSectionAt(paneInput, section, hover) - if (row.kind === 'image' || row.kind === 'hook') { - const scripts = row.kind === 'image' ? 'image' : 'hooks' - const text = shownPane[scripts][row.field as never] as string - setScriptEditor({ section: scripts, field: row.field, text, cursor: text.length }) - return - } - if (row.kind === 'repo') { - editPane((p) => ({ - ...p, - repositories: p.repositories.some((r) => r.fullName === row.fullName) - ? p.repositories.filter((r) => r.fullName !== row.fullName) - : [...p.repositories, { fullName: row.fullName, ref: null }], - })) - return - } - if (row.kind === 'mcpServer') { - editPane((p) => ({ - ...p, - mcpServers: p.mcpServers.some((s) => s.name === row.name) - ? p.mcpServers.filter((s) => s.name !== row.name) - : [...p.mcpServers, { name: row.name, command: null, url: null }], - })) - return - } - if (row.kind === 'addMcpServer') { - setServerEditor({ field: 'name', name: '', command: '', url: '', error: null }) - return - } - if (row.kind === 'variable') { - const held = variableFor(row.name) - // A valueless name is a plain checkbox — it ships the name alone and the - // sandbox resolves the value from stored secrets — so enter toggles it. - // One carrying a value opens for editing instead, since a value typed here - // is worth more than the keystroke it took and enter should not drop it. - if (held === undefined) { - editPane((p) => ({ ...p, variables: [...p.variables, { name: row.name, value: null }] })) - return - } - if (held.value === null) { - editPane((p) => ({ ...p, variables: p.variables.filter((v) => v.name !== row.name) })) - return - } - setEditor({ field: 'value', name: row.name, value: held.value, error: null }) - return - } - if (row.kind === 'addVariable') { - setEditor({ field: 'name', name: '', value: '', error: null }) - } - } - // Enter commits from either field. A name that isn't a legal shell identifier - // keeps the form open with the reason. An empty value means the sandbox - // resolves the name from stored variables. - const commitEditor = (): void => { - if (!editor) return - const parsed = parseVariableEntry(editor.name) - if ('error' in parsed) { - setEditor({ ...editor, error: parsed.error }) - return - } - // "NAME=value" typed into the name field carries its own value, so it wins - // over the (necessarily untouched) value field. - const value = parsed.value !== null ? parsed.value : editor.value === '' ? null : editor.value - editPane((p) => { - const next = [...p.variables] - const entry = { name: parsed.name, value } - // A repeat of a name already in the list replaces it in place, so the - // second typing of a name reads as an edit and not a duplicate row. - const existing = next.findIndex((v) => v.name === parsed.name) - if (existing !== -1) next[existing] = entry - else next.push(entry) - return { ...p, variables: next } - }) - setEditor(null) - } - // Typing on a checked repo's branch row edits its ref in place: backspace - // erases, an emptied ref returns to the default branch (null). - const editRepositoryRef = (fullName: string, edit: (ref: string) => string): void => { - editPane((p) => { - const existing = p.repositories.findIndex((r) => r.fullName === fullName) - const ref = edit(p.repositories[existing]?.ref ?? '') - const entry = { fullName, ref: ref === '' ? null : ref } - const next = [...p.repositories] - if (existing === -1) next.push(entry) - else next[existing] = entry - return { ...p, repositories: next } - }) - } - - // Enter with an empty prompt is a real start: the session comes up idle and - // waits for the first message, so you can open a sandbox before you know - // what to ask it. - // - // What ships is what the screen says: the checked preset by name (or `{}` - // for `empty`), or the rows whole once they have diverged. No preset checked - // means the list is still loading, and enter waits for it. - const submit = (): void => { - if (!isCustom && pickedPreset === undefined) return - const environment: ComposerEnvironment = isCustom - ? { kind: 'custom', pane: shownPane } - : pickedPreset!.id === EMPTY_ENVIRONMENT_ID - ? { kind: 'empty' } - : { kind: 'named', id: pickedPreset!.id } - onSubmit(text.trim(), { - environment, - ...composerModelChoice(modelOptions[modelIdx]), - }) - } - - // The rows below the prompt: the typed text filters the list live, so the - // prompt doubles as a search box over your recent sessions. - const shown = useMemo(() => filterSessions(sessions, text), [sessions, text]) - - // Where the list cursor sits in the current sort; the id survives the poll - // re-sorting rows, and a session that left the list snaps to the top. - const listIdx = - cursor.kind === 'list' ? Math.max(0, shown.findIndex((s) => s.id === cursor.id)) : 0 - - const toPromptWith = (ch: string): void => { - setCursor({ kind: 'prompt' }) - setText((t) => t.slice(0, textCursor) + ch + t.slice(textCursor)) - setTextCursor((c) => c + ch.length) - } - // The row above the model row, and the row below the preset row: the field - // walk when it has rows, else each other. - const lastPaneCursor = (): LauncherCursor => - walk.length > 0 ? { kind: 'pane', at: walk.length - 1 } : { kind: 'presets' } - const firstPaneCursor = (): LauncherCursor => - walk.length > 0 ? { kind: 'pane', at: 0 } : { kind: 'model' } - - useInput( - (ch, key) => { - // An open script field owns every key: a Dockerfile is written in lines, so - // enter is a newline here rather than the commit it is everywhere else, and - // esc is what commits and collapses the row. - if (scriptEditor !== null) { - const { text: script, cursor: at } = scriptEditor - const set = (next: string, cursorAt: number): void => - setScriptEditor({ ...scriptEditor, text: next, cursor: cursorAt }) - // Every keystroke writes through to the pane, so the row under the editor - // is never out of date with what is being typed. - const write = (next: string, cursorAt: number): void => { - set(next, cursorAt) - editPane((p) => ({ - ...p, - [scriptEditor.section]: { - ...p[scriptEditor.section], - [scriptEditor.field]: next, - }, - })) - } - if (key.escape) { - setScriptEditor(null) - return - } - if (key.return) { - write(script.slice(0, at) + '\n' + script.slice(at), at + 1) - return - } - if (key.leftArrow) { - set(script, Math.max(0, at - 1)) - return - } - if (key.rightArrow) { - set(script, Math.min(script.length, at + 1)) - return - } - // ↑/↓ move a line at a time, keeping the column where it can. - if (key.upArrow || key.downArrow) { - const lineStart = script.lastIndexOf('\n', at - 1) + 1 - const column = at - lineStart - if (key.upArrow) { - if (lineStart === 0) return - const prevStart = script.lastIndexOf('\n', lineStart - 2) + 1 - set(script, Math.min(prevStart + column, lineStart - 1)) - return - } - const lineEnd = script.indexOf('\n', at) - if (lineEnd === -1) return - const nextEnd = script.indexOf('\n', lineEnd + 1) - set(script, Math.min(lineEnd + 1 + column, nextEnd === -1 ? script.length : nextEnd)) - return - } - if (key.backspace || key.delete) { - if (at > 0) write(script.slice(0, at - 1) + script.slice(at), at - 1) - return - } - if (ch && !key.ctrl && !key.meta) { - write(script.slice(0, at) + ch + script.slice(at), at + ch.length) - } - return - } - // The "+ new server" form: like the variable form, it owns every key - // while up. ↑/↓ walk the three fields, enter commits, esc backs out. - if (serverEditor !== null) { - if (key.escape) { - setServerEditor(null) - return - } - if (key.return) { - const entry: CustomMcpServer = { - name: serverEditor.name.trim(), - command: serverEditor.command.trim() || null, - url: serverEditor.url.trim() || null, - } - const error = validateMcpServer(entry) - if (error) { - setServerEditor({ ...serverEditor, error }) - return - } - editPane((p) => ({ - ...p, - // A repeat of a name is an edit of it, not a duplicate row. - mcpServers: [...p.mcpServers.filter((s) => s.name !== entry.name), entry], - })) - setServerEditor(null) - return - } - const fields = SERVER_EDITOR_FIELDS - const at = fields.indexOf(serverEditor.field) - if (key.upArrow) { - setServerEditor({ ...serverEditor, field: fields[Math.max(0, at - 1)] }) - return - } - if (key.downArrow) { - setServerEditor({ - ...serverEditor, - field: fields[Math.min(fields.length - 1, at + 1)], - }) - return - } - const typed = serverEditor[serverEditor.field] - if (key.backspace || key.delete) { - setServerEditor({ ...serverEditor, [serverEditor.field]: typed.slice(0, -1), error: null }) - return - } - if (ch && !key.ctrl && !key.meta) { - setServerEditor({ ...serverEditor, [serverEditor.field]: typed + ch, error: null }) - } - return - } - // The variable form is the innermost modal: while it is up it owns every - // key, so space is typed text rather than list navigation. ↑/↓ move the - // caret between the two fields, enter commits, esc backs out. - if (editor !== null) { - if (key.escape) { - setEditor(null) - return - } - if (key.return) { - commitEditor() - return - } - if (key.upArrow) { - setEditor({ ...editor, field: 'name' }) - return - } - if (key.downArrow) { - setEditor({ ...editor, field: 'value' }) - return - } - const field = editor.field - const typed = field === 'name' ? editor.name : editor.value - if (key.backspace || key.delete) { - setEditor({ ...editor, [field]: typed.slice(0, -1), error: null }) - return - } - if (ch && !key.ctrl && !key.meta) { - setEditor({ ...editor, [field]: typed + ch, error: null }) - } - return - } - // The open model dropdown is a modal subtree: ↑/↓ walk its options, - // → (or enter/space) activates the highlighted one, ← (or esc) backs out. - if (openModel !== null) { - const optionCount = modelOptions.length - if (key.escape || key.leftArrow) { - setOpenModel(null) - return - } - if (key.upArrow) { - setOpenModel((p) => p && { hover: Math.max(0, p.hover - 1) }) - return - } - if (key.downArrow) { - setOpenModel((p) => p && { hover: Math.min(optionCount - 1, p.hover + 1) }) - return - } - if (key.rightArrow || key.return || ch === ' ') { - setModelPick(Math.min(openModel.hover, optionCount - 1)) - setOpenModel(null) - return - } - return - } - if (starting) return - // Word/line jumps and kills (option+←/→, ctrl+a/e/w/u/k, …) act on the - // prompt from anywhere in the launcher, dropping the cursor back onto it. - const edited = applyEditShortcut({ text, cursor: textCursor }, ch, key) - if (edited) { - setCursor({ kind: 'prompt' }) - setText(edited.text) - setTextCursor(edited.cursor) - return - } - // The preset row: ←/→ move the pick, ↑ returns to the prompt, ↓ walks - // into the field rows, esc returns to the prompt, typing does too. - if (cursor.kind === 'presets') { - if (key.leftArrow) { - pickPreset((checkedPresetIdx ?? presets.length) - 1) - return - } - if (key.rightArrow) { - if (checkedPresetIdx === undefined) pickPreset(0) - else if (!isCustom) pickPreset(checkedPresetIdx + 1) - return - } - if (key.upArrow) { - setCursor({ kind: 'prompt' }) - return - } - if (key.downArrow) { - setCursor(firstPaneCursor()) - return - } - if (key.escape) { - setCursor({ kind: 'prompt' }) - return - } - if (ch && ch !== ' ' && !key.ctrl && !key.meta && !key.return) toPromptWith(ch) - return - } - // A field row: ↑ off the first climbs to the preset row, ↓ off the last - // lands on the model row. The branch and compute rows are one-line - // inputs edited by typing, blank = whatever the server resolves. - if (cursor.kind === 'pane') { - // The walk shrank under the cursor (the repo list refetched, a repo - // unchecked): fall back onto the nearest row. - if (walk.length === 0) { - setCursor({ kind: 'presets' }) - return - } - const at = Math.min(cursor.at, walk.length - 1) - const step = walk[at] - if (key.upArrow) { - if (at === 0) setCursor({ kind: 'presets' }) - else setCursor({ kind: 'pane', at: at - 1 }) - return - } - if (key.downArrow) { - if (at >= walk.length - 1) setCursor({ kind: 'model' }) - else setCursor({ kind: 'pane', at: at + 1 }) - return - } - if (key.escape) { - setCursor({ kind: 'prompt' }) - return - } - const target = environmentSectionAt(paneInput, step.section, step.hover) - const typing = - ch && ch !== ' ' && !key.ctrl && !key.meta && !key.return && !key.rightArrow - if (target.kind === 'repoRef') { - if (key.backspace || key.delete) { - editRepositoryRef(target.fullName, (ref) => ref.slice(0, -1)) - return - } - if (typing) { - editRepositoryRef(target.fullName, (ref) => ref + ch) - return - } - } - if (target.kind === 'compute') { - if (key.backspace || key.delete) { - editPane((p) => ({ - ...p, - compute: { ...p.compute, [target.field]: p.compute[target.field].slice(0, -1) }, - })) - return - } - // cpu is a number on the wire, so its field only admits digits. - if (typing && (target.field !== 'cpu' || /^[0-9.]$/.test(ch))) { - editPane((p) => ({ - ...p, - compute: { ...p.compute, [target.field]: p.compute[target.field] + ch }, - })) - return - } - } - if (key.return || key.rightArrow || ch === ' ') { - activateRow(step.section, step.hover) - return - } - if (typing) toPromptWith(ch) - return - } - // The model row: →/enter opens its list on the checked row, so the walk - // starts where the run currently stands rather than at the top. - if (cursor.kind === 'model') { - if (key.upArrow) { - setCursor(lastPaneCursor()) - return - } - if (key.downArrow) { - if (!hideList && shown.length > 0) setCursor({ kind: 'list', id: shown[0].id }) - return - } - if (key.return || key.rightArrow) { - setOpenModel({ hover: modelIdx }) - return - } - if (key.escape) { - setCursor({ kind: 'prompt' }) - return - } - if (ch && !key.ctrl && !key.meta) toPromptWith(ch) - return - } - if (cursor.kind === 'list') { - // The session rows at the bottom: ↑/↓ walk them (↑ off the top climbs - // back to the model row), enter opens the highlighted session. - if (key.upArrow) { - if (listIdx <= 0) setCursor({ kind: 'model' }) - else setCursor({ kind: 'list', id: shown[listIdx - 1].id }) - return - } - if (key.downArrow) { - if (listIdx < shown.length - 1) - setCursor({ kind: 'list', id: shown[listIdx + 1].id }) - return - } - if (key.return) { - const picked = shown[listIdx] - if (picked) onOpenSession(picked.id) - return - } - if (key.escape) { - setCursor({ kind: 'prompt' }) - return - } - if (ch && !key.ctrl && !key.meta) toPromptWith(ch) - return - } - // At the prompt box: ↓ lands on the preset row just under it. - if (key.return) { - submit() - return - } - if (key.downArrow) { - setCursor({ kind: 'presets' }) - return - } - if (key.leftArrow) { - setTextCursor((c) => Math.max(0, c - 1)) - return - } - if (key.rightArrow) { - setTextCursor((c) => Math.min(text.length, c + 1)) - return - } - if (key.backspace || key.delete) { - if (textCursor > 0) { - setText((t) => t.slice(0, textCursor - 1) + t.slice(textCursor)) - setTextCursor((c) => c - 1) - } - return - } - // esc clears the typed text — the one-key way out of a filter that - // matched nothing. - if (key.escape) { - setText('') - setTextCursor(0) - return - } - if (key.ctrl || key.meta) return - if (ch) { - setText((t) => t.slice(0, textCursor) + ch + t.slice(textCursor)) - setTextCursor((c) => c + ch.length) - } - }, - { isActive: focused && rawMode }, - ) - - // Columns available inside the prompt box: the terminal minus its left accent - // bar and the interior padding on both sides. - const contentWidth = Math.max(1, width - 1 - PROMPT_PAD_X * 2) - const modelHover = openModel ? Math.min(openModel.hover, modelOptions.length - 1) : -1 - // Every option plus its group heading — the open dropdown prints the whole - // list, so a long model list grows the block and the terminal scrolls rather - // than hiding rows behind a window. - const modelRows = openModel ? composerPickerRows(modelOptions) : [] - // The price table's column widths: the label column, then one numeric column - // per lane, each as wide as its widest cell (its heading included) so the - // dollars read down a right-aligned column. null when there is no price to - // quote, or when the terminal is too narrow to hold the whole table — a - // padded row would push the last column off the right edge. - const rateTable = (() => { - if (!openModel || !modelOptions.some((o) => o.rate)) return null - const label = Math.max(...modelOptions.map((o) => o.label.length)) - const input = Math.max(2, ...modelOptions.map((o) => (o.rate?.input ?? '').length)) - const output = Math.max(3, ...modelOptions.map((o) => (o.rate?.output ?? '').length)) - const total = OPTION_GUTTER + label + 2 + input + 2 + output + RATE_UNIT.length - return total <= width ? { label, input, output } : null - })() - // A row's price cells, right-aligned into the table's columns. A model the - // server quoted no card for prints blank cells rather than shifting the ones - // below it out of column. - const rateCells = (rate: ComposerModel['rate']): string => - rateTable - ? ' ' + - (rate?.input ?? '').padStart(rateTable.input) + - ' ' + - (rate?.output ?? '').padStart(rateTable.output) - : '' - - // The gutter every row under the prompt box shares: the ▶ moves down one - // left edge, one column in from the terminal's. - const gutter = (on: boolean): React.ReactNode => ( - - {' '} - {on ? SELECTION_GLYPH : ' '}{' '} - - ) - // The blank a continuation line prints under a field row's label column. - const fieldContinuation = ' '.repeat(3 + FIELD_INDENT.length + FIELD_LABEL_WIDTH) - - // A field row's content, after its label column. The label prints on the - // section's first row only; the rest of the section's rows leave the column - // blank so the values read as one list. - const renderField = (step: PaneWalkStep, at: number): React.ReactNode => { - const paneRow = environmentSectionRows(paneInput, step.section)[step.hover] - if (!paneRow) return null - // A form owns the caret while it is open, so the row's own highlight goes - // dark rather than showing a second one. - const hovered = - focused && - cursor.kind === 'pane' && - Math.min(cursor.at, walk.length - 1) === at && - editor === null && - serverEditor === null && - scriptEditor === null - const label = step.first ? PANE_SECTION_LABELS[step.section] : '' - const prefix = ( - - {gutter(hovered)} - {FIELD_INDENT} - {label.padEnd(FIELD_LABEL_WIDTH)} - - ) - const key = `${step.section}:${step.hover}` - if (paneRow.kind === 'repo') { - const checked = repositoryFor(paneRow.fullName) !== undefined - return ( - - - {prefix} - - {`[${checked ? 'x' : ' '}] ${paneRow.fullName}`} - - - - ) - } - // A checked repo's branch input: typing edits the ref in place; empty rests - // on the repo's default branch. - if (paneRow.kind === 'repoRef') { - const ref = repositoryFor(paneRow.fullName)?.ref ?? null - const resting = repositoryRefLabel( - ref, - (repos ?? []).find((r) => r.full_name === paneRow.fullName)?.default_branch ?? null, - ) - return ( - - - {prefix} - {/* Aligned under the repo name, past its checkbox. */} - {' branch: '} - {ref !== null ? ( - - {ref} - {hovered && } - - ) : ( - // The default branch as a placeholder: it is what an untouched - // row clones, and typing replaces it. - - {hovered && resting ? ( - - {resting[0]} - {resting.slice(1)} - - ) : ( - (resting ?? '') - )} - - )} - - - ) - } - // A compute field: a one-line input like a branch row, blank meaning - // whatever the server resolves. - if (paneRow.kind === 'compute') { - const held = shownPane.compute[paneRow.field] - return ( - - - {prefix} - {`${paneRow.field}: `} - - {held} - {hovered && } - - - - ) - } - // An image or hook field: a script, so it prints as a YAML block scalar - // over its own lines rather than crushed onto the label's line. Long ones - // are capped and say how many lines they are hiding, since a hidden line - // is a hidden instruction to the sandbox. Enter opens it (scriptEditor), - // which prints every line and puts a real caret in the text. - if (paneRow.kind === 'image' || paneRow.kind === 'hook') { - const section = paneRow.kind === 'image' ? 'image' : 'hooks' - const editing = scriptEditor?.section === section && scriptEditor.field === paneRow.field - const held = editing - ? scriptEditor.text - : (shownPane[section][paneRow.field as never] as string) - const { lines, hidden } = scriptRowLines(held, editing) - return ( - - - - {prefix} - {`${paneRow.field}:`} - {/* The block-scalar marker, so a multi-line value reads the way - it would in the environment YAML. */} - {held === '' ? '' : ' |'} - {held === '' && (hovered || editing) && } - - - {held !== '' && - lines.map((line, i) => { - // The caret sits in the open editor's text, at the line and - // column it is actually on. - const before = lines.slice(0, i).reduce((n, l) => n + l.length + 1, 0) - const column = editing ? scriptEditor.cursor - before : -1 - const here = editing && column >= 0 && column <= line.length - return ( - - - {fieldContinuation} - {' '} - - {here ? ( - - {line.slice(0, column)} - {line[column] ?? ' '} - {line.slice(column + 1)} - - ) : ( - line - )} - - - - ) - })} - {hidden > 0 && ( - - - {fieldContinuation} - {` … ${hidden} more line${hidden === 1 ? '' : 's'}`} - - - )} - - ) - } - if (paneRow.kind === 'mcpServer') { - const checked = shownPane.mcpServers.some((s) => s.name === paneRow.name) - return ( - - - {prefix} - - {`[${checked ? 'x' : ' '}] ${paneRow.name}`} - - - - ) - } - if (paneRow.kind === 'variable') { - const held = variableFor(paneRow.name) - return ( - - - {prefix} - - {`[${held !== undefined ? 'x' : ' '}] ${variableRowLabel(paneRow.name, held?.value)}`} - - - - ) - } - // The two "+ new" buttons, each growing its form under itself while open. - // A form's fields sit indented under the button text, the caret on the - // field being typed. - const isServer = paneRow.kind === 'addMcpServer' - const form = isServer ? serverEditor : editor - const fields: readonly string[] = isServer ? SERVER_EDITOR_FIELDS : ['name', 'value'] - const ghostFor = (field: string): string => { - if (!isServer) return field === 'name' ? NAME_PLACEHOLDER : VALUE_PLACEHOLDER - // name is required; command/url pick the type, so their placeholders - // say the either/or. - return field === 'name' - ? 'my-tools' - : field === 'command' - ? 'stdio: npx -y my-tools-mcp' - : 'remote: https://mcp.example.com' - } - return ( - - - - {prefix} - - {isServer ? ADD_MCP_SERVER_LABEL : ADD_VARIABLE_LABEL} - - - - {form !== null && ( - - {fields.map((field) => { - const here = form.field === field - const typed = (form as unknown as Record)[field] - const ghost = typed !== '' ? null : ghostFor(field) - return ( - - - {gutter(here)} - {FIELD_INDENT} - {' '.repeat(FIELD_LABEL_WIDTH)} - {` ${field}: `} - {typed} - {ghost ? ( - - {here ? ( - {ghost[0]} - ) : ( - {ghost[0]} - )} - {ghost.slice(1)} - - ) : ( - here && - )} - - - ) - })} - {form.error !== null && ( - - - {fieldContinuation} - {' '} - {form.error} - - - )} - - )} - - ) - } - - const caretVisible = focused && cursor.kind === 'prompt' && !starting && openModel === null - const listWin = navSlice(shown.length, LIST_ROWS, listIdx) - const showList = !hideList - // The bottom line is for news only — the prompt box's hint already carries the - // key map, so there is nothing routine to print here. - const statusLine = armed ? CTRL_C_QUIT_HINT : starting ? '✻ Starting session…' : null - const presetsActive = focused && cursor.kind === 'presets' - const modelActive = focused && cursor.kind === 'model' && openModel === null - // What the row under the presets says about the checked one: the account - // default and/or where its file lives; for `custom`, which preset it was - // edited from. Nothing while the default is still unknown. - const presetNote = isCustom - ? pickedPreset - ? `edited from ${pickedPreset.name}` - : null - : (pickedPreset?.note ?? null) - - return ( - - {/* The prompt box: the chat composer's painted slab, with an accent bar - down its left edge and a blank row of padding above and below the - prompt row. */} - - - - - {focused && cursor.kind === 'prompt' ? SELECTION_GLYPH : ' '} - - - {/* Takes the columns the gutter left, so a long prompt wraps inside - the box instead of running past its right edge. The key remounts - the node so a stale measurement can't misplace the caret. */} - - - {/* The row's persistent label: typed text sits after it, the - hint only fills the empty input. */} - {PROMPT_LABEL.toUpperCase()}: - {text.slice(0, textCursor)} - {caretVisible && text !== '' && ( - {textCursor < text.length ? text[textCursor] : ' '} - )} - {textCursor < text.length ? text.slice(textCursor + (caretVisible ? 1 : 0)) : ''} - {/* Empty input: the hint sits where typed text will land, its - first character carrying the caret (inverse) instead of a - caret cell of its own pushing it a column right. */} - {text === '' && caretVisible && ( - - {PROMPT_HINT[0]} - {PROMPT_HINT.slice(1)} - - )} - {text === '' && !caretVisible && {PROMPT_HINT}} - - - - - - {/* The preset row: the saved environments laid out side by side, the - checked one bracketed. Presets wrap onto further lines when the row - runs out of width, so every one stays reachable. */} - - - - {gutter(presetsActive)} - - {ENVIRONMENT_LABEL.toUpperCase().padEnd(HEADER_LABEL_WIDTH)} - - - - {presetRow.map((p, at) => { - const checked = at === checkedPresetIdx - return ( - - - {checked ? `▸${p.name}◂` : ` ${p.name} `} - - - ) - })} - - {presetNote && ( - - - {' '.repeat(3 + HEADER_LABEL_WIDTH)} - {presetNote} - - - )} - {/* The field rows: what the checked preset resolves to, every section - open, edited in place. */} - {walk.map((step, at) => renderField(step, at))} - {/* The model row, opening its dropdown under itself. */} - - - - {gutter(modelActive)} - {MODEL_LABEL.toUpperCase().padEnd(HEADER_LABEL_WIDTH)} - - {modelOptions[modelIdx]?.label ?? 'Default'} - - - - {/* The price table's column heads, over the numeric columns they - name, with the unit stated once here instead of on every row. */} - {openModel !== null && rateTable && ( - - - {' '.repeat(OPTION_GUTTER + rateTable.label)} - {rateCells({ input: 'IN', output: 'OUT' })} - {RATE_UNIT} - - - )} - {openModel !== null && - modelRows.map((pickerRow) => { - if (pickerRow.kind === 'group') { - return ( - - - {' '} - {pickerRow.label.toUpperCase()} - - - ) - } - const at = pickerRow.at - const opt = modelOptions[at] - if (!opt) return null - const hovered = at === modelHover - const picked = at === modelIdx - return ( - - - {' '} - {hovered ? SELECTION_GLYPH : ' '}{' '} - - {`[${picked ? 'x' : ' '}] ${rateTable ? opt.label.padEnd(rateTable.label) : opt.label}`} - - {/* The prices, always muted — the table's numbers next to - the id whether or not the row is the highlighted one, so - walking the list never moves the eye off the name. */} - {rateCells(opt.rate)} - - - ) - })} - - {/* Two blank rows splitting what you are about to start from what you have - already run. */} - - - {/* The list's own heading, with who you are on the same row's right edge: - both are true of every row under them and neither is something you act - on. */} - - - - {showList ? ' RECENT SESSIONS:' : ' '} - - - - {whoLine} - - - {/* The latest sessions: status dot + description + a dim meta tag, in - sortSidebarSessions order (status band, newest first), windowed so the - highlight parks two rows from the bottom and the list scrolls under - it. */} - {showList && - shown.slice(listWin.start, listWin.end).map((s) => { - const word = rowStatusWord(s) - const g = rowGlyph(word) - const cursorHere = focused && cursor.kind === 'list' && shown[listIdx]?.id === s.id - const desc = rowDescription(s) - // The meta tag rides the right edge; the description takes what's - // left and truncates, so a long prompt can never push the tag off - // the row. - const meta = `${rowMeta(s)}${attention.has(s.id) ? ', needs you' : ''}` - const descW = Math.max(8, width - meta.length - 8) - return ( - - - - {cursorHere ? SELECTION_GLYPH : g.glyph} - - - - - - {desc.slice(0, descW)} - - - - - {meta} - - - ) - })} - {showList && shown.length === 0 && ( - - {' '} - {!polledOnce - ? 'loading sessions…' - : sessions.length === 0 - ? 'no sessions yet' - : 'no matching sessions'} - - )} - {/* An API failure gets its own line — a swallowed error is an empty - list with no explanation. */} - {error && ( - - {`✗ ${error}`} - - )} - {statusLine && ( - - {statusLine} - - )} - - ) -} diff --git a/src/ui/commands.ts b/src/ui/commands.ts deleted file mode 100644 index 6eb12bc..0000000 --- a/src/ui/commands.ts +++ /dev/null @@ -1,91 +0,0 @@ -// The composer's slash commands, and the autocomplete menu over them. -// -// A slash command is an instruction to the CLI, not a message to the agent, so -// the two are kept apart by a rule rather than by guesswork: a line whose FIRST -// character is `/` is a command, and an unknown one is refused instead of being -// forwarded. Silently sending "/stpo" to the agent as prose is the failure mode -// that rule exists to prevent. - -export type CommandId = 'stop' | 'image' | 'sessions' | 'exit' - -export type SlashCommand = { - id: CommandId - // What you type, without the leading slash. - name: string - // Extra spellings that select the same command. Not shown in the menu. - aliases?: readonly string[] - // The one-line description in the menu, lowercase and imperative. - detail: string - // Whether the rest of the line after the name is the command's argument - // (`/image shot.png`). Without it, anything after the name is a typo. - takesArgument?: boolean -} - -// Every command, in menu order: the one that acts on the session first, then the -// screen, then the way out. -export const SLASH_COMMANDS: readonly SlashCommand[] = [ - { id: 'stop', name: 'stop', detail: 'interrupt the agent, keeping the conversation' }, - { - id: 'image', - name: 'image', - detail: 'attach an image file to your next message: /image ', - takesArgument: true, - }, - { id: 'sessions', name: 'sessions', detail: 'switch sessions (esc)' }, - { id: 'exit', name: 'exit', aliases: ['quit'], detail: 'leave the CLI; the session keeps running' }, -] - -// Whether this input is addressed to the CLI rather than the agent. Deliberately -// strict about position: a `/` anywhere else is ordinary prose ("check /tmp"), -// and only a leading one claims the line. Pure, for tests. -export function isCommandInput(text: string): boolean { - return text.startsWith('/') -} - -// The commands a partially typed `/word` should offer, in menu order. -// -// PREFIX matching, not fuzzy or substring: the list is short and the names are -// short, so a prefix is enough to get you there in two or three keystrokes, and -// it never surprises you with a command whose name you did not start typing. -// Aliases match too (so `/qu` finds exit) but the canonical name is what shows. -// A bare `/` offers everything. Pure, for tests. -export function matchCommands(text: string): SlashCommand[] { - if (!isCommandInput(text)) return [] - const typed = text.slice(1).toLowerCase() - // Only the first word is the command; once you have typed a space you have - // committed to one, and the menu has nothing left to offer. - if (/\s/.test(typed)) return [] - if (typed === '') return [...SLASH_COMMANDS] - return SLASH_COMMANDS.filter((c) => - [c.name, ...(c.aliases ?? [])].some((n) => n.startsWith(typed)), - ) -} - -// The command an entered line names, or null when it names none — which the -// caller reports as an error rather than sending on. Exact match on the name or -// an alias, case-insensitively; a prefix is NOT enough here, because running the -// wrong command is worse than being told to finish typing. Pure, for tests. -export function resolveCommand(text: string): SlashCommand | null { - if (!isCommandInput(text)) return null - const trimmed = text.slice(1).trim() - const [word, ...rest] = trimmed.split(/\s+/) - const typed = (word ?? '').toLowerCase() - const command = - SLASH_COMMANDS.find((c) => [c.name, ...(c.aliases ?? [])].includes(typed)) ?? null - if (command === null) return null - // Trailing words are only meaningful to a command that takes an argument. - if (rest.length > 0 && !command.takesArgument) return null - return command -} - -// The argument after a command's name (`/image shot.png` → `shot.png`), or -// '' when there is none. Pure, for tests. -export function commandArgument(text: string): string { - return text.slice(1).trim().replace(/^\S+\s*/, '') -} - -// The text the composer holds after tab/enter completes `highlighted`: the whole -// command, with a trailing space so the line reads as finished. Pure, for tests. -export function completedText(command: SlashCommand): string { - return `/${command.name} ` -} diff --git a/src/ui/ctrlC.ts b/src/ui/ctrlC.ts deleted file mode 100644 index 63d9341..0000000 --- a/src/ui/ctrlC.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useEffect, useState } from 'react' -import { useApp, useInput } from 'ink' - -// ctrl+c, everywhere in the UI: the first press interrupts (whatever the pane -// decides that means — the chat stops a running turn) and arms the quit, the -// second press exits, any other key disarms. Ink's own exitOnCtrlC is turned -// off at both render calls so this runs instead of an immediate teardown: a -// running agent should be interruptible without ending the session. -// -// Every pane that owns the keyboard mounts this, and only the pane with focus -// is active — so the armed flag is per-pane, and one press can't arm a handler -// that a later press won't reach. Returns whether the quit is armed, for the -// pane to prompt with. The arm expires on its own after a few seconds, so the -// prompt never lingers and a much-later ctrl+c interrupts again instead of -// quitting. -const ARM_MS = 3000 - -export function useCtrlCQuit(active: boolean, onInterrupt?: () => void): boolean { - const { exit } = useApp() - const [armed, setArmed] = useState(false) - useEffect(() => { - if (!armed) return - const t = setTimeout(() => setArmed(false), ARM_MS) - return () => clearTimeout(t) - }, [armed]) - useInput( - (ch, key) => { - if (key.ctrl && ch === 'c') { - if (armed) exit() - else { - setArmed(true) - onInterrupt?.() - } - return - } - if (armed) setArmed(false) - }, - { isActive: active }, - ) - return active && armed -} - -export const CTRL_C_QUIT_HINT = 'press ctrl+c again to exit' diff --git a/src/ui/launch.tsx b/src/ui/launch.tsx deleted file mode 100644 index 75122c0..0000000 --- a/src/ui/launch.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React from 'react' -import { render } from 'ink' -import { api } from '../lib/api' -import { requireToken, resolveApiBase, resolveAppBase, sessionBar } from '../lib/config' -import { repoFromCwd } from '../lib/git' -import { makeOpenSocket, resolveWsBase } from '../lib/stream' -import { applyDetectedThemeMode } from '../lib/terminalBackground' -import type { StartAgentSessionRequest } from '../lib/types' -import { withContextRepository, withSessionPrompt } from '../lib/sessions' -import { SessionsApp } from './SessionsApp' - -// Launches the multi-session UI (sidebar + chat) — the shared destination of -// a bare `agent`, `agent "prompt"`, and `agent session connect `. The -// caller decides what the main pane opens on (a focused session or the -// new-session composer) and how composer-spawned sessions start. - -export interface SessionsUiOptions { - // Focus this session's chat on open; omit for the new-session composer. - initialSessionId?: string - // The start response's resolved config name + a caveat for that chat. - initialConfigName?: string - initialNotice?: string - // Builds the POST /sessions request for a composer-spawned session; the - // typed text rides as the prompt. Entry points bake their flags in here. - buildStartRequest: (prompt: string) => StartAgentSessionRequest -} - -// Whether this invocation can host the interactive multi-session UI at all: -// it needs a real TTY on both ends (raw-mode keyboard + a sized window). -// Headless callers (scripts, sandboxes, --no-input) keep the solo renderer. -export function canHostSessionsUi(): boolean { - return Boolean(process.stdin.isTTY && process.stdout.isTTY) -} - -// The composer-spawned session's start request when the entry point brings -// no flags of its own (connect, and the composer inside a prompt-shorthand -// UI): nothing but the detected repository, added to whichever environment -// the session resolves to, exactly like a bare `agent` start. -// -// An empty prompt starts the session idle instead: the sandbox spins up and -// Claude Code waits at its prompt, so the first composer message opens turn 0 -// (a local `claude` with no argument). -export function defaultStartRequest(prompt: string): StartAgentSessionRequest { - // A promptless start opens idle by definition (the server-side contract - // since #6394): Claude Code waits at its prompt for the first message. - let req: StartAgentSessionRequest = { claude_code: {} } - if (prompt) req = withSessionPrompt(req, prompt) - const contextRepo = repoFromCwd(process.cwd()) - if (contextRepo) req = withContextRepository(req, contextRepo) - return req -} - -export async function runSessionsUi(options: SessionsUiOptions): Promise { - const client = api() - const token = requireToken() - const openSocket = makeOpenSocket(token, resolveWsBase(resolveApiBase())) - // Pick the palette for this terminal's background before the first frame. - const [me] = await Promise.all([client.identity(), applyDetectedThemeMode()]) - - // No screen-clearing dance: the chat prints its settled transcript into THIS - // terminal's scrollback (see ConnectApp), so the conversation grows down the - // terminal from wherever the shell prompt left off, like ordinary command - // output. Homing the cursor would throw away the scrollback it relies on. - const app = render( - React.createElement(SessionsApp, { - api: client, - openSocket, - appBase: resolveAppBase(), - customerLogin: me.customer_login, - ghLogin: me.gh_user?.login ?? null, - authorId: me.gh_user?.id ?? null, - detectedRepo: repoFromCwd(process.cwd()) ?? null, - initialSessionId: options.initialSessionId, - initialConfigName: options.initialConfigName, - initialNotice: options.initialNotice, - sessionBar: sessionBar(), - buildStartRequest: options.buildStartRequest, - }), - // exitOnCtrlC off: the app handles ctrl+c itself (first press interrupts a - // running turn, second quits), which ink's default teardown would preempt. - { exitOnCtrlC: false }, - ) - // Same revoked-TTY guard as the solo connect: when the terminal is torn - // down abruptly, stdin's fd stays open but polls fire forever; unmount on - // the stream's failure events and on reparenting to init. - const detach = (): void => app.unmount() - process.stdin.on('error', detach) - process.stdin.on('end', detach) - process.stdin.on('close', detach) - const orphanWatch = setInterval(() => { - if (process.ppid === 1) app.unmount() - }, 2000) - orphanWatch.unref() - try { - await app.waitUntilExit() - } finally { - clearInterval(orphanWatch) - process.stdin.off('error', detach) - process.stdin.off('end', detach) - process.stdin.off('close', detach) - } -} diff --git a/src/ui/transcriptRows.ts b/src/ui/transcriptRows.ts deleted file mode 100644 index def43b6..0000000 --- a/src/ui/transcriptRows.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { - BRANCH_GLYPH, - clampLines, - gutterFor, - isToolActivity, - isToolFold, - LIVE_GLYPH, - USER_BAR, - type ItemKind, - type TranscriptItem, -} from '@ellipsis-dev/sdk/store' - -// The layout itself — which line wears which mark, what nests under what — -// is the SDK's (@ellipsis-dev/sdk/store layout.ts), shared with the -// dashboard's chat so the two can only disagree in colour and typography. -// Re-exported here for the callers that grew up importing them from this file. -export { - BRANCH_GLYPH, - gutterFor, - isToolActivity, - layOutItems, - LIVE_GLYPH, - USER_BAR, - type PlacedItem, -} from '@ellipsis-dev/sdk/store' -import { fitLines, hasMarkdown, renderMarkdown, visibleWidth } from '../lib/markdown' -import { theme } from '../lib/theme' - -// The transcript as a flat list of SCREEN ROWS. Everything on screen (the -// startup block, messages, tool chatter, in-flight sends, the live activity -// lines) is flattened to rows before it renders, each row exactly one terminal -// line tall and no wider than the pane. -// -// Rows, not entries, because the live frame is capped in ROWS: an entry-granular -// cap could only keep or drop a whole message, so one long message would either -// overflow the frame or vanish from it. -// -// Rows are also EXACT: text is pre-wrapped here at the width it will occupy -// (fitLines) and the renderer truncates instead of wrapping, so a slice of N rows -// always paints N lines. That is what lets the live frame be budgeted precisely — -// an over-tall frame scrolls ink's render region and smears stale rows. - -// The 2-column gutter a transcript line reserves for its sender glyph (▶/●/⎿), -// so a wrapped line's continuation aligns under its first. -export const GUTTER_COLS = 2 - -// Horizontal pad on EVERY transcript row — the text sits one cell off the -// pane's edge, like the composer's interior, so your messages and the agent's -// share one left edge instead of stepping in and out by a column. -export const MESSAGE_PAD = 1 - -// Long bodies collapse to this many lines. Nothing un-collapses them: the full -// text printed into the terminal's scrollback on its way past, which is where it -// is read. -const COLLAPSE_LINES = 6 - -// A run of same-styled text inside a row. Rows carry spans rather than one -// string because a single line mixes styles: a green ✓ before dim prose, a -// bold tool name before its dim "(3 files)" detail. -export type RowSpan = { - text: string - color?: string - dim?: boolean - bold?: boolean - // This span is a LIVE mark: it pulses while the work it describes is in - // flight (see TranscriptRow.pulse and LIVE_GLYPH). - pulse?: boolean -} - -// The colour a span actually paints in. Every span resolves to a brand hex — -// there is no "unstyled" span and no `dimColor` — because neither of the two -// things a bare span would fall back on belongs to us: -// -// * NO COLOUR MEANS THE TERMINAL'S COLOUR, which is a colour we do not know: -// most spans carry none of their own (the assistant's prose included, via -// styleFor), so leaving them bare hands the bulk of the transcript to -// whatever the user's theme happens to be. See theme.ts. -// * DIM IS OPTIONAL, as far as terminals are concerned: \x1b[2m is dropped -// outright by a fair number of them once a 24-bit foreground is also set. -// That is the same reason a pulsing mark SWAPS its colour on the off beat -// instead of dimming — bone → grey is a real colour change, so it reads -// everywhere. `dim` resolving to `muted` applies that everywhere else. -export function spanColor( - span: Pick, - pulseOn: boolean, -): string { - if (span.pulse && !pulseOn) return theme.muted - if (span.color) return span.color - return span.dim ? theme.muted : theme.foreground -} - -export type TranscriptRow = { - // Unique per row, for React keys. - id: string - // The entry (a transcript item's key, or 'sandbox') this row belongs to: the - // unit the scrollback flush is decided by, so an entry is printed once, whole. - entryKey: string - // The gutter glyph, set on an entry's FIRST row only — a multi-row item - // shows one sender icon, and its continuation rows align under it. - gutter?: RowSpan - // Blank columns before the gutter: a nested tool line sits one level in, so it - // reads as a branch off the message above. - indent?: number - // Extra blank columns BETWEEN the gutter and the text. Set on every row of a - // ⎿ item, continuation rows included, so the whole body stays aligned — see - // BRANCH_TEXT_PAD. - textPad?: number - spans: RowSpan[] - // Right-aligned metadata (a ticking duration, a pipeline state). The row's - // spans are fitted to the columns left over. - right?: RowSpan - // A blank separator row: the gap between blocks. - spacer?: boolean - // The "+N lines" count on the marker row under a clamped body. - clampedLines?: number - // A ticking duration, appended to the row's text at render time. Kept out of - // the row's spans so the once-a-second tick repaints one line instead of - // rebuilding (and re-wrapping) the entire transcript. - tick?: 'elapsed' | 'tool' - // This row contains a pulsing mark (its gutter, or one of its spans). Like - // `tick`, the phase is resolved at RENDER time — the row only records that it - // has one — so the blink repaints a glyph instead of rebuilding the - // transcript's rows, and rows without one are memoized past it entirely. - pulse?: boolean -} - -// Extra columns between a ⎿ and its text. The glyph's ink runs right up to its -// cell's edge, so the one space every other mark gets is not enough — the body -// reads as touching the branch. Applied to every row of the item, so a wrapped -// result and its "+N lines" marker stay aligned under the first line. -export const BRANCH_TEXT_PAD = 1 - -// Columns a nested line shifts right, so its branch glyph sits under the -// parent's text rather than under the parent's own mark. -export const NEST_INDENT = 2 - -// Printable columns a row's text may occupy in a pane `cols` wide. -export function contentWidth( - cols: number, - opts: { indent?: number; textPad?: number } = {}, -): number { - const taken = MESSAGE_PAD * 2 + GUTTER_COLS + (opts.indent ?? 0) + (opts.textPad ?? 0) - return Math.max(8, cols - taken) -} - -export function spacerRow(entryKey: string, id: string): TranscriptRow { - return { id, entryKey, spans: [], spacer: true } -} - -// One transcript item as its screen rows: the separator above it, its body -// pre-wrapped to the column it occupies, and the "+N lines" marker when a long -// body is clamped. -export function itemRows( - item: TranscriptItem, - cols: number, - opts: { indent?: number; nested?: boolean; attach?: boolean } = {}, -): TranscriptRow[] { - const indent = opts.indent ?? 0 - // Nested lines are marked by their INDENT, so each keeps the glyph that says - // what it is: ● the call, ⎿ the result that came back. A collapsed fold (key - // grp:*, "Ran 2 tool calls") takes the branch glyph only when it hangs off a - // message — a turn-opening run branches off nothing, so it wears ● like any - // other line the agent owns. Either way not ✦: that mark is the - // infrastructure speaking, which a fold is not. - const isFold = isToolFold(item) - const gutter = isFold ? (opts.nested ? BRANCH_GLYPH : '●') : gutterFor(item) - const textPad = gutter === BRANCH_GLYPH ? BRANCH_TEXT_PAD : 0 - const width = contentWidth(cols, { indent, textPad }) - const shown = withRenderedMarkdown(item, width) - const clamped = isCollapsible(shown) - ? clampLines(shown.text, COLLAPSE_LINES) - : { body: shown.text, more: 0 } - const { gutterColor, textColor, dim, bold } = styleFor(shown) - - const rows: TranscriptRow[] = [] - // `attach` overrides the item's own spacing: a nested line sits directly - // under its parent, with no blank row to detach it. - if (item.spaceBefore && !opts.attach) rows.push(spacerRow(item.key, `${item.key}:sp`)) - let bodyRows = 0 - const push = (spans: RowSpan[], extra: Partial = {}): void => { - rows.push({ - id: `${item.key}:r${rows.length}`, - entryKey: item.key, - gutter: - bodyRows++ === 0 || gutter === USER_BAR - ? { text: gutter, color: gutterColor, dim: dim && !shown.isError } - : undefined, - indent, - textPad, - spans, - ...extra, - }) - } - - const lines = fitLines(clamped.body, width) - for (const [i, line] of lines.entries()) { - // The detail ("(3 files)") trails the body's last line when it fits, and - // takes rows of its own when it doesn't. - const detail = i === lines.length - 1 && shown.detail ? shown.detail : null - if (detail && visibleWidth(line) + visibleWidth(detail) <= width) { - push([{ text: line, color: textColor, dim, bold }, { text: detail, dim: true }]) - } else { - push([{ text: line, color: textColor, dim, bold }]) - if (detail) for (const l of fitLines(detail, width)) push([{ text: l, dim: true }]) - } - } - if (clamped.more > 0) push([], { clampedLines: clamped.more }) - return rows -} - -// A live status line — "Generating…", "Running Bash(pytest…)…" — with its -// ticking readout in the right-hand metadata column. `hug` drops the spacer -// above so the line reads as part of the tool burst it belongs to. The duration is a `tick` marker rather -// than text: it changes every second, and baking it in here would re-wrap the -// transcript once a second. -export function activityRows( - key: string, - label: string, - tick: 'elapsed' | 'tool', - suffix: string, - cols: number, - hug: boolean, - // The line describes a TOOL CALL in flight, so it nests under the message - // that made the call, exactly where its ⎿ result will land a moment later. A - // "Generating…"/"Working…" line describes the message itself and stays flat. - nested = false, -): TranscriptRow[] { - const indent = nested ? NEST_INDENT : 0 - // Reserve the widest the readout gets ("1h 3m 30s, 12.3k tokens") so the - // label doesn't reflow as the clock ticks. - const width = Math.max(8, contentWidth(cols, { indent }) - visibleWidth(suffix) - 16) - const rows: TranscriptRow[] = hug || nested ? [] : [spacerRow(key, `${key}:sp`)] - rows.push({ - id: `${key}:r`, - entryKey: key, - gutter: { text: LIVE_GLYPH, color: theme.foreground, pulse: true }, - indent, - spans: [{ text: fitLines(label, width)[0] ?? '', dim: true }], - right: { text: suffix, dim: true }, - tick, - pulse: true, - }) - return rows -} - -// An in-flight send, or the streaming assistant response: laid out exactly like -// the committed record it becomes, so nothing shifts when that record lands. -// `pulse` marks the send as still in flight — the same breathing ⏺ a running -// tool wears, so a message the agent hasn't answered yet never reads as settled -// conversation. -export function pendingMessageRows( - key: string, - text: string, - cols: number, - opts: { - gutter: string - gutterColor?: string - dim?: boolean - bold?: boolean - right?: string - pulse?: boolean - }, -): TranscriptRow[] { - const width = contentWidth(cols) - const rows: TranscriptRow[] = [spacerRow(key, `${key}:sp`)] - const lines = fitLines(text, width) - for (const [i, line] of lines.entries()) { - rows.push({ - id: `${key}:r${i}`, - entryKey: key, - gutter: - (i === 0 || opts.gutter === USER_BAR) && opts.gutter - ? { - text: opts.gutter, - color: opts.gutterColor ?? theme.foreground, - dim: opts.dim, - pulse: opts.pulse, - } - : undefined, - spans: [{ text: line, dim: opts.dim, bold: opts.bold }], - right: i === lines.length - 1 && opts.right ? { text: opts.right, dim: true } : undefined, - pulse: i === 0 ? opts.pulse : undefined, - }) - } - return rows -} - -// ---------------------------------------------------------------- scrollback -// -// The settled part of the transcript is printed ONCE, into the terminal's own -// scrollback, and never repainted (ink's ). That buys native -// wheel/trackpad scrolling and native select/copy, and it costs mutability: a -// row that has been flushed can't re-wrap or re-fold. So the flush point has to -// be a row that CANNOT change again, and these two helpers are what decide it. - -// The items whose rows are final. An item's rows can still change while a turn -// is in flight: a collapsed fold ("Ran 2 tool calls") grows as more tool activity -// lands under the same message. So the LAST message and everything after it stay -// live, and everything before it is final. With no turn in flight nothing can -// grow, so all of it is final. -// -// Note this is per-MESSAGE, not per-turn: the moment the agent starts a new -// message the previous one and its whole tool run flush together, which is what -// keeps the live frame about one message tall during a long turn. Pure, for -// tests. -export function settledItemKeys( - items: readonly TranscriptItem[], - turnLive: boolean, -): Set { - if (!turnLive) return new Set(items.map((i) => i.key)) - let lastSpeech = -1 - for (const [i, item] of items.entries()) if (!isToolActivity(item)) lastSpeech = i - // A transcript that is nothing but an open tool run has no settled prefix. - if (lastSpeech < 0) return new Set() - return new Set(items.slice(0, lastSpeech).map((i) => i.key)) -} - -// How many rows off the FRONT of the list are final — the count handed to -// . A prefix, deliberately: rows are flushed in screen order, so the -// first row that can still change stops the scan even if later rows are -// settled. Pure, for tests. -export function settledRowCount( - rows: readonly TranscriptRow[], - settledKeys: ReadonlySet, -): number { - let n = 0 - for (const row of rows) { - if (!settledKeys.has(row.entryKey)) break - n++ - } - return n -} - -// Agent and user prose rendered as markdown (bold, headings, bullets, tables, -// fenced code), pre-wrapped to the column it will occupy. Only these two kinds -// go through it: tool lines and system notices are the SDK's own formatting, -// where a stray asterisk or pipe is literal text. Items without markdown come -// back untouched, so the common case allocates nothing. Pure, for tests. -export function withRenderedMarkdown(item: TranscriptItem, width: number): TranscriptItem { - if (item.kind !== 'assistant' && item.kind !== 'user') return item - if (!hasMarkdown(item.text)) return item - const rendered = renderMarkdown(item.text, width) - return rendered === item.text ? item : { ...item, text: rendered } -} - -// Which items collapse when long: tool results and user turns (the latter carry -// the re-injected run context, which is bulky). Assistant prose stays full. -function isCollapsible(item: TranscriptItem): boolean { - return ( - (item.kind === 'tool_result' || item.kind === 'user') && - item.text.split('\n').length > COLLAPSE_LINES - ) -} - -// Colour + weight for each transcript item kind, matched loosely to Claude Code. -function styleFor(item: TranscriptItem): { - gutterColor?: string - textColor?: string - dim: boolean - bold: boolean -} { - const kind: ItemKind = item.kind - switch (kind) { - case 'tool': - return { gutterColor: theme.success, bold: true, dim: false } - case 'tool_result': - return { - textColor: item.isError ? theme.error : undefined, - dim: !item.isError, - bold: false, - } - // User copy stays white like the assistant's — only the bar takes the cyan, - // matching the composer's own left edge. - case 'user': - return { gutterColor: theme.cursor, bold: true, dim: false } - case 'error': - return { gutterColor: theme.error, textColor: theme.error, dim: false, bold: false } - case 'summary': - return { - textColor: item.isError ? theme.error : undefined, - dim: true, - bold: false, - } - case 'thinking': - case 'system': - case 'notice': - return { dim: true, bold: false } - case 'assistant': - default: - return { dim: false, bold: false } - } -} diff --git a/test/commands.test.ts b/test/commands.test.ts deleted file mode 100644 index a063b18..0000000 --- a/test/commands.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - completedText, - isCommandInput, - matchCommands, - resolveCommand, - SLASH_COMMANDS, - commandArgument, -} from '../src/ui/commands' - -describe('isCommandInput', () => { - it('claims a line only when the slash LEADS it', () => { - expect(isCommandInput('/stop')).toBe(true) - expect(isCommandInput('/')).toBe(true) - // Ordinary prose that happens to contain a slash still reaches the agent. - expect(isCommandInput('check /tmp for the log')).toBe(false) - expect(isCommandInput('what does a/b mean')).toBe(false) - expect(isCommandInput('')).toBe(false) - }) -}) - -describe('matchCommands', () => { - const names = (text: string): string[] => matchCommands(text).map((c) => c.name) - - it('offers everything for a bare slash', () => { - expect(names('/')).toEqual(SLASH_COMMANDS.map((c) => c.name)) - }) - - it('narrows by prefix', () => { - expect(names('/st')).toEqual(['stop']) - expect(names('/s')).toEqual(['stop', 'sessions']) - expect(names('/e')).toEqual(['exit']) - }) - - it('matches aliases but shows the canonical name', () => { - expect(names('/qu')).toEqual(['exit']) - expect(names('/quit')).toEqual(['exit']) - }) - - it('is case-insensitive', () => { - expect(names('/ST')).toEqual(['stop']) - }) - - it('offers nothing once a space commits to a command', () => { - // The first word is the command; past it there is nothing left to complete. - expect(names('/stop ')).toEqual([]) - expect(names('/stop now')).toEqual([]) - }) - - it('offers nothing for prose or an unknown prefix', () => { - expect(names('hello')).toEqual([]) - expect(names('/zzz')).toEqual([]) - }) -}) - -describe('resolveCommand', () => { - it('needs the WHOLE name, so a prefix never runs the wrong command', () => { - expect(resolveCommand('/stop')?.id).toBe('stop') - // '/s' matches two commands in the menu; running either would be a guess. - expect(resolveCommand('/s')).toBeNull() - expect(resolveCommand('/st')).toBeNull() - }) - - it('accepts aliases and surrounding whitespace', () => { - expect(resolveCommand('/quit')?.id).toBe('exit') - expect(resolveCommand('/exit')?.id).toBe('exit') - expect(resolveCommand('/stop ')?.id).toBe('stop') - expect(resolveCommand('/STOP')?.id).toBe('stop') - }) - - it('returns null for prose and for an unknown command', () => { - expect(resolveCommand('hello')).toBeNull() - expect(resolveCommand('/stpo')).toBeNull() - }) -}) - -describe('completedText', () => { - it('completes to the canonical name with a trailing space', () => { - expect(completedText(SLASH_COMMANDS[0])).toBe('/stop ') - // An alias completes to the name it stands for, not to the alias. - const exit = SLASH_COMMANDS.find((c) => c.id === 'exit') - expect(completedText(exit!)).toBe('/exit ') - }) - - it('produces text that resolves back to the same command', () => { - for (const command of SLASH_COMMANDS) { - expect(resolveCommand(completedText(command))?.id).toBe(command.id) - } - }) -}) - -describe('the command list itself', () => { - it('has no duplicate names or aliases, so a spelling means one thing', () => { - const spellings = SLASH_COMMANDS.flatMap((c) => [c.name, ...(c.aliases ?? [])]) - expect(new Set(spellings).size).toBe(spellings.length) - }) - - it('describes every command, since the menu shows the detail', () => { - for (const command of SLASH_COMMANDS) { - expect(command.detail, command.name).toMatch(/^[a-z]/) - expect(command.detail.length, command.name).toBeGreaterThan(8) - } - }) -}) - -describe('commands with an argument', () => { - it('resolves /image with its path, and refuses trailing words elsewhere', () => { - expect(resolveCommand('/image shot.png')?.id).toBe('image') - expect(resolveCommand('/image')?.id).toBe('image') - expect(resolveCommand('/stop now')).toBeNull() - }) - - it('returns the argument after the name', () => { - expect(commandArgument('/image ~/Desktop/shot.png')).toBe('~/Desktop/shot.png') - expect(commandArgument('/image')).toBe('') - expect(commandArgument('/image a b.png')).toBe('a b.png') - }) -}) diff --git a/test/config.test.ts b/test/config.test.ts index 7b5dca0..8431b50 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -14,8 +14,6 @@ import { resolveApiBase, resolveAppBase, resolveToken, - SESSION_BAR_DEFAULTS, - sessionBar, setActiveHostToken, updateHost, useHost, @@ -210,79 +208,6 @@ describe('host management', () => { }) }) -describe('sessionBar', () => { - it('falls back to the defaults with no config file', () => { - expect(sessionBar()).toEqual(SESSION_BAR_DEFAULTS) - }) - - it('reads every field from the config file', () => { - writeConfig({ - version: 2, - hosts: {}, - sessionBar: { - hidden: true, - days: 30, - repo: 'any', - statuses: 'unfinished', - sources: ['cli', 'web'], - }, - }) - expect(sessionBar()).toEqual({ - hidden: true, - days: 30, - repo: 'any', - statuses: 'unfinished', - sources: ['cli', 'web'], - }) - }) - - it('fills in the fields the file leaves out', () => { - writeConfig({ version: 2, hosts: {}, sessionBar: { days: 3 } }) - expect(sessionBar()).toEqual({ ...SESSION_BAR_DEFAULTS, days: 3 }) - }) - - // A typo in a preference should not stop the UI from opening. - it('ignores values of the wrong type or out of range', () => { - writeConfig({ - version: 2, - hosts: {}, - sessionBar: { hidden: 'yes', days: -3, repo: 'origin', statuses: 'live' }, - }) - expect(sessionBar()).toEqual(SESSION_BAR_DEFAULTS) - }) - - it('drops unknown sources and treats an empty list as every source', () => { - writeConfig({ version: 2, hosts: {}, sessionBar: { sources: ['cli', 'carrier-pigeon'] } }) - expect(sessionBar().sources).toEqual(['cli']) - writeConfig({ version: 2, hosts: {}, sessionBar: { sources: [] } }) - expect(sessionBar().sources).toBeUndefined() - writeConfig({ version: 2, hosts: {}, sessionBar: { sources: ['carrier-pigeon'] } }) - expect(sessionBar().sources).toBeUndefined() - }) - - it('defaults to the human-started sources when the key is absent', () => { - writeConfig({ version: 2, hosts: {}, sessionBar: { days: 3 } }) - expect(sessionBar().sources).toEqual(['web', 'cli', 'mention']) - }) - - it('takes days 0 as "no age cutoff", not as a missing value', () => { - writeConfig({ version: 2, hosts: {}, sessionBar: { days: 0 } }) - expect(sessionBar().days).toBe(0) - }) - - it('survives a host write', () => { - writeConfig({ version: 2, hosts: {}, sessionBar: { hidden: true } }) - addHost('beta', 'https://beta-api.ellipsis.dev') - expect(sessionBar().hidden).toBe(true) - }) - - // hideSessionBar (the flat key sessionBar replaced) is gone, not honored. - it('ignores the old hideSessionBar key', () => { - writeConfig({ version: 2, hosts: {}, hideSessionBar: true }) - expect(sessionBar().hidden).toBe(false) - }) -}) - describe('v1 -> v2 config migration', () => { it('folds a flat {token, apiBase} into one active host', () => { writeConfig({ token: 'file_tok', apiBase: 'https://beta-api.ellipsis.dev' }) diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts deleted file mode 100644 index 0d4b703..0000000 --- a/test/connect-app.test.ts +++ /dev/null @@ -1,530 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { groupRecordsToChatTurns } from '@ellipsis-dev/sdk/store' -import { cursorLineDown, cursorLineUp } from '../src/ui/ConnectApp' -import { chatTurnsToItems, undisplayedRecordCount } from '../src/lib/chatItems' -import { deriveSandboxState, sessionLogText } from '../src/lib/steps' -import { - gutterFor, - itemRows, - layOutItems, - settledItemKeys, - settledRowCount, - spanColor, - withRenderedMarkdown, - type TranscriptRow, -} from '../src/ui/transcriptRows' -import stripAnsi from 'strip-ansi' -import { theme } from '../src/lib/theme' -import type { TranscriptItem } from '@ellipsis-dev/sdk/store' - -let seq = 0 -// SDK 0.28 narrows records by kind, including archived Claude SDK payloads. -function rec(recordType: string, payload: Record = {}, source = 'lifecycle') { - const feed = ++seq - return { - id: `rec${feed}`, - created_at: '2026-01-01T00:00:00Z', - feed_seq: feed, - source, - kind: source === 'lifecycle' ? 'platform' : source === 'claude_code' ? 'claude_sdk' : 'unknown', - record_type: recordType, - record_format: source === 'lifecycle' ? 'ellipsis_lifecycle@1' : 'claude_sdk@1', - payload, - } -} - -// The derivations themselves (deriveSandboxState, awaitingAgentPhase, -// deliveredUnechoedSends, lastLines, humanDuration, hookPhrase, …) are the -// SDK's now, tested there with its middot wording. What the CLI owns is the -// comma wording: src/lib/steps.ts wraps the SDK functions and swaps ' · ' -// separators for ', ', so the terminal reads plain sentences. -describe('deriveSandboxState comma wording', () => { - const texts = (state: ReturnType) => - (state?.log ?? []).map((l) => l.text) - - it('writes phase readouts comma-separated, not middot-separated', () => { - const state = deriveSandboxState( - [ - rec('sandbox_starting', { repositories: ['o/r'] }), - rec('sandbox_phase', { - phase: 'image', - status: 'completed', - duration_ms: 1200, - detail: { cache_tier: 'exact' }, - }), - rec('sandbox_ready', { - cache_tier: 'exact', - phase_timings: { image: 1.5, clone: 27.5 }, - }), - ], - 0, - ) - expect(texts(state)).toEqual([ - 'Preparing image, cached image, 1.2s', - 'Sandbox ready, cached image, 29s', - ]) - }) - - it('writes the retry headline comma-separated', () => { - const state = deriveSandboxState( - [ - rec('session_starting', { attempt: 0, wake_index: 0 }), - rec('session_retrying', { reason: 'sandbox provisioning failed', attempt: 1 }), - ], - 0, - ) - expect(state?.headline).toBe('Retrying, sandbox provisioning failed') - }) - - it('passes null through — no lifecycle records means no block', () => { - expect(deriveSandboxState([], 0)).toBeNull() - }) -}) - -describe('chatTurnsToItems', () => { - const assistant = (text: string) => - rec('cc', { kind: 'assistant', content: [{ type: 'text', text }] }, 'claude_code') - const user = (text: string) => rec('cc', { kind: 'user', content: text }, 'claude_code') - const toolCall = (id: string, name = 'Bash', input: Record = { command: 'ls' }) => - rec('cc', { kind: 'assistant', content: [{ type: 'tool_use', id, name, input }] }, 'claude_code') - const toolResult = (id: string, text = 'ok') => - rec( - 'cc', - { kind: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: text }] }, - 'claude_code', - ) - const result = (over: Record = {}) => - rec( - 'cc', - { kind: 'result', duration_ms: 4000, cost_usd: 0.1, is_error: false, ...over }, - 'claude_code', - ) - const items = (records: ReturnType[]) => - chatTurnsToItems(groupRecordsToChatTurns(records as never)) - - it('maps prose, user messages and paired tool calls onto transcript items', () => { - const out = items([user('do it'), toolCall('t1'), toolResult('t1', 'files'), assistant('done')]) - expect(out.map((i) => i.kind)).toEqual(['user', 'tool', 'tool_result', 'assistant']) - expect(out.map((i) => i.text)).toEqual(['do it', 'Bash', 'files', 'done']) - }) - - it('renders a still-running tool call without a result row', () => { - const out = items([toolCall('t1')]) - expect(out.map((i) => i.kind)).toEqual(['tool']) - }) - - it('drops a turn-closing summary — duration and cost are not conversation', () => { - expect(items([assistant('one'), result(), assistant('two'), result()]).map((i) => i.text)) - .toEqual(['one', 'two']) - expect(items([result()])).toEqual([]) - }) - - it('keeps a failed turn as its own red line under a plain label', () => { - const out = items([assistant('oops'), result({ is_error: true })]) - expect(out.map((i) => i.kind)).toEqual(['assistant', 'summary']) - expect(out[1].text).toBe('turn ended with an error') - expect(out[1].isError).toBe(true) - }) - - it('settles the waking line in place instead of logging the wake twice', () => { - // The result record closes the turn before the session parks, as on the - // wire — an unclosed turn would keep collecting the post-wake response. - const records = [ - assistant('done for now'), - result(), - rec('session_idle'), - rec('session_starting', { wake_index: 1 }), - ] - const waking = items(records) - expect(waking.map((i) => i.text)).toEqual([ - 'done for now', - 'Session asleep', - 'Session waking', - ]) - const awake = items([...records, rec('session_resumed'), assistant('back')]) - expect(awake.map((i) => i.text)).toEqual([ - 'done for now', - 'Session asleep', - 'Session awake', - 'back', - ]) - // Same key, so settling the line can't slide the scroll anchor or - // re-print the flushed row. - expect(awake[2].key).toBe(waking[2].key) - }) - - it('leaves startup detail out of the chat — that story is the startup block', () => { - const out = items([ - rec('sandbox_starting'), - rec('sandbox_phase', { phase: 'setup', status: 'started' }), - rec('sandbox_output', { lines: ['installing…'] }), - rec('sandbox_ready', { cache_tier: 'exact' }), - assistant('hello'), - ]) - expect(out.map((i) => i.text)).toEqual(['hello']) - }) - - it('writes lifecycle reasons comma-separated through the wrapper', () => { - const out = items([rec('session_retrying', { reason: 'node lost' })]) - expect(out.map((i) => i.text)).toEqual(['Retrying, node lost']) - }) -}) - -describe('undisplayedRecordCount', () => { - const assistant = (text: string) => - rec('cc', { kind: 'assistant', content: [{ type: 'text', text }] }, 'claude_code') - const count = (records: ReturnType[], min = 0) => - undisplayedRecordCount(records as never, min) - - it('counts agent records that render nothing, so a shape it cannot read is visible', () => { - // A payload this build's reader returns nothing for. - const shapeless = rec('assistant', { kind: 'assistant', content: [] }, 'claude_code') - expect(count([shapeless])).toBe(1) - expect(count([assistant('fine'), shapeless])).toBe(1) - // A harness this build has no reader for at all: recordToItems renders - // nothing for an unknown record_format, which must count, not throw. - const future = { - ...rec('assistant', { kind: 'assistant' }, 'grok'), - record_format: 'grok_native@1', - } - expect(count([future])).toBe(1) - // A payload the reader THROWS on costs one count, never the whole - // transcript. - const hostile = rec('assistant', { kind: 'video' }, 'claude_code') - expect(count([assistant('before'), hostile])).toBe(1) - }) - - it('never warns on a readable transcript, silent-by-design records included', () => { - const result = rec( - 'cc', - { kind: 'result', duration_ms: 4000, cost_usd: 0.1, is_error: false }, - 'claude_code', - ) - expect(count([assistant('fine'), result])).toBe(0) - expect(count([rec('system', { kind: 'system', subtype: 'init' }, 'claude_code')])).toBe(0) - expect(count([rec('session_idle')])).toBe(0) - }) - - it('skips records at or below the render cursor (--no-records)', () => { - const shapeless = rec('assistant', { kind: 'assistant', content: [] }, 'claude_code') - expect(count([shapeless], shapeless.feed_seq)).toBe(0) - }) -}) - -describe('sessionLogText comma wording', () => { - it('writes reasons comma-separated, not middot-separated', () => { - expect(sessionLogText('session_retrying', { reason: 'node lost' })).toBe( - 'Retrying, node lost', - ) - expect(sessionLogText('session_cancelled', { reason: 'budget' })).toBe( - 'Session cancelled, budget', - ) - }) - - it('passes plain lines and nulls through unchanged', () => { - expect(sessionLogText('session_idle', {})).toBe('Session asleep') - expect(sessionLogText('session_starting', { wake_index: 0 })).toBeNull() - }) -}) - -describe('layOutItems', () => { - const prose = (key: string): TranscriptItem => ({ key, kind: 'assistant', text: 'hi' }) - const user = (key: string): TranscriptItem => ({ key, kind: 'user', text: 'do it' }) - const call = (key: string): TranscriptItem => ({ key, kind: 'tool', text: 'Bash' }) - const res = (key: string): TranscriptItem => ({ key, kind: 'tool_result', text: 'ok' }) - const fold = (key: string): TranscriptItem => ({ key: `grp:${key}`, kind: 'notice', text: 'Ran 2' }) - - it('keeps rows flat and attaches consecutive activity rows', () => { - // A run is never indented under the message before it: an indented run - // under a message that has scrolled by reads as if the message were the - // subject, when the run is the agent's own next step in the turn. - const think: TranscriptItem = { key: 'th', kind: 'thinking', text: 'hmm', gutter: '✻' } - const out = layOutItems([prose('a'), call('t1'), res('r1'), think, fold('t2'), user('u')]) - expect(out.map((p) => p.item.key)).toEqual(['a', 't1', 'r1', 'th', 'grp:t2', 'u']) - expect(out.every((p) => !p.nested)).toBe(true) - expect(out.map((p) => p.attach)).toEqual([false, false, true, true, true, false]) - }) -}) - -describe('gutterFor', () => { - const item = (kind: TranscriptItem['kind'], gutter?: string): TranscriptItem => - ({ key: 'k', kind, text: 'x', spaceBefore: false, gutter }) as TranscriptItem - - it('bars user messages, marks assistant prose ● and system lines ✦, overriding the SDK gutter', () => { - expect(gutterFor(item('user', '›'))).toBe('┃') - expect(gutterFor(item('assistant'))).toBe('●') - expect(gutterFor(item('system'))).toBe('✦') - expect(gutterFor(item('notice'))).toBe('✦') - }) - - it('keeps the SDK glyph for tool activity and none for the rest', () => { - expect(gutterFor(item('tool', '●'))).toBe('●') - expect(gutterFor(item('tool_result', '⎿'))).toBe('⎿') - expect(gutterFor(item('thinking', '✻'))).toBe('✻') - expect(gutterFor(item('summary'))).toBe('') - }) -}) - -describe('cursorLineUp', () => { - it('is null on the first line — the signal to enter transcript navigation', () => { - expect(cursorLineUp('', 0)).toBeNull() - expect(cursorLineUp('hello', 3)).toBeNull() - expect(cursorLineUp('ab\ncd', 2)).toBeNull() - }) - - it('keeps the column when the previous line is long enough', () => { - expect(cursorLineUp('ab\ncd', 4)).toBe(1) - expect(cursorLineUp('ab\ncd', 3)).toBe(0) - }) - - it('clamps to the previous line end when it is shorter', () => { - expect(cursorLineUp('a\nbcd', 5)).toBe(1) - expect(cursorLineUp('\nabc', 3)).toBe(0) - }) - - it('walks middle lines of a three-line input', () => { - // "one\ntwo\nthree", cursor at 'r' (line 3 col 2) -> line 2 col 2. - expect(cursorLineUp('one\ntwo\nthree', 10)).toBe(6) - }) -}) - -describe('cursorLineDown', () => { - it('is null on the last line', () => { - expect(cursorLineDown('', 0)).toBeNull() - expect(cursorLineDown('hello', 2)).toBeNull() - expect(cursorLineDown('ab\ncd', 4)).toBeNull() - }) - - it('keeps the column and clamps to a shorter next line', () => { - expect(cursorLineDown('ab\ncd', 1)).toBe(4) - expect(cursorLineDown('abcd\nx', 3)).toBe(6) - }) - - it('moves from a line-end newline to the next line', () => { - expect(cursorLineDown('ab\ncd', 2)).toBe(5) - }) -}) - -describe('settledItemKeys', () => { - const item = (key: string, kind: TranscriptItem['kind'], text = 'x'): TranscriptItem => ({ - key, - kind, - text, - }) - - it('settles everything when no turn is in flight', () => { - const items = [item('a', 'user'), item('b', 'assistant'), item('c', 'tool')] - expect([...settledItemKeys(items, false)]).toEqual(['a', 'b', 'c']) - }) - - it('holds back the last message and its tool run while a turn is live', () => { - // 'b' is the message the open run hangs off, so → can still unfold it and - // its fold row can still grow: neither may be flushed yet. - const items = [item('a', 'user'), item('b', 'assistant'), item('c', 'tool')] - expect([...settledItemKeys(items, true)]).toEqual(['a']) - }) - - it('flushes a finished message once the agent moves on to the next', () => { - const items = [ - item('a', 'user'), - item('b', 'assistant'), - item('c', 'tool'), - item('d', 'assistant'), - ] - expect([...settledItemKeys(items, true)]).toEqual(['a', 'b', 'c']) - }) - - it('settles nothing when the transcript is only an open tool run', () => { - expect(settledItemKeys([item('c', 'tool')], true).size).toBe(0) - }) -}) - -describe('settledRowCount', () => { - const row = (id: string, entryKey: string): TranscriptRow => ({ id, entryKey, spans: [] }) - - it('counts the settled PREFIX, stopping at the first live row', () => { - const rows = [row('1', 'a'), row('2', 'a'), row('3', 'b'), row('4', 'c')] - // 'c' is settled too, but it sits behind live 'b' — rows flush in screen - // order, so the scan stops there. - expect(settledRowCount(rows, new Set(['a', 'c']))).toBe(2) - }) - - it('is 0 when the first row is live and everything when all are settled', () => { - const rows = [row('1', 'a'), row('2', 'b')] - expect(settledRowCount(rows, new Set(['b']))).toBe(0) - expect(settledRowCount(rows, new Set(['a', 'b']))).toBe(2) - }) -}) - -describe('itemRows', () => { - it('spends no rows on vertical padding, so exchanges pack tightly', () => { - const rows = itemRows({ key: 'a', kind: 'assistant', text: 'hi' }, 40) - expect(rows).toHaveLength(1) - }) - - // Every row is unpainted: a row prints into the terminal's scrollback and is - // never repainted, so a fill on it would outlive the frame that drew it. The - // sender is carried by the gutter glyph alone. - it('marks the sender with a gutter glyph and paints no background', () => { - const gutterOf = (kind: TranscriptItem['kind'], nested = false): string | undefined => - itemRows({ key: 'a', kind, text: 'x' } as TranscriptItem, 40, { nested })[0] - .gutter?.text - expect(gutterOf('user')).toBe('┃') - expect(gutterOf('assistant')).toBe('●') - expect(gutterOf('notice')).toBe('✦') - for (const row of itemRows({ key: 'a', kind: 'user', text: 'x' }, 40)) { - expect(row).not.toHaveProperty('panel') - } - }) - - it('emits one row per line of a multi-line body', () => { - const rows = itemRows({ key: 'a', kind: 'assistant', text: 'one\ntwo\nthree' }, 40) - expect(rows.map((r) => r.spans[0].text)).toEqual(['one', 'two', 'three']) - }) - - it('never emits a row wider than the pane', () => { - const rows = itemRows({ key: 'a', kind: 'assistant', text: 'x'.repeat(200) }, 40) - for (const row of rows) { - const width = row.spans.reduce((n, s) => n + stripAnsi(s.text).length, 0) - expect(width).toBeLessThanOrEqual(40) - } - }) - - it('puts the gutter glyph on the first content row only', () => { - const rows = itemRows({ key: 'a', kind: 'assistant', text: 'one\ntwo' }, 40) - const withGutter = rows.filter((r) => r.gutter) - expect(withGutter).toHaveLength(1) - expect(withGutter[0].gutter?.text).toBe('●') - }) - - // The one exception: a user message's bar is an EDGE, so it repeats down every - // row of the body rather than marking only the first. - it('runs the user bar down every row of a multi-line message', () => { - const rows = itemRows({ key: 'a', kind: 'user', text: 'one\ntwo' }, 40) - expect(rows.map((r) => r.gutter?.text)).toEqual(['┃', '┃']) - }) - - it('marks a fold ⎿ only when it branches off a message, ● when it opens a turn', () => { - const foldItem: TranscriptItem = { key: 'grp:t1', kind: 'notice', text: 'Ran 1 shell command' } - expect(itemRows(foldItem, 40, { nested: true })[0].gutter?.text).toBe('⎿') - expect(itemRows(foldItem, 40, { nested: false })[0].gutter?.text).toBe('●') - // A real ✦ notice keeps its own mark either way. - const notice: TranscriptItem = { key: 'n', kind: 'notice', text: 'Session asleep' } - expect(itemRows(notice, 40, { nested: true })[0].gutter?.text).toBe('✦') - }) - - it('pads every row of a ⎿ item, so a wrapped body stays aligned', () => { - const rows = itemRows({ key: 'r', kind: 'tool_result', text: 'a\nb', gutter: '⎿' }, 40) - expect(rows.map((r) => r.textPad)).toEqual([1, 1]) - // And nothing else gets it. - expect(itemRows({ key: 'a', kind: 'assistant', text: 'hi' }, 40)[0].textPad) - .toBe(0) - }) - - it('leads with a spacer row when the item wants space before it', () => { - const rows = itemRows({ key: 'a', kind: 'notice', text: 'note', spaceBefore: true }, 40) - expect(rows[0].spacer).toBe(true) - }) - - it('clamps a long body, marking how many lines are hidden', () => { - const text = Array.from({ length: 10 }, (_, i) => `l${i}`).join('\n') - const rows = itemRows({ key: 'r', kind: 'tool_result' as const, text }, 40) - expect(rows).toHaveLength(7) // 6 lines + the "+N lines" marker - // The row carries the COUNT; the renderer writes the text around it. - expect(rows[6].clampedLines).toBe(4) - }) - - it('measures visible columns, not escape sequences', () => { - // A markdown-rendered line carries ANSI codes that occupy no columns. - // Counting them would over-count rows and desync the window. - const styled = `\u001b[1m${'x'.repeat(30)}\u001b[22m` - const rows = itemRows({ key: 'a', kind: 'assistant', text: styled }, 40) - expect(rows.filter((r) => r.spans.length > 0)).toHaveLength(1) - }) -}) - -describe('withRenderedMarkdown', () => { - it('styles assistant and user prose that contains markdown', () => { - const item = { key: 'a', kind: 'assistant' as const, text: 'a **bold** word' } - const out = withRenderedMarkdown(item, 60) - expect(out.text).not.toBe(item.text) - expect(stripAnsi(out.text)).toBe('a bold word') - }) - - it('leaves plain prose untouched, object identity included', () => { - const item = { key: 'a', kind: 'assistant' as const, text: 'just plain prose' } - expect(withRenderedMarkdown(item, 60)).toBe(item) - }) - - it('leaves tool and system lines alone — their asterisks and pipes are literal', () => { - const tool = { key: 't', kind: 'tool' as const, text: 'Bash(ls *.ts | head)' } - expect(withRenderedMarkdown(tool, 60)).toBe(tool) - const system = { key: 's', kind: 'system' as const, text: '**not markdown**' } - expect(withRenderedMarkdown(system, 60)).toBe(system) - }) - - it('pre-wraps to the given width so the height estimate is exact', () => { - const long = `- ${'word '.repeat(40)}` - const out = withRenderedMarkdown({ key: 'a', kind: 'assistant', text: long }, 40) - for (const line of out.text.split('\n')) { - expect(stripAnsi(line).length).toBeLessThanOrEqual(40) - } - }) -}) - -describe('spanColor', () => { - // Nothing on a row may fall through to the terminal's own foreground: under a - // light theme that is the same near-black as the canvas painted beneath it. - it('gives an uncoloured span the brand foreground, never the terminal default', () => { - expect(spanColor({}, true)).toBe(theme.foreground) - }) - - it('resolves dim to muted instead of leaning on the terminal honouring dim', () => { - expect(spanColor({ dim: true }, true)).toBe(theme.muted) - }) - - it('keeps a span its own colour', () => { - expect(spanColor({ color: theme.error, dim: true }, true)).toBe(theme.error) - }) - - it('swaps a pulsing mark to muted on the off beat, whatever it is coloured', () => { - expect(spanColor({ color: theme.success, pulse: true }, false)).toBe(theme.muted) - expect(spanColor({ color: theme.success, pulse: true }, true)).toBe(theme.success) - }) -}) - -describe('itemRows colours', () => { - // styleFor leaves textColor off for most kinds, the assistant's prose (the - // bulk of a transcript) included, so this is the path that used to reach ink - // with no colour and render in the terminal's default foreground. - // What each kind's body text lands on: prose and the work it describes read - // primary, the infrastructure and a tool's output read quiet, a failure reads - // error. Quiet is a COLOUR, so it survives a terminal that ignores dim. - const bodyColor: Array<[TranscriptItem['kind'], string]> = [ - ['assistant', theme.foreground], - ['user', theme.foreground], - ['tool', theme.foreground], - ['tool_result', theme.muted], - ['thinking', theme.muted], - ['system', theme.muted], - ['notice', theme.muted], - ['summary', theme.muted], - ['error', theme.error], - ] - - it('resolves each kind to its brand colour, never to the terminal default', () => { - for (const [kind, expected] of bodyColor) { - const rows = itemRows({ key: `k:${kind}`, kind, text: 'some text' }, 60) - const body = rows.filter((r) => r.spans.some((s) => s.text.includes('some text'))) - expect(body.length, kind).toBeGreaterThan(0) - for (const row of body) { - for (const span of row.spans) expect(spanColor(span, true), kind).toBe(expected) - if (row.gutter) { - expect(new Set(Object.values(theme)), `${kind} gutter`).toContain( - spanColor(row.gutter, true), - ) - } - } - } - }) -}) diff --git a/test/connect.test.ts b/test/connect.test.ts deleted file mode 100644 index 03c2856..0000000 --- a/test/connect.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { connectability, resolveConnectSessionId } from '../src/commands/connect' -import type { AgentSession } from '../src/lib/types' - -import { session } from './fixtures/session' - -describe('resolveConnectSessionId', () => { - it('prefers the positional argument', () => { - expect(resolveConnectSessionId('session_abc', { ELLIPSIS_SESSION_ID: 'session_env' })).toBe( - 'session_abc', - ) - }) - - it('falls back to ELLIPSIS_SESSION_ID (the in-sandbox default)', () => { - expect(resolveConnectSessionId(undefined, { ELLIPSIS_SESSION_ID: 'session_env' })).toBe( - 'session_env', - ) - }) - - it('errors with a hint when neither is available', () => { - expect(() => resolveConnectSessionId(undefined, {})).toThrow(/agent session list/) - }) -}) - -describe('connectability', () => { - it('sends when the server says prompting is enabled', () => { - expect(connectability(session({ lifecycle: { prompting: { enabled: true } } }))).toEqual({ canSend: true }) - }) - - it('is watch-only when the server refuses, quoting its reason', () => { - const res = connectability( - session({ lifecycle: { prompting: { - enabled: false, - blocked_reason: 'non_interactive', - detail: 'This agent runs a workflow and takes no messages.', - } } }), - ) - expect(res.canSend).toBe(false) - expect(res.reason).toContain('This agent runs a workflow') - expect(res.reason).toMatch(/watch-only/) - }) - - it('falls back to a generic reason when the server sends no detail', () => { - const res = connectability(session({ lifecycle: { prompting: { enabled: false } } })) - expect(res.canSend).toBe(false) - expect(res.reason).toMatch(/does not accept messages/) - }) -}) diff --git a/test/editing.test.ts b/test/editing.test.ts deleted file mode 100644 index 7998885..0000000 --- a/test/editing.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - applyEditShortcut, - lineEnd, - lineStart, - wordLeft, - wordRight, - type EditKey, -} from '../src/lib/editing' - -function key(overrides: Partial = {}): EditKey { - return { - ctrl: false, - meta: false, - leftArrow: false, - rightArrow: false, - backspace: false, - delete: false, - ...overrides, - } -} - -describe('wordLeft / wordRight', () => { - it('hops whole words, skipping the whitespace between them', () => { - expect(wordLeft('fix the webhook tests', 21)).toBe(16) - expect(wordLeft('fix the webhook tests', 16)).toBe(8) - expect(wordRight('fix the webhook tests', 0)).toBe(3) - expect(wordRight('fix the webhook tests', 3)).toBe(7) - }) - - it('stops at the text edges', () => { - expect(wordLeft('abc', 0)).toBe(0) - expect(wordRight('abc', 3)).toBe(3) - expect(wordLeft(' ', 3)).toBe(0) - }) - - it('crosses newlines like an editor does', () => { - expect(wordLeft('one\ntwo', 7)).toBe(4) - expect(wordLeft('one\ntwo', 4)).toBe(0) - expect(wordRight('one\ntwo', 3)).toBe(7) - }) -}) - -describe('lineStart / lineEnd', () => { - it('finds the bounds of the caret line', () => { - expect(lineStart('ab\ncd', 4)).toBe(3) - expect(lineStart('ab\ncd', 1)).toBe(0) - expect(lineEnd('ab\ncd', 0)).toBe(2) - expect(lineEnd('ab\ncd', 4)).toBe(5) - }) -}) - -describe('applyEditShortcut', () => { - const state = { text: 'fix the webhook tests', cursor: 21 } - - it('ignores unmodified keys', () => { - expect(applyEditShortcut(state, 'a', key())).toBeNull() - expect(applyEditShortcut(state, '', key({ leftArrow: true }))).toBeNull() - }) - - it('jumps a word with option/ctrl + arrows', () => { - expect(applyEditShortcut(state, '', key({ meta: true, leftArrow: true }))?.cursor).toBe(16) - expect(applyEditShortcut(state, '', key({ ctrl: true, leftArrow: true }))?.cursor).toBe(16) - expect( - applyEditShortcut({ text: state.text, cursor: 0 }, '', key({ meta: true, rightArrow: true })) - ?.cursor, - ).toBe(3) - }) - - it('jumps a word with meta+b / meta+f (what iTerm sends for option+arrows)', () => { - expect(applyEditShortcut(state, 'b', key({ meta: true }))?.cursor).toBe(16) - expect(applyEditShortcut({ ...state, cursor: 0 }, 'f', key({ meta: true }))?.cursor).toBe(3) - }) - - it('reaches the line edges with ctrl+a / ctrl+e', () => { - expect(applyEditShortcut(state, 'a', key({ ctrl: true }))?.cursor).toBe(0) - expect( - applyEditShortcut({ text: 'ab\ncd', cursor: 4 }, 'a', key({ ctrl: true }))?.cursor, - ).toBe(3) - expect(applyEditShortcut({ text: 'ab\ncd', cursor: 0 }, 'e', key({ ctrl: true }))?.cursor).toBe( - 2, - ) - }) - - it('kills the word behind the caret with option+backspace / ctrl+w', () => { - expect(applyEditShortcut(state, '', key({ meta: true, backspace: true }))).toEqual({ - text: 'fix the webhook ', - cursor: 16, - }) - expect(applyEditShortcut(state, 'w', key({ ctrl: true }))).toEqual({ - text: 'fix the webhook ', - cursor: 16, - }) - }) - - it('kills to the line start with ctrl+u and to the line end with ctrl+k', () => { - expect(applyEditShortcut({ text: 'ab cd', cursor: 3 }, 'u', key({ ctrl: true }))).toEqual({ - text: 'cd', - cursor: 0, - }) - expect(applyEditShortcut({ text: 'ab cd', cursor: 3 }, 'k', key({ ctrl: true }))).toEqual({ - text: 'ab ', - cursor: 3, - }) - }) - - it('leaves other modified keys to the caller (ctrl+r, ctrl+s, ctrl+c)', () => { - for (const ch of ['r', 's', 'c']) { - expect(applyEditShortcut(state, ch, key({ ctrl: true }))).toBeNull() - } - }) -}) diff --git a/test/markdown.test.ts b/test/markdown.test.ts deleted file mode 100644 index 2aa819f..0000000 --- a/test/markdown.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, expect, it } from 'vitest' -import chalk from 'chalk' -import stripAnsi from 'strip-ansi' -import { fitLines, hasMarkdown, renderMarkdown, visibleWidth } from '../src/lib/markdown' -import { theme } from '../src/lib/theme' - -const lines = (text: string): string[] => text.split('\n') -const plain = (text: string): string => stripAnsi(text) - -describe('hasMarkdown', () => { - it('detects the constructs worth parsing', () => { - expect(hasMarkdown('**bold**')).toBe(true) - expect(hasMarkdown('a `code` span')).toBe(true) - expect(hasMarkdown('## heading')).toBe(true) - expect(hasMarkdown('- bullet')).toBe(true) - expect(hasMarkdown('1. ordered')).toBe(true) - expect(hasMarkdown('> quote')).toBe(true) - expect(hasMarkdown('| a | b |')).toBe(true) - expect(hasMarkdown('[label](https://x.com)')).toBe(true) - }) - - it('passes on plain prose so the parser stays off the hot path', () => { - expect(hasMarkdown('just some ordinary prose, 2 * 3 = 6')).toBe(false) - expect(hasMarkdown('a sentence\nacross two lines')).toBe(false) - }) -}) - -describe('renderMarkdown', () => { - it('styles inline runs without changing the words', () => { - expect(plain(renderMarkdown('a **bold** and `code` word', 60))).toBe('a bold and code word') - }) - - it('drops the literal hashes from a heading', () => { - expect(plain(renderMarkdown('## Summary', 60))).toBe('Summary') - }) - - it('keeps inline styling INSIDE list items (marked-terminal drops it unpatched)', () => { - const out = renderMarkdown('- a bullet with **bold** and `code`', 60) - expect(plain(out)).toContain('a bullet with bold and code') - expect(out).not.toContain('**') - expect(out).not.toContain('`') - }) - - it('draws a table', () => { - const out = plain(renderMarkdown('| A | B |\n|---|---|\n| 1 | 2 |', 60)) - expect(out).toContain('│') - expect(out).toContain('A') - expect(out).toContain('2') - }) - - it('sizes a wide table to the pane and wraps its cells', () => { - // cli-table3 sizes columns to their content by default, which pushed a - // wide table past the pane and smeared its rows across the frame. - const wide = [ - '| Step | Detail |', - '|------|--------|', - `| +5.1s | ${'a very long cell value '.repeat(6)} |`, - ].join('\n') - for (const width of [40, 60, 96]) { - for (const line of lines(renderMarkdown(wide, width))) { - expect(visibleWidth(line)).toBeLessThanOrEqual(width) - } - } - }) - - it('keeps inline styling inside table cells', () => { - const out = renderMarkdown('| A | B |\n|---|---|\n| **hi** | `x` |', 60) - expect(out).not.toContain('**') - expect(plain(out)).toContain('hi') - }) - - it('wraps every line to the width, list continuations included', () => { - const src = `- ${'word '.repeat(60)}` - for (const line of lines(renderMarkdown(src, 40))) { - expect(visibleWidth(line)).toBeLessThanOrEqual(40) - } - }) - - it('hangs a wrapped bullet under its text, not its marker', () => { - const out = lines(renderMarkdown(`- ${'word '.repeat(20)}`, 40)).map(plain) - const marker = out[0].indexOf('*') - expect(marker).toBeGreaterThanOrEqual(0) - expect(out.length).toBeGreaterThan(1) - expect(out[1].match(/^ */)?.[0].length).toBeGreaterThan(marker) - }) - - it('has no leading or trailing blank line — the transcript owns spacing', () => { - const out = renderMarkdown('**hi**', 60) - expect(out.startsWith('\n')).toBe(false) - expect(out.endsWith('\n')).toBe(false) - }) - - it('is stable across calls at the same width (cached)', () => { - expect(renderMarkdown('**hi**', 60)).toBe(renderMarkdown('**hi**', 60)) - }) - - it('re-renders for a different width rather than reusing the cached wrap', () => { - const src = `- ${'word '.repeat(30)}` - expect(lines(renderMarkdown(src, 30)).length).toBeGreaterThan( - lines(renderMarkdown(src, 90)).length, - ) - }) -}) - -describe('visibleWidth', () => { - it('ignores escape sequences', () => { - expect(visibleWidth('bold')).toBe(4) - expect(visibleWidth('plain')).toBe(5) - }) -}) - -describe('fitLines', () => { - it('returns the exact lines the text occupies, none wider than the width', () => { - const out = fitLines('word '.repeat(30).trim(), 20) - expect(out.length).toBeGreaterThan(1) - for (const line of out) expect(visibleWidth(line)).toBeLessThanOrEqual(20) - }) - - it('keeps a short line as one line, and splits on existing newlines', () => { - expect(fitLines('hi', 20)).toEqual(['hi']) - expect(fitLines('a\nb\nc', 20)).toEqual(['a', 'b', 'c']) - }) - - it('measures visible columns, so escapes cost no width', () => { - // 30 visible chars in a 40-column pane: one line, despite the escapes. - expect(fitLines(`\u001b[1m${'x'.repeat(30)}\u001b[22m`, 40)).toHaveLength(1) - }) - - it('truncates a table row instead of reflowing it', () => { - // Box-drawing rows can't move their columns, so they're cut to fit. - const row = `│ ${'a'.repeat(40)} │` - const out = fitLines(row, 20) - expect(out).toHaveLength(1) - expect(visibleWidth(out[0])).toBeLessThanOrEqual(20) - }) -}) - -// Everything a table draws used to resolve through the terminal's OWN 16-colour -// palette: chalk.red on the header cells, and cli-table3's `border: ['gray']`, -// which only takes a colour NAME. Whatever the user's theme mapped those two -// slots to was what a table came out as. -describe('renderMarkdown colours', () => { - const table = '| Name | Status |\n| --- | --- |\n| alpha | ok |' - - // Rendered escapes only exist when chalk is actually emitting colour, which - // it isn't under a test runner's pipe. Force a level, and vary the width so - // the (width, source)-keyed cache can't hand back an uncoloured render. - const coloured = (source: string, width: number): string => { - const level = chalk.level - chalk.level = 3 - try { - return renderMarkdown(source, width) - } finally { - chalk.level = level - } - } - - it('draws the header in brand bone, not the palette red', () => { - const out = coloured(table, 41) - expect(out).not.toContain('') - expect(out).toContain(chalk.hex(theme.foreground).bold('Name')) - }) - - it('draws the rules in brand muted, not the palette grey', () => { - const out = coloured(table, 42) - expect(out).not.toContain('') - // Every box-drawing run carries the muted foreground. - for (const run of out.match(/[│┌┐└┘├┤┬┴┼─]+/g) ?? []) { - expect(out).toContain(chalk.hex(theme.muted)(run)) - } - }) - - it('leaves cell text uncoloured so the row it lands on supplies the brand fg', () => { - // The transcript wraps each row in an explicit colour (see spanColor), and - // chalk re-opens it after a nested reset, so a cell needs no colour of its - // own — but it must not carry a full  either, which would drop the - // row's background tint for the rest of the line. - const out = coloured(table, 43) - expect(out).not.toContain('') - }) -}) diff --git a/test/output.test.ts b/test/output.test.ts index c3f611d..8e700b2 100644 --- a/test/output.test.ts +++ b/test/output.test.ts @@ -5,7 +5,6 @@ import { relativeAge, usd, usdFromMillicents, - usdNumberFromMillicents, } from '../src/lib/output' // The SDK's error, as its transport builds one from a parsed error body: the @@ -28,14 +27,6 @@ describe('usdFromMillicents', () => { }) }) -describe('usdNumberFromMillicents', () => { - it('converts millicents to a USD number for math before formatting', () => { - expect(usdNumberFromMillicents(0)).toBe(0) - expect(usdNumberFromMillicents(100_000)).toBe(1) - expect(usdNumberFromMillicents(12_345_000)).toBeCloseTo(123.45) - }) -}) - describe('relativeAge', () => { const now = new Date('2026-07-06T12:00:00Z') diff --git a/test/sdk-030.test.ts b/test/sdk-030.test.ts index 700e82d..8f7acf6 100644 --- a/test/sdk-030.test.ts +++ b/test/sdk-030.test.ts @@ -8,7 +8,6 @@ import { registerAutomation } from '../src/commands/automation' import { registerEnvironment } from '../src/commands/environment' import { registerSession, watchSession } from '../src/commands/session' import { api } from '../src/lib/api' -import { environmentPane, paneEnvironment } from '../src/lib/sessions' import { session } from './fixtures/session' let dir: string @@ -96,13 +95,6 @@ describe('SDK 0.30 request contracts', () => { expect(config).not.toHaveProperty('image') }) - it('preserves build inputs and before_start when editing environment build scripts', () => { - const pane = environmentPane({ hooks: { build_base: { inputs: ['api/package.json'], run: 'npm ci' }, before_start: { run: 'echo ready' } } }) - const env = paneEnvironment({ ...pane, image: { ...pane.image, build_base: 'npm install' } }) - expect(env.hooks).toEqual({ build_base: { inputs: ['api/package.json'], run: 'npm install' }, before_start: { run: 'echo ready' } }) - expect(env).not.toHaveProperty('image') - }) - it.each(['completed', 'budget_hit'] as const)('uses the execution outcome when a conversation closes: %s', async (reason) => { vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ session: session({ lifecycle: { status: 'closed', last_execution_result: { completion_reason: reason, detail: null } } }) })))) await watchSession(api(), 's_1', 1, true) diff --git a/test/sdk-upgrade.test.ts b/test/sdk-upgrade.test.ts index 55f5461..84db13b 100644 --- a/test/sdk-upgrade.test.ts +++ b/test/sdk-upgrade.test.ts @@ -1,22 +1,11 @@ import { describe, expect, it } from 'vitest' import type { SessionRecord } from '@ellipsis-dev/sdk' -import { SessionTranscriptStore } from '@ellipsis-dev/sdk/store' import { budgetLines } from '../src/commands/usage' -import { - chatTurnsToItems, - foldRecordCosts, - undisplayedRecordCount, -} from '../src/lib/chatItems' import { recordText } from '../src/lib/steps' import { buildStartOverride } from '../src/commands/session' import { toHarness } from '../src/lib/args' -import { - applyComposerChoices, - composerModelChoice, - composerModelOptions, - startRequestFromConfig, -} from '../src/lib/sessions' -import type { BudgetSummary, SupportedModel } from '../src/lib/types' +import { startRequestFromConfig } from '../src/lib/sessions' +import type { BudgetSummary } from '../src/lib/types' const envelope = { id: 'record_1', @@ -75,43 +64,14 @@ const result: SessionRecord = { } describe('native session records', () => { - it('renders native Claude replies through the CLI transcript and record listing', () => { + it('renders native Claude replies through the record listing', () => { const original = JSON.stringify(assistant) - const store = new SessionTranscriptStore() - store.ingest({ type: 'records_append', records: [assistant, result] }) - expect(chatTurnsToItems(store.chatTurns()).map((item) => item.text)).toEqual([ - 'All tests pass.', - ]) expect(recordText(assistant)).toBe('All tests pass.') expect(recordText(result)).toBe('All tests pass.') - expect(undisplayedRecordCount([assistant, result], 0)).toBe(0) expect(JSON.stringify(assistant)).toBe(original) }) - it('keeps the cost footer populated for native and archived Claude results', () => { - const archived: SessionRecord = { - ...envelope, - kind: 'claude_sdk', - source: 'claude_code', - record_format: 'claude_sdk@1', - record_type: 'result', - payload: { - kind: 'result', - subtype: 'success', - is_error: false, - duration_ms: 1000, - duration_api_ms: 800, - num_turns: 1, - cost_usd: 0.08, - }, - } - expect(foldRecordCosts([archived, result])).toMatchObject({ - total: 0.2, - lastStep: 0.12, - }) - }) - - it('renders native Codex replies and ignores successful turn completion', () => { + it('renders native Codex replies', () => { const reply: SessionRecord = { ...envelope, kind: 'codex_app_server', @@ -127,34 +87,10 @@ describe('native session records', () => { }, }, } - const done: SessionRecord = { - ...reply, - id: 'record_2', - feed_seq: 2, - record_type: 'turn/completed', - payload: { - method: 'turn/completed', - params: { - threadId: 'thread_1', - turn: { id: 'turn_1', status: 'completed', items: [] }, - }, - }, - } - const store = new SessionTranscriptStore() - store.ingest({ type: 'records_append', records: [reply, done] }) - expect(chatTurnsToItems(store.chatTurns()).map((item) => item.text)).toEqual([ - 'Done.', - ]) expect(recordText(reply)).toBe('Done.') - expect(undisplayedRecordCount([reply, done], 0)).toBe(0) }) - it('ignores native bookkeeping but still warns about unknown records from a known producer', () => { - const system: SessionRecord = { - ...assistant, - record_type: 'system', - payload: { type: 'system', subtype: 'init' }, - } + it('falls back to the raw payload for an unknown record', () => { const unknown: SessionRecord = { ...envelope, kind: 'unknown', @@ -163,8 +99,6 @@ describe('native session records', () => { record_format: 'claude_jsonl@2', payload: { future: true }, } - expect(undisplayedRecordCount([system], 0)).toBe(0) - expect(undisplayedRecordCount([system, unknown], 0)).toBe(1) expect(recordText(unknown)).toBe('{"future":true}') }) }) @@ -223,37 +157,6 @@ describe('explicit harness requests', () => { expect(() => buildStartOverride({ system: 'Be brief.' })).toThrow(/--system is no longer supported/) }) - it('switches native options cleanly while keeping the prompt', () => { - const base = { claude_code: { model: 'claude-opus-5', max_turns: 5, effort: 'max' as const, prompt: 'Keep changes small.' } } - const unchanged = applyComposerChoices(base, { environment: { kind: 'empty' }, model: 'claude-fable-5', harness: 'claude_code' }) - expect(unchanged.claude_code).toEqual({ ...base.claude_code, model: 'claude-fable-5' }) - const changed = applyComposerChoices(base, { environment: { kind: 'empty' }, model: 'gpt-6-astra', harness: 'codex' }) - expect(changed.codex).toEqual({ model: 'gpt-6-astra', prompt: 'Keep changes small.' }) - expect(changed.claude_code).toBeUndefined() - expect(base.claude_code.max_turns).toBe(5) - }) - - it('keeps the certified harness and concrete model on a Codex default row', () => { - const model: SupportedModel = { - id: 'gpt-6-astra', - display_name: 'GPT-6 Astra', - harness: 'codex', - capabilities: [], - manufacturer: 'openai', - is_default_agent_model: true, - rate_card: { - input_millicents_per_1m_tokens: 0, - output_millicents_per_1m_tokens: 0, - cache_write_5m_millicents_per_1m_tokens: 0, - cache_write_1h_millicents_per_1m_tokens: 0, - cache_read_millicents_per_1m_tokens: 0, - }, - } - const [row] = composerModelOptions([model]) - expect(row.id).toBeNull() - expect(composerModelChoice(row)).toEqual({ harness: 'codex', model: 'gpt-6-astra' }) - }) - it('validates the harness flag and replaces options on a switch', () => { expect(toHarness('codex')).toBe('codex') expect(() => toHarness('claude')).toThrow(/claude_code, codex/) diff --git a/test/sessions.test.ts b/test/sessions.test.ts index d7637ac..3652221 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -1,1136 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - applyComposerChoices, - attentionFlip, - builtInMcpServers, - mcpServerEntry, - validateMcpServer, - COMPOSER_MODELS, - composerModelOptions, - composerPickerRows, - connectability, - environmentPaneWalk, - environmentPresets, - environmentPane, - environmentSectionAt, - environmentSectionRows, - environmentSourceLabel, - EMPTY_COMPUTE, - EMPTY_HOOKS, - EMPTY_IMAGE, - EMPTY_PANE, - EMPTY_ENVIRONMENT_ID, - computeOverride, - fieldsOverride, - filterSessions, - mcpServerName, - oneLine, - paneEquals, - paneWithRepository, - parseRepo, - resolveRepoFullName, - startRequestFromConfig, - withContextRepository, - restingPresetIndex, - paneEnvironment, - scriptRowLines, - parseVariableEntry, - repositoryRefLabel, - variableRowLabel, - modelRate, - rateDollars, - isActiveStatusWord, - lastEventAt, - navSlice, - rowDescription, - rowGlyph, - rowMeta, - rowStatusWord, - SESSION_BAR_FETCH, - sessionBarQuery, - shortAge, - sidebarSlice, - sortSidebarSessions, - statusBand, - mergeSidebarSessions, -} from '../src/lib/sessions' -import { theme } from '../src/lib/theme' -import type { SupportedModel } from '../src/lib/types' - -import { session } from './fixtures/session' - -describe('connectability', () => { - it('sends when the server says prompting is enabled', () => { - expect(connectability(session({ lifecycle: { prompting: { enabled: true } } }))).toEqual({ canSend: true }) - }) - - it('honors the server prompting projection', () => { - // A Slack mention session is keyed and live, but its answers post back to - // the Slack thread, so the server refuses direct messages and we open - // watch-only instead of a composer whose first Enter would 409. - const c = connectability( - session({ lifecycle: { prompting: { - enabled: false, - blocked_reason: 'mention_surface', - detail: 'This conversation lives on Slack. Reply there to steer the agent.', - surface_name: 'Slack', - } } }), - ) - expect(c.canSend).toBe(false) - // The server's own sentence is shown verbatim, so the reason names Slack. - expect(c.reason).toContain('This conversation lives on Slack.') - expect(c.reason).toMatch(/watch-only/) - }) -}) - -describe('rowStatusWord / rowGlyph', () => { - it('reads the canonical lifecycle status', () => { - const s = session({ lifecycle: { status: 'waiting' } }) - expect(rowStatusWord(s)).toBe('waiting') - }) - - it('reads a closed lifecycle', () => { - expect(rowStatusWord(session({ lifecycle: { status: 'closed' } }))).toBe('closed') - }) - - it('is always a dot — status is told by color, the arrow means selection', () => { - for (const word of ['working', 'waiting', 'sleeping', 'failed', 'stopped', 'completed']) { - expect(rowGlyph(word).glyph).toBe('●') - } - }) - - it('colors in-flight amber, waiting with the accent, sleeping dim', () => { - expect(rowGlyph('working')).toEqual({ glyph: '●', color: theme.active, dim: false }) - expect(isActiveStatusWord('working')).toBe(true) - expect(rowGlyph('waiting').color).toBe(theme.foreground) - expect(rowGlyph('sleeping')).toEqual({ glyph: '●', dim: true }) - }) - - it('colors failures with the error token and settles the rest as done', () => { - expect(rowGlyph('failed').color).toBe(theme.error) - expect(rowGlyph('error')).toEqual({ glyph: '●', color: theme.error, dim: false }) - expect(rowGlyph('stopped')).toEqual({ glyph: '●', color: theme.error, dim: true }) - expect(rowGlyph('completed').color).toBe(theme.success) - expect(rowGlyph('closed').color).toBe(theme.success) - }) -}) - -describe('rowDescription', () => { - it('prefers the live summary, collapsed to one line', () => { - const s = session({ summary: { description: 'fixing the\n webhook tests', created_at: null }, claude_code: { prompt: 'do a thing' } }) - expect(rowDescription(s)).toBe('fixing the webhook tests') - }) - - it('falls back to the prompt, then the source', () => { - expect(rowDescription(session({ claude_code: { prompt: 'fix the tests' } }))).toBe('fix the tests') - expect(rowDescription(session({ source: 'react' }))).toBe('react session') - // Every session carries a source, so that is the floor. - expect(rowDescription(session({ }))).toBe('api session') - }) - - it('ignores whitespace-only summaries', () => { - expect( - rowDescription(session({ summary: { description: ' \n ', created_at: null }, claude_code: { prompt: 'p' } })), - ).toBe('p') - }) -}) - -describe('lastEventAt / shortAge', () => { - it('prefers last_activity_at, then last_message_at, then updated_at', () => { - expect( - lastEventAt( - session({ lifecycle: { timestamps: { last_activity_at: 'A', last_message_at: 'B', updated_at: 'C' } } } as never), - ), - ).toBe('A') - expect(lastEventAt(session({ lifecycle: { timestamps: { last_message_at: 'B', updated_at: 'C' } } } as never))).toBe('B') - expect(lastEventAt(session({ lifecycle: { timestamps: { updated_at: 'C' } } }))).toBe('C') - }) - - it('renders compact ages and never goes negative', () => { - const now = new Date('2026-07-23T12:00:00Z') - expect(shortAge('2026-07-23T11:59:48Z', now)).toBe('12s ago') - expect(shortAge('2026-07-23T11:58:00Z', now)).toBe('2m ago') - expect(shortAge('2026-07-23T09:00:00Z', now)).toBe('3h ago') - expect(shortAge('2026-07-18T12:00:00Z', now)).toBe('5d ago') - expect(shortAge('2026-07-23T12:00:05Z', now)).toBe('0s ago') - }) -}) - -describe('rowMeta', () => { - const now = new Date('2026-07-23T12:00:00Z') - - it('reads spend and age, never the token count', () => { - const s = session({ tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0, total: 84_200, model: '' }, cost: { llm: 30_000, sandbox_cpu: 10_000, sandbox_memory: 2_000, fee: 0, total: 42_000 }, lifecycle: { timestamps: { updated_at: '2026-07-23T11:58:00Z' } } } as never) - expect(rowMeta(s, now)).toBe('$0.42, 2m ago') - }) - - it('drops the spend a fresh session has none of', () => { - const s = session({ lifecycle: { timestamps: { updated_at: '2026-07-23T11:59:48Z' } } }) - expect(rowMeta(s, now)).toBe('12s ago') - }) -}) - -describe('filterSessions', () => { - const budget = session({ id: 'budget', summary: { description: 'Checking the monthly budget' }, claude_code: { prompt: 'can you check my budget' } }) - const greet = session({ id: 'greet', summary: null, claude_code: { prompt: 'Hey!' } }) - const bare = session({ id: 'bare', summary: null, claude_code: { prompt: null } }) - - it('matches all on an empty or blank query', () => { - expect(filterSessions([budget, greet], '')).toEqual([budget, greet]) - expect(filterSessions([budget, greet], ' ')).toEqual([budget, greet]) - }) - - it('matches the summary and the prompt, case-insensitively', () => { - expect(filterSessions([budget, greet, bare], 'MONTHLY').map((s) => s.id)).toEqual(['budget']) - expect(filterSessions([budget, greet, bare], 'hey').map((s) => s.id)).toEqual(['greet']) - }) - - it('drops sessions with nothing to match', () => { - expect(filterSessions([bare], 'anything')).toEqual([]) - }) -}) - -describe('sessionBarQuery', () => { - const bar = { - days: 7, - repo: 'cwd' as const, - statuses: 'all' as const, - sources: undefined, - } - const context = { authorId: 42, detectedRepo: 'acme/api' } - - it('scopes to the author, the cwd repo, and the age cutoff', () => { - expect(sessionBarQuery(bar, context)).toEqual({ - author_id: 42, - limit: SESSION_BAR_FETCH, - days: 7, - repo: 'acme/api', - }) - }) - - // Outside a repository, asking for repo "" would empty the bar; the whole - // account is the useful answer instead. - it('drops the repo filter when the cwd is not a repository', () => { - expect(sessionBarQuery(bar, { authorId: 42, detectedRepo: null }).repo).toBeUndefined() - }) - - it('drops the repo filter under repo "any" even inside one', () => { - expect(sessionBarQuery({ ...bar, repo: 'any' }, context).repo).toBeUndefined() - }) - - it('omits days entirely at 0, rather than asking for a zero-day window', () => { - expect(sessionBarQuery({ ...bar, days: 0 }, context).days).toBeUndefined() - }) - - it('asks for unfinished sessions only when configured to', () => { - expect(sessionBarQuery(bar, context).unfinished).toBeUndefined() - expect(sessionBarQuery({ ...bar, statuses: 'unfinished' }, context).unfinished).toBe(true) - }) - - it('passes sources through and omits them when unset', () => { - expect(sessionBarQuery(bar, context).source).toBeUndefined() - expect(sessionBarQuery({ ...bar, sources: ['cli', 'manual'] }, context).source).toEqual([ - 'cli', - 'manual', - ]) - }) - - // An API-key credential has no GitHub user behind it: list the account's. - it('omits the author filter without one', () => { - expect( - sessionBarQuery(bar, { authorId: null, detectedRepo: 'acme/api' }).author_id, - ).toBeUndefined() - }) - - it('fetches a page deep enough to band and scroll', () => { - expect(sessionBarQuery(bar, context).limit).toBe(SESSION_BAR_FETCH) - }) -}) - -describe('statusBand / sortSidebarSessions', () => { - // A row per band, deliberately born newest-first-is-wrong-order so a - // recency sort can't accidentally pass. - const waiting = session({ id: 'waiting', lifecycle: { status: 'waiting', timestamps: { created_at: '2026-07-20T00:00:00Z' } } }) - const working = session({ id: 'working', lifecycle: { status: 'working', timestamps: { created_at: '2026-07-21T00:00:00Z' } } }) - const sleeping = session({ id: 'sleeping', lifecycle: { status: 'idle', timestamps: { created_at: '2026-07-22T00:00:00Z' } } }) - const done = session({ id: 'done', lifecycle: { status: 'closed', timestamps: { created_at: '2026-07-23T00:00:00Z' } } }) - const failed = session({ id: 'failed', lifecycle: { status: 'failed', timestamps: { created_at: '2026-07-24T00:00:00Z' } } }) - - it('bands by status: live, parked, done, dead', () => { - expect(sortSidebarSessions([failed, done, sleeping, waiting, working]).map((s) => s.id)).toEqual( - ['working', 'waiting', 'sleeping', 'done', 'failed'], - ) - expect(statusBand('completed')).toBe(statusBand('closed')) - }) - - it('bands waiting with in-flight, so a finished turn does not move the row', () => { - // The same session either side of a turn boundary: same band, same - // created_at, so it holds its slot instead of jumping on every response. - expect(statusBand('waiting')).toBe(statusBand('working')) - expect(statusBand('waiting')).toBe(statusBand('starting')) - const mid = sortSidebarSessions([waiting, working, sleeping]).map((s) => s.id) - const after = sortSidebarSessions([ - session({ ...waiting, lifecycle: { ...waiting.lifecycle, status: 'working' } }), - session({ ...working, lifecycle: { ...working.lifecycle, status: 'waiting' } }), - sleeping, - ] as AgentSession[]).map((s) => s.id) - expect(after).toEqual(mid) - }) - - it('orders within a band newest-born first, ignoring event recency', () => { - const old = session({ id: 'old', lifecycle: { status: 'working', timestamps: { created_at: '2026-07-20T00:00:00Z' } } }) - const fresh = session({ id: 'fresh', lifecycle: { status: 'working', timestamps: { created_at: '2026-07-22T00:00:00Z' } } }) - // `old` just spoke; that must not lift it above the younger session. - const chatty = session({ ...old, lifecycle: { ...old.lifecycle, timestamps: { ...old.lifecycle.timestamps, last_activity_at: '2026-07-23T00:00:00Z' } } }) - expect(sortSidebarSessions([chatty, fresh]).map((s) => s.id)).toEqual(['fresh', 'old']) - expect(sortSidebarSessions([fresh, chatty]).map((s) => s.id)).toEqual(['fresh', 'old']) - }) -}) - -describe('mergeSidebarSessions', () => { - it('keeps local sessions the poll has not returned yet', () => { - const polled = session({ id: 'a', lifecycle: { timestamps: { created_at: '2026-07-23T10:00:00Z' } } }) - const local = session({ id: 'b', lifecycle: { timestamps: { created_at: '2026-07-23T11:00:00Z' } } }) - expect(mergeSidebarSessions([polled], [local]).map((s) => s.id)).toEqual(['b', 'a']) - }) - - it('emits one row when a session is in both lists, preferring the polled copy', () => { - const polled = session({ id: 'a', lifecycle: { status: 'closed' } }) - const local = session({ id: 'a', lifecycle: { status: 'working' } }) - const merged = mergeSidebarSessions([polled], [local]) - expect(merged.map((s) => s.id)).toEqual(['a']) - expect(merged[0]?.lifecycle.status).toBe('closed') - }) -}) - -describe('attentionFlip', () => { - it('flags active → waiting transitions', () => { - expect(attentionFlip('working', 'waiting')).toBe(true) - expect(attentionFlip('starting', 'idle')).toBe(true) - }) - - it('ignores first sightings, active continuations, and terminal flips', () => { - expect(attentionFlip(undefined, 'waiting')).toBe(false) - expect(attentionFlip('working', 'working')).toBe(false) - expect(attentionFlip('waiting', 'waiting')).toBe(false) - expect(attentionFlip('working', 'completed')).toBe(false) - }) -}) - -describe('sidebarSlice', () => { - it('shows everything when it fits', () => { - expect(sidebarSlice(3, 10, 0)).toEqual({ start: 0, end: 3 }) - }) - - it('windows around the selection when it overflows', () => { - expect(sidebarSlice(20, 6, 0)).toEqual({ start: 0, end: 6 }) - expect(sidebarSlice(20, 6, 10)).toEqual({ start: 7, end: 13 }) - expect(sidebarSlice(20, 6, 19)).toEqual({ start: 14, end: 20 }) - }) -}) - -describe('navSlice', () => { - it('shows everything when it fits', () => { - expect(navSlice(3, 5, 0)).toEqual({ start: 0, end: 3 }) - }) - - it('holds still until the highlight reaches the second-to-last row', () => { - expect(navSlice(10, 5, 0)).toEqual({ start: 0, end: 5 }) - expect(navSlice(10, 5, 3)).toEqual({ start: 0, end: 5 }) - }) - - it('scrolls with the highlight parked on the second-to-last row', () => { - expect(navSlice(10, 5, 4)).toEqual({ start: 1, end: 6 }) - expect(navSlice(10, 5, 5)).toEqual({ start: 2, end: 7 }) - expect(navSlice(10, 5, 7)).toEqual({ start: 4, end: 9 }) - }) - - it('lets the last row hold the highlight at the end of the list', () => { - expect(navSlice(10, 5, 8)).toEqual({ start: 5, end: 10 }) - expect(navSlice(10, 5, 9)).toEqual({ start: 5, end: 10 }) - }) -}) - -function model( - id: string, - manufacturer: SupportedModel['manufacturer'], - overrides: Partial = {}, -): SupportedModel { - return { - id, - display_name: id, - manufacturer, - is_default_agent_model: false, - rate_card: { - input_millicents_per_1m_tokens: 5_00_000, - cache_write_5m_millicents_per_1m_tokens: 6_25_000, - cache_write_1h_millicents_per_1m_tokens: 10_00_000, - cache_read_millicents_per_1m_tokens: 50_000, - output_millicents_per_1m_tokens: 25_00_000, - }, - ...overrides, - } -} - -describe('rateDollars', () => { - it('drops the cents on a whole dollar and keeps them otherwise', () => { - expect(rateDollars(5_00_000)).toBe('$5') - expect(rateDollars(75_000)).toBe('$0.75') - expect(rateDollars(14_25_000)).toBe('$14.25') - expect(rateDollars(0)).toBe('$0') - }) -}) - -describe('modelRate', () => { - it('quotes the input and output lanes only', () => { - expect( - modelRate({ - input_millicents_per_1m_tokens: 3_00_000, - cache_write_5m_millicents_per_1m_tokens: 3_75_000, - cache_write_1h_millicents_per_1m_tokens: 6_00_000, - cache_read_millicents_per_1m_tokens: 30_000, - output_millicents_per_1m_tokens: 15_00_000, - }), - ).toEqual({ input: '$3', output: '$15' }) - }) - - it('says nothing when the server sent no rate card', () => { - expect(modelRate(undefined)).toBeNull() - expect(modelRate(null)).toBeNull() - }) -}) - -describe('composerModelOptions', () => { - it('gives the account default row the null id, in its own vendor group', () => { - const options = composerModelOptions([ - model('claude-fable-5', 'anthropic'), - model('claude-opus-5', 'anthropic', { is_default_agent_model: true }), - ]) - expect(options.map((o) => [o.label, o.id, o.group])).toEqual([ - ['claude-fable-5', 'claude-fable-5', 'Anthropic'], - ['claude-opus-5', null, 'Anthropic'], - ]) - }) - - it('groups by manufacturer, ranked, keeping the server order inside a group', () => { - const options = composerModelOptions([ - model('gpt-5.6-sol', 'openai'), - model('kimi-k3', 'moonshot'), - model('claude-fable-5', 'anthropic', { is_default_agent_model: true }), - model('gpt-5.6-luna', 'openai'), - model('claude-sonnet-5', 'anthropic'), - ]) - expect(options.map((o) => [o.group, o.label])).toEqual([ - ['Anthropic', 'claude-fable-5'], - ['Anthropic', 'claude-sonnet-5'], - ['OpenAI', 'gpt-5.6-sol'], - ['OpenAI', 'gpt-5.6-luna'], - ['Moonshot AI', 'kimi-k3'], - ]) - }) - - it('groups an unranked manufacturer last, under its raw name', () => { - const options = composerModelOptions([ - model('some-new-model', 'nvidia' as SupportedModel['manufacturer']), - model('claude-fable-5', 'anthropic'), - ]) - expect(options.map((o) => o.group)).toEqual([null, 'Anthropic', 'nvidia']) - }) - - it('carries each row price as its two table cells', () => { - const options = composerModelOptions([ - model('claude-opus-5', 'anthropic', { is_default_agent_model: true }), - model('claude-haiku-4-5-20251001', 'anthropic', { - rate_card: { - input_millicents_per_1m_tokens: 1_00_000, - cache_write_5m_millicents_per_1m_tokens: 1_25_000, - cache_write_1h_millicents_per_1m_tokens: 2_00_000, - cache_read_millicents_per_1m_tokens: 10_000, - output_millicents_per_1m_tokens: 5_00_000, - }, - }), - ]) - expect(options[0].rate).toEqual({ input: '$5', output: '$25' }) - expect(options[1].rate).toEqual({ input: '$1', output: '$5' }) - }) - - it('falls back to the built-in list when the server returns none', () => { - expect(composerModelOptions([])).toEqual([...COMPOSER_MODELS]) - }) - - it('quotes no price it cannot refresh in the built-in list', () => { - expect(COMPOSER_MODELS.every((o) => !o.rate)).toBe(true) - }) - - it('heads the list with a bare Default row when no model claims the flag', () => { - const options = composerModelOptions([model('claude-opus-5', 'anthropic')]) - expect(options[0]).toEqual({ id: null, label: 'Default', group: null }) - expect(options[1].label).toBe('claude-opus-5') - expect(options[1].id).toBe('claude-opus-5') - }) -}) - -describe('composerPickerRows', () => { - it('prints one heading per group, above the options it covers', () => { - expect( - composerPickerRows([ - { id: null, label: 'claude-opus-5', group: 'Agent default' }, - { id: 'claude-fable-5', label: 'claude-fable-5', group: 'Anthropic' }, - { id: 'claude-sonnet-5', label: 'claude-sonnet-5', group: 'Anthropic' }, - { id: 'gpt-5.6-sol', label: 'gpt-5.6-sol', group: 'OpenAI' }, - ]), - ).toEqual([ - { kind: 'group', label: 'Agent default' }, - { kind: 'option', at: 0 }, - { kind: 'group', label: 'Anthropic' }, - { kind: 'option', at: 1 }, - { kind: 'option', at: 2 }, - { kind: 'group', label: 'OpenAI' }, - { kind: 'option', at: 3 }, - ]) - }) - - it('heads a flat list with nothing at all', () => { - expect( - composerPickerRows([ - { id: null, label: 'Default' }, - { id: 'acme/api', label: 'acme/api' }, - ]), - ).toEqual([ - { kind: 'option', at: 0 }, - { kind: 'option', at: 1 }, - ]) - }) - - it('opens a group again when a later row returns to it', () => { - expect( - composerPickerRows([ - { id: 'a', label: 'a', group: 'Anthropic' }, - { id: 'b', label: 'b', group: 'OpenAI' }, - { id: 'c', label: 'c', group: 'Anthropic' }, - ]).filter((r) => r.kind === 'group'), - ).toEqual([ - { kind: 'group', label: 'Anthropic' }, - { kind: 'group', label: 'OpenAI' }, - { kind: 'group', label: 'Anthropic' }, - ]) - }) -}) - -describe('applyComposerChoices', () => { - // The launcher always names an environment; `empty` is its bare-sandbox - // stand-in when nothing else is checked. - const untouched = { environment: { kind: 'empty' } as const, model: null } - - it('keeps the context repo and patches the selected harness model', () => { - const req = applyComposerChoices( - { repositories: ['acme/api'] }, - { ...untouched, model: 'claude-opus-5' }, - ) - expect(req).toEqual({ - repositories: ['acme/api'], - environment: {}, - claude_code: { model: 'claude-opus-5' }, - }) - }) - - // A named environment ships as the string, which re-picks it wholesale; the - // context repo rides along as the additive key. - it('names a chosen environment on the request, keeping the context repo', () => { - const req = applyComposerChoices( - { claude_code: { prompt: 'ship it' }, repositories: ['acme/api'] }, - { ...untouched, environment: { kind: 'named', id: 'env_1' } }, - ) - expect(req).toEqual({ claude_code: { prompt: 'ship it' }, repositories: ['acme/api'], environment: 'env_1' }) - }) - - it('carries an environment and a model through together', () => { - const req = applyComposerChoices( - { claude_code: { prompt: 'ship it' } }, - { environment: { kind: 'named', id: 'env_1' }, model: 'claude-fable-5' }, - ) - expect(req).toEqual({ - claude_code: { prompt: 'ship it', model: 'claude-fable-5' }, - environment: 'env_1', - }) - }) - - // `empty` is the bare sandbox: `{}` on the wire, said outright. - it('sends an empty object for the empty preset', () => { - const req = applyComposerChoices( - { repositories: ['acme/api'] }, - { ...untouched, environment: { kind: 'empty' } }, - ) - expect(req).toEqual({ repositories: ['acme/api'], environment: {} }) - }) - - it('does not mutate the request it was given', () => { - const base = { claude_code: { prompt: 'hi' }, repositories: ['acme/api'] } - applyComposerChoices(base, { ...untouched, environment: { kind: 'custom', pane: EMPTY_PANE } }) - expect(base).toEqual({ claude_code: { prompt: 'hi' }, repositories: ['acme/api'] }) - }) - - // The whole point of the pane: once custom it IS the sandbox, shipped as the - // request's own environment object — and the context repo key goes, since - // the pane's checkbox is now the only word on whether that repo is cloned. - it('ships the whole pane when custom, dropping the context repo key', () => { - const req = applyComposerChoices( - { repositories: ['acme/api'] }, - { - ...untouched, - environment: { - kind: 'custom', - pane: { - ...EMPTY_PANE, - repositories: [{ fullName: 'acme/api', ref: 'main' }], - variables: [{ name: 'NODE_ENV', value: 'production' }], - mcpServers: [{ name: 'linear', command: null, url: null }], - }, - }, - }, - ) - expect(req).toEqual({ - environment: { - repositories: [{ owner: 'acme', name: 'api', ref: 'main' }], - variables: [{ name: 'NODE_ENV', value: 'production' }], - mcp_servers: [{ name: 'linear' }], - }, - }) - }) -}) - -describe('paneEnvironment', () => { - // Empty lists ship as empty arrays: the environment object is the whole - // sandbox, so "none" has to be said, not left out. - it('states every list, even empty', () => { - expect(paneEnvironment(EMPTY_PANE)).toEqual({ - repositories: [], - variables: [], - mcp_servers: [], - }) - }) - - it('omits the value of a variable that resolves from stored secrets', () => { - expect( - paneEnvironment({ ...EMPTY_PANE, variables: [{ name: 'API_TOKEN', value: null }] }), - ).toMatchObject({ variables: [{ name: 'API_TOKEN' }] }) - }) - - // Compute, image and hooks are scalar blocks, so only the set fields ship — - // an unset one keeps whatever the server resolves. - it('sends only the set compute fields', () => { - expect( - paneEnvironment({ ...EMPTY_PANE, compute: { cpu: '4', memory: '16GB', timeout: '' } }), - ).toMatchObject({ compute: { cpu: 4, memory: '16GB' } }) - }) - - it('sends only the set image fields', () => { - expect( - paneEnvironment({ ...EMPTY_PANE, image: { build_base: '', after_checkout: 'npm install' } }), - ).toMatchObject({ hooks: { after_checkout: 'npm install' } }) - }) - - it('sends only the set hook fields', () => { - expect( - paneEnvironment({ ...EMPTY_PANE, hooks: { post_start: 'doppler setup', post_clone: '' } }), - ).toMatchObject({ hooks: { post_start: 'doppler setup' } }) - }) - - // A server the pane was seeded with keeps its own entry, so a definition the - // launcher's three fields can't hold survives a custom run. - it('ships a seeded server verbatim, and builds the rest from their fields', () => { - const seeded = { name: 'my-tools', command: 'npx', args: ['my-tools'], env: { A: '1' } } - const env = paneEnvironment({ - ...EMPTY_PANE, - mcpServers: [ - { name: 'my-tools', command: 'npx', url: null, raw: seeded }, - { name: 'docs', command: null, url: 'https://mcp.example.com' }, - ], - }) - expect(env.mcp_servers).toEqual([seeded, { name: 'docs', url: 'https://mcp.example.com' }]) - }) -}) - -describe('environmentPane', () => { - it('reads every field the pane shows off an environment config', () => { - expect( - environmentPane({ - repositories: [{ owner: 'acme', name: 'api', ref: 'main' }, { name: 'solo' }], - variables: [{ name: 'A', value: '1' }, { name: 'B' }], - mcp_servers: ['linear', { name: 'docs', url: 'https://x' }], - compute: { cpu: 4, memory: '16GB' }, - hooks: { after_checkout: 'npm ci', before_start: 'make' }, - }), - ).toEqual({ - repositories: [ - { fullName: 'acme/api', ref: 'main' }, - { fullName: 'solo', ref: null }, - ], - variables: [ - { name: 'A', value: '1' }, - { name: 'B', value: null }, - ], - mcpServers: [ - { name: 'linear', command: null, url: null, raw: 'linear' }, - { name: 'docs', command: null, url: 'https://x', raw: { name: 'docs', url: 'https://x' } }, - ], - compute: { cpu: '4', memory: '16GB', timeout: '' }, - image: { build_base: '', after_checkout: 'npm ci' }, - hooks: { post_start: '', post_clone: '' }, - rawHooks: { after_checkout: 'npm ci', before_start: 'make' }, - }) - }) - - it('is the empty pane for an environment that sets nothing, and for none at all', () => { - expect(environmentPane({})).toEqual(EMPTY_PANE) - expect(environmentPane(null)).toEqual(EMPTY_PANE) - }) - - // A script keeps its newlines here: the pane ships what it holds, and only its - // row flattens (oneLine). - it('keeps a multi-line script whole', () => { - expect(environmentPane({ hooks: { after_checkout: 'a\nb' } }).image.after_checkout).toBe('a\nb') - }) -}) - -describe('resolveRepoFullName', () => { - const connected = ['ellipsis-dev/ellipsis', 'ellipsis-dev/cli'] - - // An environment YAML may write "name: ellipsis" with no owner, and that is the - // same repository as the connected "ellipsis-dev/ellipsis" — one row, not two. - it('resolves a bare name against the connected repositories', () => { - expect(resolveRepoFullName('ellipsis', connected)).toBe('ellipsis-dev/ellipsis') - expect(resolveRepoFullName('cli', connected)).toBe('ellipsis-dev/cli') - }) - - it('leaves an owner-qualified name and an unknown one alone', () => { - expect(resolveRepoFullName('other-org/ellipsis', connected)).toBe('other-org/ellipsis') - expect(resolveRepoFullName('mystery', connected)).toBe('mystery') - }) - - // Two owners with the same repo name would be a guess, so it stays as written. - it('does not guess between two owners of the same name', () => { - expect(resolveRepoFullName('api', ['acme/api', 'other/api'])).toBe('api') - }) -}) - -describe('environmentPane repository dedupe', () => { - it('seeds an ownerless entry onto its connected row', () => { - expect( - environmentPane({ repositories: [{ name: 'ellipsis' }] }, ['ellipsis-dev/ellipsis']) - .repositories, - ).toEqual([{ fullName: 'ellipsis-dev/ellipsis', ref: null }]) - }) -}) - -describe('scriptRowLines', () => { - const script = ['a', 'b', 'c', 'd', 'e', 'f', 'g'].join('\n') - - it('caps a long script and counts what it hid', () => { - expect(scriptRowLines(script, false)).toEqual({ - lines: ['a', 'b', 'c', 'd', 'e'], - hidden: 2, - }) - }) - - it('shows every line while the row is open', () => { - expect(scriptRowLines(script, true).hidden).toBe(0) - expect(scriptRowLines(script, true).lines).toHaveLength(7) - }) - - it('hides nothing from a short script', () => { - expect(scriptRowLines('one\ntwo', false)).toEqual({ lines: ['one', 'two'], hidden: 0 }) - }) -}) - -describe('paneEquals', () => { - it('ignores the order a list was built in', () => { - const a = { - ...EMPTY_PANE, - repositories: [{ fullName: 'acme/api', ref: null }, { fullName: 'acme/web', ref: null }], - } - const b = { - ...EMPTY_PANE, - repositories: [{ fullName: 'acme/web', ref: null }, { fullName: 'acme/api', ref: null }], - } - expect(paneEquals(a, b)).toBe(true) - }) - - it('sees a changed field, a dropped entry and an edited ref', () => { - const base = { ...EMPTY_PANE, repositories: [{ fullName: 'acme/api', ref: null }] } - expect(paneEquals(base, EMPTY_PANE)).toBe(false) - expect( - paneEquals(base, { ...EMPTY_PANE, repositories: [{ fullName: 'acme/api', ref: 'dev' }] }), - ).toBe(false) - expect(paneEquals(base, { ...base, compute: { ...EMPTY_COMPUTE, cpu: '4' } })).toBe(false) - }) -}) - -describe('mcpServerName', () => { - it('reads the name off every shape the config admits', () => { - expect(mcpServerName('linear')).toBe('linear') - expect(mcpServerName({ name: 'docs', url: 'https://x' })).toBe('docs') - expect(mcpServerName({})).toBe('') - }) -}) - -describe('oneLine', () => { - it('collapses a script to the one line its row is', () => { - expect(oneLine('npm ci\n npm test\n')).toBe('npm ci npm test') - expect(oneLine('')).toBe('') - }) -}) - -describe('builtInMcpServers', () => { - it('lists only the connected integrations that back a built-in server', () => { - expect(builtInMcpServers({ linear: { org: 'x' }, slack: null })).toEqual(['linear']) - expect(builtInMcpServers({})).toEqual([]) - expect(builtInMcpServers({ linear: {}, slack: {} })).toEqual(['linear', 'slack']) - }) -}) - -describe('mcpServerEntry', () => { - it('splits a command line into the stdio shape', () => { - expect( - mcpServerEntry({ name: 't', command: 'npx -y my-tools-mcp', url: null }), - ).toEqual({ name: 't', command: 'npx', args: ['-y', 'my-tools-mcp'] }) - expect(mcpServerEntry({ name: 't', command: 'server-bin', url: null })).toEqual({ - name: 't', - command: 'server-bin', - }) - }) - - it('builds the remote shape from a url, and a bare name from neither', () => { - expect(mcpServerEntry({ name: 'd', command: null, url: 'https://x' })).toEqual({ - name: 'd', - url: 'https://x', - }) - expect(mcpServerEntry({ name: 'linear', command: null, url: null })).toEqual({ - name: 'linear', - }) - }) -}) - -describe('validateMcpServer', () => { - it('requires a name and rejects command with url', () => { - expect(validateMcpServer({ name: '', command: null, url: null })).toBeTruthy() - expect(validateMcpServer({ name: 'x', command: 'c', url: 'u' })).toBeTruthy() - expect(validateMcpServer({ name: 'x', command: 'c', url: null })).toBeNull() - expect(validateMcpServer({ name: 'x', command: null, url: 'u' })).toBeNull() - expect(validateMcpServer({ name: 'x', command: null, url: null })).toBeNull() - }) -}) - -describe('computeOverride', () => { - it('parses cpu to a number and keeps the others as typed', () => { - expect(computeOverride({ cpu: '4', memory: '16GB', timeout: '30m' })).toEqual({ - cpu: 4, - memory: '16GB', - timeout: '30m', - }) - }) - - it('drops blank and unparseable fields', () => { - expect(computeOverride(EMPTY_COMPUTE)).toEqual({}) - expect(computeOverride({ cpu: '..', memory: ' ', timeout: '' })).toEqual({}) - }) -}) - -describe('fieldsOverride', () => { - it('keeps only the set fields, trimmed', () => { - expect(fieldsOverride({ build_base: '', after_checkout: ' npm install ' })).toEqual({ - after_checkout: 'npm install', - }) - expect(fieldsOverride(EMPTY_IMAGE)).toEqual({}) - }) -}) - -describe('parseVariableEntry', () => { - it('reads a bare name as a stored-secret lookup', () => { - expect(parseVariableEntry(' API_TOKEN ')).toEqual({ name: 'API_TOKEN', value: null }) - }) - - it('splits on the first equals, so a value may contain more', () => { - expect(parseVariableEntry('DSN=https://a=b')).toEqual({ name: 'DSN', value: 'https://a=b' }) - }) - - // Distinct from a bare name: the empty string is a legitimate value. - it('reads a trailing equals as an empty value', () => { - expect(parseVariableEntry('EMPTY=')).toEqual({ name: 'EMPTY', value: '' }) - }) - - it('rejects a name the sandbox shell could not export', () => { - expect(parseVariableEntry('not-a-name=1')).toHaveProperty('error') - expect(parseVariableEntry('9LIVES=1')).toHaveProperty('error') - expect(parseVariableEntry(' ')).toHaveProperty('error') - }) -}) - -describe('environmentSectionRows', () => { - const input = { - repoNames: ['acme/api'], - checkedRepoNames: [] as string[], - secretNames: ['API_TOKEN', 'NPM_TOKEN'], - variables: [{ name: 'PORT', value: '3000' }], - builtInMcpServers: ['linear'], - mcpServers: [] as { name: string; command: string | null; url: string | null }[], - } - - // Each section is its own flat walk: hover indexes start at 0 per section. - it('lays each section out with its own hover indexes', () => { - expect(environmentSectionRows(input, 'repositories')).toEqual([ - { kind: 'repo', fullName: 'acme/api', hover: 0 }, - ]) - expect(environmentSectionRows(input, 'mcpServers')).toEqual([ - { kind: 'mcpServer', name: 'linear', hover: 0 }, - { kind: 'addMcpServer', hover: 1 }, - ]) - expect(environmentSectionRows(input, 'variables')).toEqual([ - { kind: 'variable', name: 'API_TOKEN', hover: 0 }, - { kind: 'variable', name: 'NPM_TOKEN', hover: 1 }, - { kind: 'variable', name: 'PORT', hover: 2 }, - { kind: 'addVariable', hover: 3 }, - ]) - expect(environmentSectionRows(input, 'image')).toEqual([ - { kind: 'image', field: 'build_base', hover: 0 }, - { kind: 'image', field: 'after_checkout', hover: 1 }, - ]) - expect(environmentSectionRows(input, 'hooks')).toEqual([ - { kind: 'hook', field: 'post_start', hover: 0 }, - { kind: 'hook', field: 'post_clone', hover: 1 }, - ]) - expect(environmentSectionRows(input, 'compute')).toEqual([ - { kind: 'compute', field: 'cpu', hover: 0 }, - { kind: 'compute', field: 'memory', hover: 1 }, - { kind: 'compute', field: 'timeout', hover: 2 }, - ]) - }) - - // A server the pane carries whose name is also a built-in rides that row, like - // the variables: one name, one row, whichever way it got in. - it('gives a built-in server one row even once it is checked', () => { - const rows = environmentSectionRows( - { - ...input, - mcpServers: [ - { name: 'linear', command: null, url: null }, - { name: 'my-server', command: 'npx my-server', url: null }, - ], - }, - 'mcpServers', - ) - expect( - rows.filter((r) => r.kind === 'mcpServer').map((r) => (r as { name: string }).name), - ).toEqual(['linear', 'my-server']) - }) - - // Same for a variable: checking a stored one adds it to the pane, so without - // the union it would appear twice. - it('gives a stored variable one row even once it is checked', () => { - const rows = environmentSectionRows( - { ...input, variables: [{ name: 'API_TOKEN', value: null }] }, - 'variables', - ) - expect(rows.filter((r) => 'name' in r && r.name === 'API_TOKEN')).toHaveLength(1) - }) - - // A variable an environment brought in that the account has no secret for - // still gets a row: the section shows the whole sandbox, not just what is - // stored. - it('lists a variable the account holds no secret for', () => { - const rows = environmentSectionRows( - { ...input, secretNames: [], variables: [{ name: 'NODE_ENV', value: 'production' }] }, - 'variables', - ) - expect(rows.filter((r) => r.kind === 'variable')).toEqual([ - { kind: 'variable', name: 'NODE_ENV', hover: 0 }, - ]) - }) - - // A checked repo grows a branch input row directly under it, so ↓ can land - // there and type a ref. - it('adds a branch row under a checked repo only', () => { - const rows = environmentSectionRows( - { ...input, repoNames: ['acme/api', 'acme/web'], checkedRepoNames: ['acme/api'] }, - 'repositories', - ) - expect(rows).toEqual([ - { kind: 'repo', fullName: 'acme/api', hover: 0 }, - { kind: 'repoRef', fullName: 'acme/api', hover: 1 }, - { kind: 'repo', fullName: 'acme/web', hover: 2 }, - ]) - }) - - // An empty repo list (not landed yet, or none connected) opens onto nothing. - it('has no rows while there are no repos', () => { - expect(environmentSectionRows({ ...input, repoNames: [] }, 'repositories')).toEqual([]) - }) -}) - -describe('environmentSectionAt', () => { - const input = { - repoNames: ['acme/api'], - checkedRepoNames: ['acme/api'], - secretNames: ['API_TOKEN'], - variables: [] as { name: string; value: string | null }[], - builtInMcpServers: [] as string[], - mcpServers: [] as { name: string; command: string | null; url: string | null }[], - } - - it('walks a section by its own hover index', () => { - expect(environmentSectionAt(input, 'repositories', 0)).toEqual({ - kind: 'repo', - fullName: 'acme/api', - }) - expect(environmentSectionAt(input, 'repositories', 1)).toEqual({ - kind: 'repoRef', - fullName: 'acme/api', - }) - expect(environmentSectionAt(input, 'mcpServers', 0)).toEqual({ kind: 'addMcpServer' }) - expect(environmentSectionAt(input, 'variables', 0)).toEqual({ - kind: 'variable', - name: 'API_TOKEN', - }) - expect(environmentSectionAt(input, 'variables', 1)).toEqual({ kind: 'addVariable' }) - expect(environmentSectionAt(input, 'image', 0)).toEqual({ - kind: 'image', - field: 'build_base', - }) - }) - - it("clamps a hover past the section's last row", () => { - expect(environmentSectionAt(input, 'compute', 99)).toEqual({ - kind: 'compute', - field: 'timeout', - }) - }) -}) - -describe('environmentPaneWalk', () => { - // Every section's rows in PANE_SECTIONS order, the first row of each - // carrying the section's label. - it('flattens the sections into one walk', () => { - const walk = environmentPaneWalk({ - repoNames: ['acme/api'], - checkedRepoNames: ['acme/api'], - secretNames: [], - variables: [], - builtInMcpServers: [], - mcpServers: [], - }) - expect(walk.map((w) => `${w.section}:${w.hover}${w.first ? '*' : ''}`)).toEqual([ - 'repositories:0*', - 'repositories:1', - 'mcpServers:0*', - 'variables:0*', - 'image:0*', - 'image:1', - 'hooks:0*', - 'hooks:1', - 'compute:0*', - 'compute:1', - 'compute:2', - ]) - }) -}) - -describe('environmentPresets', () => { - const environments = [ - { id: 'env_1', name: 'backend' }, - { id: 'env_2', name: 'web-e2e' }, - ] - - // The API's order, then the built-in `empty` closes the row. No - // organization default leads: the server has none. - it('keeps the API order and ends with empty', () => { - const presets = environmentPresets(environments) - expect(presets.map((p) => p.id)).toEqual(['env_1', 'env_2', EMPTY_ENVIRONMENT_ID]) - expect(presets[0].note).toBeNull() - }) - - // A synced environment's note says where its definition lives. - it('notes the source file a synced environment came from', () => { - const presets = environmentPresets( - environments, - new Map([['env_1', 'acme/api/e.yaml @ abcdef1']]), - ) - expect(presets[0].note).toBe('acme/api/e.yaml @ abcdef1') - }) -}) - -describe('restingPresetIndex', () => { - // Still loading: nothing is checked, so the request claims nothing. - it('is undefined while the list is loading', () => { - expect(restingPresetIndex(environmentPresets([]), false)).toBeUndefined() - }) - - it('is the first saved environment', () => { - expect(restingPresetIndex(environmentPresets([{ id: 'env_1', name: 'backend' }]), true)).toBe(0) - }) - - it('is empty when the account has no saved environment', () => { - const presets = environmentPresets([]) - expect(presets[restingPresetIndex(presets, true)!].id).toBe(EMPTY_ENVIRONMENT_ID) - }) -}) - -describe('variableRowLabel', () => { - // The name alone whenever the sandbox resolves the value; the VARIABLES - // heading above the rows already says what these are. - it('shows a value only when there is one', () => { - expect(variableRowLabel('API_TOKEN', undefined)).toBe('API_TOKEN') - expect(variableRowLabel('API_TOKEN', null)).toBe('API_TOKEN') - expect(variableRowLabel('PORT', '3000')).toBe('PORT=3000') - expect(variableRowLabel('EMPTY', '')).toBe('EMPTY=') - }) -}) - -describe('repositoryRefLabel', () => { - // The branch row says which ref a start would clone: the typed one, else the - // repo's default branch. - it('shows the typed ref over the default branch', () => { - expect(repositoryRefLabel('release', 'main')).toBe('release') - expect(repositoryRefLabel(null, 'main')).toBe('main') - expect(repositoryRefLabel(null, null)).toBeNull() - }) -}) - -describe('environmentSourceLabel', () => { - const repoNames = new Map([[42, 'acme/api']]) - - it('names the file and short sha a synced environment came from', () => { - expect( - environmentSourceLabel( - { - source_details: { repo_id: 42, path: 'agents/environments/dev.yaml', branch: 'main' }, - last_synced_commit_sha: 'abcdef1234567890', - }, - repoNames, - ), - ).toBe('acme/api/agents/environments/dev.yaml @ abcdef1') - }) - - it('drops the sha when the API has none', () => { - expect( - environmentSourceLabel( - { source_details: { repo_id: 42, path: 'e.yaml', branch: 'main' } }, - repoNames, - ), - ).toBe('acme/api/e.yaml') - }) - - // Only what the API already gave us: no source (API-managed), or a repo the - // connected list can't name, says nothing rather than guessing. - it('says nothing for an API-managed environment or an unknown repo', () => { - expect(environmentSourceLabel({ source_details: null }, repoNames)).toBeNull() - expect( - environmentSourceLabel( - { source_details: { repo_id: 7, path: 'e.yaml', branch: 'main' } }, - repoNames, - ), - ).toBeNull() - }) -}) +import { parseRepo, startRequestFromConfig, withContextRepository } from '../src/lib/sessions' describe('start request shaping', () => { it('parses a repository value into an environment entry', () => { @@ -1194,12 +63,3 @@ describe('start request shaping', () => { expect(() => withContextRepository({}, 'a/b/c')).toThrow(/owner\/name/) }) }) - -describe('paneWithRepository', () => { - it('checks the repo once, and does nothing without one', () => { - const pane = paneWithRepository(EMPTY_PANE, 'acme/api') - expect(pane.repositories).toEqual([{ fullName: 'acme/api', ref: null }]) - expect(paneWithRepository(pane, 'acme/api')).toBe(pane) - expect(paneWithRepository(pane, null)).toBe(pane) - }) -}) diff --git a/test/terminal-background.test.ts b/test/terminal-background.test.ts deleted file mode 100644 index e070a7a..0000000 --- a/test/terminal-background.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { modeFromColorFgBg, modeFromOscReply } from '../src/lib/terminalBackground' -import { applyThemeMode, darkPalette, lightPalette, theme } from '../src/lib/theme' - -describe('modeFromOscReply', () => { - it('reads a light background from a 16-bit-per-channel reply', () => { - expect(modeFromOscReply('\x1b]11;rgb:ffff/ffff/ffff\x07')).toBe('light') - expect(modeFromOscReply('\x1b]11;rgb:fdfd/f6f6/e3e3\x1b\\')).toBe('light') - }) - - it('reads a dark background', () => { - expect(modeFromOscReply('\x1b]11;rgb:1c1c/1b1b/1a1a\x07')).toBe('dark') - expect(modeFromOscReply('\x1b]11;rgb:0000/0000/0000\x07')).toBe('dark') - }) - - it('handles short channel widths', () => { - expect(modeFromOscReply('\x1b]11;rgb:ff/ff/ff\x07')).toBe('light') - expect(modeFromOscReply('\x1b]11;rgb:0/0/0\x07')).toBe('dark') - }) - - it('rejects anything that is not an OSC 11 color reply', () => { - expect(modeFromOscReply('')).toBeNull() - expect(modeFromOscReply('\x1b[6n')).toBeNull() - expect(modeFromOscReply('\x1b]11;?\x07')).toBeNull() - }) -}) - -describe('modeFromColorFgBg', () => { - it('reads the background from the last field', () => { - expect(modeFromColorFgBg('0;15')).toBe('light') - expect(modeFromColorFgBg('15;0')).toBe('dark') - expect(modeFromColorFgBg('0;default;7')).toBe('light') - }) - - it('returns null when unset or malformed', () => { - expect(modeFromColorFgBg(undefined)).toBeNull() - expect(modeFromColorFgBg('')).toBeNull() - expect(modeFromColorFgBg('default')).toBeNull() - }) -}) - -describe('applyThemeMode', () => { - it('swaps the live palette in place, so existing imports follow', () => { - applyThemeMode('light') - expect(theme.foreground).toBe(lightPalette.foreground) - applyThemeMode('dark') - expect(theme.foreground).toBe(darkPalette.foreground) - }) -}) diff --git a/test/theme.test.ts b/test/theme.test.ts deleted file mode 100644 index f621011..0000000 --- a/test/theme.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { theme } from '../src/lib/theme' - -describe('theme', () => { - it('carries a color for every token, so no call site has to fall back', () => { - for (const [name, value] of Object.entries(theme)) { - expect(value, name).toMatch(/^#[0-9a-f]{6}$/) - } - }) - - // The palette paints FOREGROUNDS only. Backgrounds are the terminal's, because - // transcript rows print into its scrollback and are never repainted — a fill - // there outlives the frame that drew it. No surface tokens means no call site - // can reintroduce one by reaching for a plausible name. - it('holds no surface colors', () => { - for (const name of Object.keys(theme)) { - expect(name).not.toMatch(/canvas|panel|surface|background/i) - } - }) -}) diff --git a/test/urls.test.ts b/test/urls.test.ts index d24ab4f..a7ef2ae 100644 --- a/test/urls.test.ts +++ b/test/urls.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { appLoginUrl, automationUrl, cliAuthUrl, hyperlink, sessionUrl } from '../src/lib/urls' +import { appLoginUrl, automationUrl, cliAuthUrl, sessionUrl } from '../src/lib/urls' describe('sessionUrl', () => { it('builds the account page link with the session query param', () => { @@ -48,18 +48,3 @@ describe('appLoginUrl', () => { ) }) }) - -describe('hyperlink', () => { - const ESC = String.fromCharCode(27) - - it('wraps the label in an OSC 8 escape on a TTY', () => { - const url = 'https://app.ellipsis.dev/octocat/sessions/session_8f2c' - expect(hyperlink(url, 'session_8f2c', true)).toBe( - `${ESC}]8;;${url}${ESC}\\session_8f2c${ESC}]8;;${ESC}\\`, - ) - }) - - it('returns the bare label off a TTY, so piped output stays clean', () => { - expect(hyperlink('https://x', 'label', false)).toBe('label') - }) -}) diff --git a/tsconfig.json b/tsconfig.json index b8b1ba1..f2a08fd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,6 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", - "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "resolveJsonModule": true, diff --git a/tsup.config.ts b/tsup.config.ts index 990cb43..c9cd126 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/cli.tsx'], + entry: ['src/cli.ts'], format: ['esm'], target: 'node22', clean: true,