From 739f3552a5e016de50af0b55675de58dac109575 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Wed, 24 Dec 2025 15:15:52 +0900 Subject: [PATCH 01/16] Replace in-TUI DevTools with browser server --- README.ja.md | 2 + README.md | 2 + bin/btuin | 4 + docs/devtools.ja.md | 238 ++++++++++ docs/devtools.md | 238 ++++++++++ docs/roadmap.ja.md | 6 +- docs/roadmap.md | 29 -- examples/devtools.ts | 73 +++ examples/hot-reload.ts | 15 + package.json | 5 + src/cli/args.ts | 131 ++++++ src/cli/index.ts | 2 + src/cli/main.ts | 132 ++++++ src/dev/hot-reload-state.ts | 77 ++++ src/dev/hot-reload.ts | 395 ++++++++++++++++ src/dev/index.ts | 2 + src/devtools/controller.ts | 43 ++ src/devtools/index.ts | 10 + src/devtools/log-stream.ts | 66 +++ src/devtools/server.ts | 482 ++++++++++++++++++++ src/devtools/stream.ts | 130 ++++++ src/devtools/types.ts | 80 ++++ src/devtools/use-log.ts | 82 ++++ src/index.ts | 2 + src/runtime/app.ts | 1 + src/runtime/loop.ts | 23 +- src/runtime/render-loop.ts | 12 + src/runtime/types.ts | 4 + src/terminal/capture.ts | 37 +- src/types/index.ts | 11 + tests/units/cli/args.test.ts | 54 +++ tests/units/dev/hot-reload-state.test.ts | 29 ++ tests/units/dev/hot-reload.test.ts | 68 +++ tests/units/runtime/app.test.ts | 28 +- tests/units/runtime/devtools-server.test.ts | 91 ++++ tests/units/runtime/devtools-stream.test.ts | 163 +++++++ tests/units/runtime/use-log.test.ts | 55 +++ tests/units/terminal/capture.test.ts | 13 + 38 files changed, 2797 insertions(+), 38 deletions(-) create mode 100755 bin/btuin create mode 100644 docs/devtools.ja.md create mode 100644 docs/devtools.md delete mode 100644 docs/roadmap.md create mode 100644 examples/devtools.ts create mode 100644 examples/hot-reload.ts create mode 100644 src/cli/args.ts create mode 100644 src/cli/index.ts create mode 100644 src/cli/main.ts create mode 100644 src/dev/hot-reload-state.ts create mode 100644 src/dev/hot-reload.ts create mode 100644 src/dev/index.ts create mode 100644 src/devtools/controller.ts create mode 100644 src/devtools/index.ts create mode 100644 src/devtools/log-stream.ts create mode 100644 src/devtools/server.ts create mode 100644 src/devtools/stream.ts create mode 100644 src/devtools/types.ts create mode 100644 src/devtools/use-log.ts create mode 100644 tests/units/cli/args.test.ts create mode 100644 tests/units/dev/hot-reload-state.test.ts create mode 100644 tests/units/dev/hot-reload.test.ts create mode 100644 tests/units/runtime/devtools-server.test.ts create mode 100644 tests/units/runtime/devtools-stream.test.ts create mode 100644 tests/units/runtime/use-log.test.ts diff --git a/README.ja.md b/README.ja.md index ed47d60..a3a046a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -70,6 +70,8 @@ await app.mount({ inline: true, inlineCleanupOnExit: true }); - [**ドキュメント**](./docs/) (アーキテクチャ, ロードマップ) - [**Inline モード**](./docs/inline-mode.ja.md) +- [**DevTools**](./docs/devtools.ja.md) +- [**ホットリロード**](./docs/hot-reload.ja.md) - [**GitHub**](https://github.com/HALQME/btuin) (ソースコード, Issue) ## 言語 diff --git a/README.md b/README.md index 7b6e1dd..a083d73 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ await app.mount({ inline: true, inlineCleanupOnExit: true }); - [**Documentation**](./docs/) (Architecture, Roadmap) - [**Inline Mode**](./docs/inline-mode.md) +- [**DevTools**](./docs/devtools.md) +- [**Hot Reload**](./docs/hot-reload.md) - [**GitHub**](https://github.com/HALQME/btuin) (Source Code, Issues) ## Language diff --git a/bin/btuin b/bin/btuin new file mode 100755 index 0000000..c5fb964 --- /dev/null +++ b/bin/btuin @@ -0,0 +1,4 @@ +#!/usr/bin/env bun +import { btuinCli } from "../src/cli/main"; + +await btuinCli(Bun.argv.slice(2)); diff --git a/docs/devtools.ja.md b/docs/devtools.ja.md new file mode 100644 index 0000000..fd79365 --- /dev/null +++ b/docs/devtools.ja.md @@ -0,0 +1,238 @@ +# DevTools + +btuin には、TUI 開発時の観測性(ログ確認など)に特化した DevTools があります。 + +- ブラウザ DevTools(ローカルサーバ + WebSocket) +- 外部へログをストリーミング(file / TCP)して `tail -f` や `nc` で別ターミナルから閲覧 + +## 有効化 + +`createApp({ devtools: ... })` で有効化します: + +```ts +import { createApp, ui } from "btuin"; + +const app = createApp({ + devtools: { enabled: true }, + init: () => ({}), + render: () => ui.Text("Hello"), +}); +``` + +## ブラウザ DevTools(おすすめ) + +ローカルの DevTools サーバを起動します: + +```ts +import { createApp, ui } from "btuin"; + +const app = createApp({ + devtools: { + enabled: true, + server: { + host: "127.0.0.1", + port: 0, + onListen: ({ url }) => console.log(`[devtools] open ${url}`), + }, + }, + init: () => ({}), + render: () => ui.Text("Hello"), +}); +``` + +表示された URL をブラウザで開くと、ログとスナップショットが確認できます。 + +スナップショットは **Preview**(レイアウトのボックス + テキスト)と **JSON**(生の payload)の両方で確認できます。 + +## `useLog()` フック + +`useLog()` は capture した console 出力をリアクティブに参照するためのフックです(ログUIを自作したい場合に使えます)。 + +オプション: + +- `devtools.maxLogLines`(デフォルト: `1000`) + +```ts +import { defineComponent, useLog, ui } from "btuin"; + +export const LogView = defineComponent({ + setup() { + const log = useLog(); + return () => ui.Text(`lines: ${log.lines.value.length}`); + }, +}); +``` + +注意: + +- 基本はコンポーネント `init()` / `setup()` 内で呼ぶ想定(unmount で自動 cleanup)。 +- それ以外の場所で呼ぶ場合は `dispose()` を手動で呼んでください。 + +## file へストリーミング(JSONL) + +1行1イベントの JSONL 形式で追記します: + +```ts +devtools: { + enabled: true, + stream: { file: "/tmp/btuin-devtools.log" }, +} +``` + +例: + +```bash +tail -f /tmp/btuin-devtools.log | jq -r '.type + " " + .text' +``` + +フォーマット(1行=1イベント): + +```json +{ "text": "hello", "type": "stdout", "timestamp": 1730000000000 } +``` + +## TCP でストリーミング(JSONL) + +ローカルで TCP サーバを起動し、接続クライアントへ JSONL を流します: + +```ts +devtools: { + enabled: true, + stream: { + tcp: { + host: "127.0.0.1", + port: 9229, + backlog: 200, + onListen: ({ host, port }) => console.log(`DevTools TCP: ${host}:${port}`), + }, + }, +} +``` + +別ターミナルから接続: + +```bash +nc 127.0.0.1 9229 | jq -r '.type + " " + .text' +``` + +Backlog: + +- `backlog` は直近のログをメモリに保持し、新規接続時に先頭へフラッシュするための行数です。 +- 接続前後のタイミングでログを取りこぼしにくくします。 + +セキュリティ注意: + +- 特別な理由がなければ `127.0.0.1` にバインドしてください。 +- stdout/stderr が流れるので、公開ポートにする場合は漏洩リスクを理解した上で運用してください。 + +# ホットリロード(開発用ランナー) + +`btuin` の raw 入力処理はプロセス全体で共有されるシングルトンを使っています。そのため、プロセス内で “remount” を繰り返すような HMR(ホットモジュールリロード)的な実装をすると、キー入力ハンドラが積み上がって入力が二重に届くなどの問題が起きます。 + +そこで現状は、開発時のホットリロードは **プロセスを再起動する方式(dev runner)** を推奨します。変更検知で同じターミナル上で TUI を再実行するだけです。 + +## CLI + +```bash +btuin dev [options] [-- ] +``` + +例: + +```bash +btuin dev examples/devtools.ts +btuin dev src/main.ts --watch src --watch examples +btuin dev src/main.ts -- --foo bar +``` + +主なオプション: + +- `--watch `(複数指定可) +- `--debounce `(デフォルト: `50`) +- `--cwd `(デフォルト: `process.cwd()`) +- `--no-preserve-state`(デフォルト: preserve 有効) +- `--no-tcp`(TCP リロードトリガー無効化) +- `--tcp-host `(デフォルト: `127.0.0.1`) +- `--tcp-port `(デフォルト: `0`) + +## リスタート時のステート保持 + +アプリ側で `enableHotReloadState()` を使うと、リスタート間で状態を引き継げます。 + +ステート保持を無効化: + +```bash +btuin dev examples/devtools.ts --no-preserve-state +``` + +または、ランナー用のスクリプトを作ります: + +```ts +import { runHotReloadProcess } from "btuin"; + +runHotReloadProcess({ + command: "bun", + args: ["examples/devtools.ts"], + watch: { paths: ["src", "examples"] }, +}); +``` + +実行: + +```bash +bun run examples/hot-reload.ts +``` + +## TCPトリガ(任意) + +`btuin dev` はデフォルトで TCP を有効化(ポートは自動選択)します。コード側で明示設定することもできます: + +```ts +import { runHotReloadProcess } from "btuin"; + +runHotReloadProcess({ + command: "bun", + args: ["examples/devtools.ts"], + watch: { paths: ["src", "examples"] }, + tcp: { + host: "127.0.0.1", + port: 0, + onListen: ({ host, port }) => { + process.stderr.write(`[btuin] hot-reload tcp: ${host}:${port}\n`); + }, + }, +}); +``` + +別ターミナルからトリガ: + +```bash +printf 'reload\n' | nc 127.0.0.1 +``` + +JSONLでもOK: + +```bash +printf '{"type":"reload"}\n' | nc 127.0.0.1 +``` + +## ステート保持(任意 / opt-in) + +この方式はプロセスを再起動するため、通常はメモリ上の状態はリセットされます。 + +再起動後も状態を引き継ぎたい場合は、アプリ側で opt-in します: + +```ts +import { enableHotReloadState, ref } from "btuin"; + +const count = ref(0); + +enableHotReloadState({ + getSnapshot: () => ({ count: count.value }), + applySnapshot: (snapshot) => { + if (!snapshot || typeof snapshot !== "object") return; + const maybe = (snapshot as any).count; + if (typeof maybe === "number") count.value = maybe; + }, +}); +``` diff --git a/docs/devtools.md b/docs/devtools.md new file mode 100644 index 0000000..b9461c5 --- /dev/null +++ b/docs/devtools.md @@ -0,0 +1,238 @@ +# DevTools + +btuin includes a lightweight DevTools layer focused on observability during TUI development. + +- Browser DevTools (local server + WebSocket) +- Stream logs externally (file / TCP) so you can `tail -f` or `nc` from another terminal + +## Enable + +Enable DevTools via `createApp({ devtools: ... })`: + +```ts +import { createApp, ui } from "btuin"; + +const app = createApp({ + devtools: { enabled: true }, + init: () => ({}), + render: () => ui.Text("Hello"), +}); +``` + +## Browser DevTools (recommended) + +Start the local DevTools server: + +```ts +import { createApp, ui } from "btuin"; + +const app = createApp({ + devtools: { + enabled: true, + server: { + host: "127.0.0.1", + port: 0, + onListen: ({ url }) => console.log(`[devtools] open ${url}`), + }, + }, + init: () => ({}), + render: () => ui.Text("Hello"), +}); +``` + +Open the printed URL in your browser. It shows logs and a snapshot stream. + +The Snapshot view includes a simple **Preview** (layout boxes + text) and a **JSON** view (raw snapshot payload). + +## `useLog()` hook + +`useLog()` exposes captured console output as reactive state (useful for building your own log UI). + +Options: + +- `devtools.maxLogLines` (default: `1000`) + +```ts +import { defineComponent, useLog, ui } from "btuin"; + +export const LogView = defineComponent({ + setup() { + const log = useLog(); + return () => ui.Text(`lines: ${log.lines.value.length}`); + }, +}); +``` + +Notes: + +- Intended to be called inside component `init()`/`setup()` (auto-disposed on unmount). +- If you call it outside component initialization, call `dispose()` yourself. + +## Stream logs to a file (JSONL) + +Append each captured line as JSONL: + +```ts +devtools: { + enabled: true, + stream: { file: "/tmp/btuin-devtools.log" }, +} +``` + +Example: + +```bash +tail -f /tmp/btuin-devtools.log | jq -r '.type + " " + .text' +``` + +Format (one line per event): + +```json +{ "text": "hello", "type": "stdout", "timestamp": 1730000000000 } +``` + +## Stream logs over TCP (JSONL) + +Start a local TCP server and stream JSONL to connected clients: + +```ts +devtools: { + enabled: true, + stream: { + tcp: { + host: "127.0.0.1", + port: 9229, + backlog: 200, + onListen: ({ host, port }) => console.log(`DevTools TCP: ${host}:${port}`), + }, + }, +} +``` + +Connect from another terminal: + +```bash +nc 127.0.0.1 9229 | jq -r '.type + " " + .text' +``` + +Backlog: + +- `backlog` is the number of most recent log lines kept in memory and flushed to new clients. +- This helps avoid missing logs around connect timing. + +Security notes: + +- Bind to `127.0.0.1` unless you explicitly want remote access. +- Do not expose the port publicly unless you accept leaking stdout/stderr content. + +# Hot Reload (Dev Runner) + +`btuin` treats raw terminal input handling as a process-wide singleton. Because of that, doing a true in-process “remount” loop (HMR-style) would accumulate key handlers and lead to duplicated input events. + +Instead, the recommended development workflow is to **restart the app process** on changes (hot reload as a dev runner). It simply re-runs your TUI in the same terminal. + +## CLI + +```bash +btuin dev [options] [-- ] +``` + +Examples: + +```bash +btuin dev examples/devtools.ts +btuin dev src/main.ts --watch src --watch examples +btuin dev src/main.ts -- --foo bar +``` + +Options: + +- `--watch ` (repeatable) +- `--debounce ` (default: `50`) +- `--cwd ` (default: `process.cwd()`) +- `--no-preserve-state` (default: preserve enabled) +- `--no-tcp` (disable TCP reload trigger) +- `--tcp-host ` (default: `127.0.0.1`) +- `--tcp-port ` (default: `0`) + +## Preserve state across restarts + +Use `enableHotReloadState()` in your app to opt into state preservation. + +Disable state preservation: + +```bash +btuin dev examples/devtools.ts --no-preserve-state +``` + +Or create a small runner script: + +```ts +import { runHotReloadProcess } from "btuin"; + +runHotReloadProcess({ + command: "bun", + args: ["examples/devtools.ts"], + watch: { paths: ["src", "examples"] }, +}); +``` + +Run it: + +```bash +bun run examples/hot-reload.ts +``` + +## TCP Trigger (Optional) + +`btuin dev` enables TCP by default (ephemeral port). You can also configure it in code: + +```ts +import { runHotReloadProcess } from "btuin"; + +runHotReloadProcess({ + command: "bun", + args: ["examples/devtools.ts"], + watch: { paths: ["src", "examples"] }, + tcp: { + host: "127.0.0.1", + port: 0, + onListen: ({ host, port }) => { + process.stderr.write(`[btuin] hot-reload tcp: ${host}:${port}\n`); + }, + }, +}); +``` + +Trigger reload from another terminal: + +```bash +printf 'reload\n' | nc 127.0.0.1 +``` + +Or JSONL: + +```bash +printf '{"type":"reload"}\n' | nc 127.0.0.1 +``` + +## Preserving State (Opt-in) + +Because the runner restarts the process, in-memory state resets by default. + +If you want to preserve state across restarts, opt in from your app: + +```ts +import { enableHotReloadState, ref } from "btuin"; + +const count = ref(0); + +enableHotReloadState({ + getSnapshot: () => ({ count: count.value }), + applySnapshot: (snapshot) => { + if (!snapshot || typeof snapshot !== "object") return; + const maybe = (snapshot as any).count; + if (typeof maybe === "number") count.value = maybe; + }, +}); +``` diff --git a/docs/roadmap.ja.md b/docs/roadmap.ja.md index 390f5ee..8cec4c6 100644 --- a/docs/roadmap.ja.md +++ b/docs/roadmap.ja.md @@ -10,12 +10,12 @@ - [ ] ヒットテスト(`ComputedLayout` と座標の照合、重なり順の決定) - [ ] バブリング/伝播(子→親、キャンセル可能なイベントモデル) - [ ] Developer Tools - - [ ] シェル統合 + - [x] シェル統合 - [x] stdout/stderr capture 基盤(listener/console patch/テストモード): `src/terminal/capture.ts` - - [ ] `useLog`(capture → reactive state)でログUIを作る + - [x] `useLog`(capture → reactive state)でログUIを作る - [ ] デバッグ - [ ] インスペクターモード(境界線/座標/サイズ可視化) - - [ ] ホットリロード + - [x] ホットリロード - [x] 配布 - [x] GitHub Release 用 tarball 生成(`src/layout-engine/native/` 同梱): `.github/workflows/release.yml` - [x] `npm pack` の成果物を展開し、`src/layout-engine/native/` と `src/layout-engine/index.ts` の解決が噛み合うことを自動チェック diff --git a/docs/roadmap.md b/docs/roadmap.md deleted file mode 100644 index f68abdc..0000000 --- a/docs/roadmap.md +++ /dev/null @@ -1,29 +0,0 @@ -# Roadmap - -- [x] Input - - [x] Make input parser stateful (resistant to chunk splitting): `src/terminal/parser/ansi.ts` - - [x] Resolve ambiguity between standalone `ESC` vs `Alt+Key` - - [x] Normalize bracketed paste into a single event: `src/terminal/parser/ansi.ts` - - [x] Integrate bracketed paste on/off into the runtime -- [ ] Mouse - - [ ] Integrate mouse input (e.g., SGR) into the runtime (enable/disable, finalize event format) - - [ ] Hit testing (matching coordinates with `ComputedLayout`, determining z-order) - - [ ] Event bubbling/propagation (child to parent, cancellable event model) -- [ ] Developer Tools - - [ ] Shell Integration - - [x] stdout/stderr capture infrastructure (listener/console patch/test mode): `src/terminal/capture.ts` - - [ ] Create a log UI with `useLog` (capture -> reactive state) - - [ ] Debug - - [ ] Inspector mode (visualize borders/coordinates/sizes) - - [ ] Hot Reload -- [x] Distribution - - [x] Generate tarball for GitHub Release (including `src/layout-engine/native/`): `.github/workflows/release.yml` - - [x] Automated check to ensure `npm pack` artifacts work with `src/layout-engine/native/` and `src/layout-engine/index.ts` resolution -- [x] Inline Mode -- [ ] Components - - [ ] Bring `TextInput` to a practical level (editing, cursor movement, IME finalization) - - [ ] `ScrollView` / `ListView` (virtual scrolling as needed, mouse wheel support) -- [x] Safety - - [x] Add FFI boundary sync tests (Rust constants/structs ↔ JS definitions) to CI -- [ ] Documentation / Starter - - [ ] Expand `examples/` diff --git a/examples/devtools.ts b/examples/devtools.ts new file mode 100644 index 0000000..eed9f44 --- /dev/null +++ b/examples/devtools.ts @@ -0,0 +1,73 @@ +import { createApp, enableHotReloadState, ref, useLog } from "@/index"; +import { Text, VStack } from "@/view"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const logFile = join(tmpdir(), "btuin-devtools.log"); + +const app = createApp({ + devtools: { + enabled: true, + maxLogLines: 1000, + server: { + host: "127.0.0.1", + port: 0, + onListen: ({ url }) => console.log(`[devtools] open ${url}`), + }, + stream: { + file: logFile, + tcp: { + host: "127.0.0.1", + port: 9229, + backlog: 200, + }, + }, + }, + init({ onKey, onTick, runtime }) { + const count = ref(0); + const log = useLog({ maxLines: 200 }); + + enableHotReloadState({ + getSnapshot: () => ({ count: count.value }), + applySnapshot: (snapshot) => { + if (!snapshot || typeof snapshot !== "object") return; + const maybe = (snapshot as any).count; + if (typeof maybe === "number") count.value = maybe; + }, + }); + + onKey((k) => { + if (k.name === "up") count.value++; + if (k.name === "down") count.value--; + if (k.name === "l") console.log(`[app] count=${count.value}`); + if (k.name === "e") console.error(`[app] error (count=${count.value})`); + if (k.name === "q") runtime.exit(0); + }); + + onTick(() => { + // Keep some background noise for tail/stream demos. + if (count.value % 10 === 0) console.log(`[tick] count=${count.value}`); + }, 1000); + + return { count, log }; + }, + render({ count, log }) { + const tail = log.lines.value.slice(-8); + return VStack([ + Text("DevTools example").foreground("cyan"), + Text("DevTools: browser UI + log streaming"), + Text("Keys: Up/Down=counter l=console.log e=console.error q=quit"), + Text(`File stream: ${logFile}`), + Text("TCP stream: nc 127.0.0.1 9229 | jq -r '.type + \" \" + .text'"), + Text(`count: ${count.value}`).foreground("yellow"), + Text("LogTail (useLog):").foreground("cyan"), + ...tail.map((line, i) => + Text(`${line.type === "stderr" ? "ERR" : "LOG"} ${line.text}`).setKey(`log-tail-${i}`), + ), + ]) + .width("100%") + .height("100%"); + }, +}); + +await app.mount(); diff --git a/examples/hot-reload.ts b/examples/hot-reload.ts new file mode 100644 index 0000000..596aff4 --- /dev/null +++ b/examples/hot-reload.ts @@ -0,0 +1,15 @@ +import { runHotReloadProcess } from "@/dev"; + +runHotReloadProcess({ + command: "bun", + args: ["examples/devtools.ts"], + watch: { paths: ["src", "examples"] }, + tcp: { + host: "127.0.0.1", + port: 0, + onListen: ({ host, port }) => { + process.stderr.write(`[btuin] hot-reload tcp: ${host}:${port}\n`); + process.stderr.write(`[btuin] trigger: printf 'reload\\n' | nc ${host} ${port}\n`); + }, + }, +}); diff --git a/package.json b/package.json index 9f968e2..f03fb0b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,11 @@ "type": "git", "url": "git+https://github.com/HALQME/btuin.git" }, + "bin": { + "btuin": "./bin/btuin" + }, "files": [ + "bin", "README.md", "src", "tsconfig.json" @@ -15,6 +19,7 @@ "exports": { ".": "./src/index.ts", "./components": "./src/components/index.ts", + "./dev": "./src/dev/index.ts", "./layout": "./src/layout/index.ts", "./layout-engine": "./src/layout-engine/index.ts", "./reactivity": "./src/reactivity/index.ts", diff --git a/src/cli/args.ts b/src/cli/args.ts new file mode 100644 index 0000000..70ecfad --- /dev/null +++ b/src/cli/args.ts @@ -0,0 +1,131 @@ +export type BtuinCliParsed = + | { kind: "help" } + | { kind: "version" } + | { + kind: "dev"; + entry: string; + childArgs: string[]; + cwd?: string; + watch: string[]; + debounceMs?: number; + preserveState: boolean; + tcp: { enabled: false } | { enabled: true; host?: string; port?: number }; + }; + +function takeFlagValue(argv: string[], idx: number, flagName: string): string { + const value = argv[idx + 1]; + if (!value || value.startsWith("-")) { + throw new Error(`[btuin] missing value for ${flagName}`); + } + return value; +} + +export function parseBtuinCliArgs(argv: string[]): BtuinCliParsed { + const args = [...argv]; + if (args.length === 0) return { kind: "help" }; + + if (args.includes("-h") || args.includes("--help")) return { kind: "help" }; + if (args.includes("-v") || args.includes("--version")) return { kind: "version" }; + + const [subcommand, ...rest] = args; + if (subcommand !== "dev") return { kind: "help" }; + + let entry: string | null = null; + const childArgs: string[] = []; + const watch: string[] = []; + let debounceMs: number | undefined; + let cwd: string | undefined; + let tcpEnabled = true; + let tcpHost: string | undefined; + let tcpPort: number | undefined; + let preserveState = true; + + let passthrough = false; + for (let i = 0; i < rest.length; i++) { + const a = rest[i]!; + if (a === "--") { + passthrough = true; + continue; + } + + if (passthrough) { + childArgs.push(a); + continue; + } + + if (a === "--watch") { + const v = takeFlagValue(rest, i, "--watch"); + watch.push(v); + i++; + continue; + } + + if (a === "--debounce") { + const v = takeFlagValue(rest, i, "--debounce"); + const n = Number(v); + if (!Number.isFinite(n) || n < 0) throw new Error(`[btuin] invalid --debounce: ${v}`); + debounceMs = n; + i++; + continue; + } + + if (a === "--cwd") { + const v = takeFlagValue(rest, i, "--cwd"); + cwd = v; + i++; + continue; + } + + if (a === "--no-tcp") { + tcpEnabled = false; + continue; + } + + if (a === "--no-preserve-state") { + preserveState = false; + continue; + } + + if (a === "--tcp-host") { + const v = takeFlagValue(rest, i, "--tcp-host"); + tcpHost = v; + i++; + continue; + } + + if (a === "--tcp-port") { + const v = takeFlagValue(rest, i, "--tcp-port"); + const n = Number(v); + if (!Number.isInteger(n) || n < 0 || n > 65535) + throw new Error(`[btuin] invalid --tcp-port: ${v}`); + tcpPort = n; + i++; + continue; + } + + if (a.startsWith("-")) { + throw new Error(`[btuin] unknown option: ${a}`); + } + + if (!entry) { + entry = a; + continue; + } + + // Treat extra args as passthrough to the child. + childArgs.push(a); + } + + if (!entry) throw new Error("[btuin] missing entry path (e.g. btuin dev examples/devtools.ts)"); + + return { + kind: "dev", + entry, + childArgs, + cwd, + watch, + debounceMs, + preserveState, + tcp: tcpEnabled ? { enabled: true, host: tcpHost, port: tcpPort } : { enabled: false }, + }; +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..9000ac8 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,2 @@ +export * from "./args"; +export * from "./main"; diff --git a/src/cli/main.ts b/src/cli/main.ts new file mode 100644 index 0000000..ee9b610 --- /dev/null +++ b/src/cli/main.ts @@ -0,0 +1,132 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runHotReloadProcess } from "../dev/hot-reload"; +import { parseBtuinCliArgs } from "./args"; + +function printHelp() { + process.stderr.write( + [ + "btuin", + "", + "Usage:", + " btuin dev [options] [-- ]", + "", + "Examples:", + " btuin dev examples/devtools.ts", + " btuin dev src/main.ts --watch src --watch examples", + " btuin dev src/main.ts -- --foo bar", + "", + "Options:", + " --watch Add watch path (repeatable)", + " --debounce Debounce fs events (default: 50)", + " --cwd Child working directory (default: process.cwd())", + " --no-preserve-state Disable state preservation (default: enabled)", + " --no-tcp Disable TCP reload trigger", + " --tcp-host TCP bind host (default: 127.0.0.1)", + " --tcp-port TCP bind port (default: 0)", + " -h, --help Show help", + " -v, --version Print version", + "", + ].join("\n"), + ); +} + +function printVersion() { + try { + const pkgPath = fileURLToPath(new URL("../../package.json", import.meta.url)); + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { name?: string; version?: string }; + process.stdout.write(`${pkg.name ?? "btuin"} ${pkg.version ?? ""}`.trimEnd() + "\n"); + } catch { + process.stdout.write("btuin\n"); + } +} + +function toAbsolutePath(cwd: string, p: string): string { + return isAbsolute(p) ? p : resolve(cwd, p); +} + +export async function btuinCli(argv: string[]) { + let parsed: ReturnType; + try { + parsed = parseBtuinCliArgs(argv); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`${message}\n`); + printHelp(); + process.exitCode = 1; + return; + } + + if (parsed.kind === "help") { + printHelp(); + return; + } + + if (parsed.kind === "version") { + printVersion(); + return; + } + + const cwd = parsed.cwd ? resolve(process.cwd(), parsed.cwd) : process.cwd(); + + const entryAbs = toAbsolutePath(cwd, parsed.entry); + const watchPaths = (() => { + const out: string[] = []; + const add = (p: string) => { + const abs = toAbsolutePath(cwd, p); + if (!out.includes(abs)) out.push(abs); + }; + + if (parsed.watch.length > 0) { + for (const p of parsed.watch) add(p); + return out; + } + + const srcDir = join(cwd, "src"); + if (existsSync(srcDir)) add(srcDir); + + const entryDir = dirname(entryAbs); + if (existsSync(entryDir)) out.push(entryDir); + + if (out.length === 0) out.push(cwd); + return out; + })(); + + let tcp: + | undefined + | { + host?: string; + port?: number; + onListen: (info: { host: string; port: number }) => void; + } = undefined; + + if (parsed.tcp.enabled) { + tcp = { + host: parsed.tcp.host ?? "127.0.0.1", + port: parsed.tcp.port ?? 0, + onListen: ({ host, port }) => { + process.stderr.write(`[btuin] hot-reload tcp: ${host}:${port}\n`); + process.stderr.write(`[btuin] trigger: printf 'reload\\n' | nc ${host} ${port}\n`); + }, + }; + } + + runHotReloadProcess({ + command: "bun", + args: [entryAbs, ...parsed.childArgs], + cwd, + watch: { paths: watchPaths, debounceMs: parsed.debounceMs }, + preserveState: parsed.preserveState, + tcp: parsed.tcp.enabled + ? { + host: tcp!.host, + port: tcp!.port, + onListen: tcp!.onListen, + } + : undefined, + }); + + // Keep the CLI alive while the child runs. + await new Promise(() => {}); +} diff --git a/src/dev/hot-reload-state.ts b/src/dev/hot-reload-state.ts new file mode 100644 index 0000000..f7503ee --- /dev/null +++ b/src/dev/hot-reload-state.ts @@ -0,0 +1,77 @@ +type HotReloadIpcMessage = + | { type: "btuin:hot-reload:request-snapshot" } + | { type: "btuin:hot-reload:snapshot"; snapshot: unknown }; + +const SNAPSHOT_ENV_KEY = "BTUIN_HOT_RELOAD_SNAPSHOT"; + +function decodeSnapshot(encoded: string): unknown | null { + try { + const json = Buffer.from(encoded, "base64").toString("utf8"); + return JSON.parse(json) as unknown; + } catch { + return null; + } +} + +export interface EnableHotReloadStateOptions { + /** + * Create a JSON-serializable snapshot of your app state. + * This will be requested by the hot-reload runner before restart. + */ + getSnapshot: () => unknown; + + /** + * Restore from a previous snapshot (if present). + * Called once when this helper is first invoked. + */ + applySnapshot?: (snapshot: unknown) => void; +} + +let current: EnableHotReloadStateOptions | null = null; +let appliedEnvSnapshot = false; +let messageHandlerRegistered = false; + +/** + * Opt-in helper to preserve state across process restarts when using `btuin dev`. + * + * How it works: + * - The runner requests a snapshot via Bun IPC before restarting the process. + * - The runner passes the snapshot to the next process via `BTUIN_HOT_RELOAD_SNAPSHOT`. + */ +export function enableHotReloadState(options: EnableHotReloadStateOptions) { + current = options; + + if (!appliedEnvSnapshot && options.applySnapshot) { + const encoded = process.env[SNAPSHOT_ENV_KEY]; + if (encoded) { + const snapshot = decodeSnapshot(encoded); + if (snapshot !== null) { + try { + options.applySnapshot(snapshot); + } catch { + // ignore + } + } + } + appliedEnvSnapshot = true; + } + + const maybeSend = (process as any).send as undefined | ((msg: HotReloadIpcMessage) => void); + if (!maybeSend) return; + + if (messageHandlerRegistered) return; + messageHandlerRegistered = true; + + process.on("message", (message: any) => { + const m = message as HotReloadIpcMessage; + if (!m || typeof m !== "object" || !("type" in m)) return; + if ((m as any).type !== "btuin:hot-reload:request-snapshot") return; + if (!current) return; + + try { + maybeSend({ type: "btuin:hot-reload:snapshot", snapshot: current.getSnapshot() }); + } catch { + // ignore + } + }); +} diff --git a/src/dev/hot-reload.ts b/src/dev/hot-reload.ts new file mode 100644 index 0000000..5db944d --- /dev/null +++ b/src/dev/hot-reload.ts @@ -0,0 +1,395 @@ +import { watch, type FSWatcher } from "node:fs"; + +export interface HotReloadWatchOptions { + /** + * Paths to watch. Can be files or directories. + */ + paths: string[]; + + /** + * Debounce file change events. + * @default 50 + */ + debounceMs?: number; +} + +export interface HotReloadTcpOptions { + /** + * Bind host. + * @default "127.0.0.1" + */ + host?: string; + + /** + * Bind port. Use 0 to pick an ephemeral port. + * @default 0 + */ + port?: number; + + /** + * Called after the server starts listening (useful when `port: 0`). + */ + onListen?: (info: { host: string; port: number }) => void; +} + +export interface RunHotReloadProcessOptions { + /** + * Command to run your TUI entry (typically "bun"). + */ + command: string; + + /** + * Command arguments (e.g. ["run", "examples/devtools.ts"]). + */ + args?: string[]; + + /** + * Watch paths that trigger restarts. + */ + watch: HotReloadWatchOptions; + + /** + * Optional TCP reload server: send "reload\\n" (or JSONL {"type":"reload"}). + */ + tcp?: HotReloadTcpOptions; + + /** + * Working directory for the child process. + * @default process.cwd() + */ + cwd?: string; + + /** + * Extra env vars for the child process. + */ + env?: Record; + + /** + * Preserve state across restarts (opt-in from the app via `enableHotReloadState`). + * @default true + */ + preserveState?: boolean; + + /** + * Signal used when restarting the child. + * @default "SIGTERM" + */ + restartSignal?: NodeJS.Signals; + + /** + * Force-kill timeout when restarting. + * @default 1500 + */ + restartTimeoutMs?: number; +} + +export interface HotReloadProcessHandle { + /** + * Restart the child process. + */ + restart(): Promise; + /** + * Stop watchers, stop TCP server, and terminate the child process. + */ + close(): Promise; + /** + * Get whether a child is currently running. + */ + isRunning(): boolean; +} + +type TcpReloadMessage = "reload" | { type: "reload" }; +type HotReloadIpcMessage = + | { type: "btuin:hot-reload:request-snapshot" } + | { type: "btuin:hot-reload:snapshot"; snapshot: unknown }; + +const SNAPSHOT_ENV_KEY = "BTUIN_HOT_RELOAD_SNAPSHOT"; + +function parseTcpReloadMessage(line: string): TcpReloadMessage | null { + const trimmed = line.trim(); + if (trimmed === "reload") return "reload"; + + try { + const parsed: unknown = JSON.parse(trimmed); + if ( + typeof parsed === "object" && + parsed !== null && + "type" in parsed && + (parsed as any).type === "reload" + ) { + return { type: "reload" }; + } + } catch { + // ignore + } + return null; +} + +function encodeSnapshot(snapshot: unknown): string | null { + try { + return Buffer.from(JSON.stringify(snapshot), "utf8").toString("base64"); + } catch { + return null; + } +} + +function createDebouncedCallback(cb: () => void, debounceMs: number) { + let timer: ReturnType | null = null; + return () => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + cb(); + }, debounceMs); + }; +} + +function createWatchers(watchOptions: HotReloadWatchOptions, onChange: () => void): () => void { + const debounceMs = watchOptions.debounceMs ?? 50; + const trigger = createDebouncedCallback(onChange, debounceMs); + + const watchers: FSWatcher[] = []; + for (const p of watchOptions.paths) { + try { + watchers.push(watch(p, { recursive: true }, trigger)); + } catch { + try { + watchers.push(watch(p, trigger)); + } catch { + // ignore + } + } + } + + return () => { + for (const w of watchers.splice(0)) { + try { + w.close(); + } catch { + // ignore + } + } + }; +} + +export interface TcpReloadServerHandle { + close(): Promise; +} + +export function createTcpReloadServer( + options: HotReloadTcpOptions, + onReload: () => void, +): TcpReloadServerHandle { + const host = options.host ?? "127.0.0.1"; + const port = options.port ?? 0; + + let listener: Bun.TCPSocketListener<{ buf: string }> | null = null; + try { + listener = Bun.listen({ + hostname: host, + port, + data: { buf: "" }, + socket: { + open(socket) { + socket.data = { buf: "" }; + }, + data(socket, data) { + const chunk = Buffer.isBuffer(data) ? data.toString("utf8") : String(data); + socket.data.buf += chunk; + for (;;) { + const idx = socket.data.buf.indexOf("\n"); + if (idx < 0) break; + const line = socket.data.buf.slice(0, idx); + socket.data.buf = socket.data.buf.slice(idx + 1); + const msg = parseTcpReloadMessage(line); + if (msg) onReload(); + } + }, + error() { + // ignore + }, + close() { + // ignore + }, + }, + }); + + try { + options.onListen?.({ host: listener.hostname, port: listener.port }); + } catch { + // ignore + } + } catch { + // ignore + } + + return { + close: () => + new Promise((resolve) => { + try { + listener?.stop(true); + resolve(); + } catch { + resolve(); + } + }), + }; +} + +async function stopChild( + child: ReturnType | null, + signal: NodeJS.Signals, + timeoutMs: number, +): Promise { + if (!child) return; + + try { + child.kill(signal); + } catch { + // ignore + } + + const didExit = await Promise.race([ + child.exited.then(() => true).catch(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), + ]); + + if (!didExit) { + try { + child.kill("SIGKILL"); + } catch { + // ignore + } + await child.exited.catch(() => {}); + } +} + +export function runHotReloadProcess(options: RunHotReloadProcessOptions): HotReloadProcessHandle { + const cmd = [options.command, ...(options.args ?? [])]; + const restartSignal = options.restartSignal ?? "SIGTERM"; + const restartTimeoutMs = options.restartTimeoutMs ?? 1500; + const preserveState = options.preserveState ?? true; + + let closing = false; + let restarting = false; + let child: ReturnType | null = null; + let disposeWatchers: (() => void) | null = null; + let tcpServer: TcpReloadServerHandle | null = null; + let lastSnapshot: unknown = null; + let snapshotWaiter: ((snapshot: unknown) => void) | null = null; + + const close = async () => { + if (closing) return; + closing = true; + + process.off("SIGINT", onSigint); + + disposeWatchers?.(); + disposeWatchers = null; + + await tcpServer?.close(); + tcpServer = null; + + await stopChild(child, restartSignal, restartTimeoutMs); + child = null; + }; + + const restart = async () => { + if (closing) return; + if (restarting) return; + restarting = true; + + if (preserveState && child && typeof (child as any).send === "function") { + try { + const p = new Promise((resolve) => { + snapshotWaiter = resolve; + }); + (child as any).send({ + type: "btuin:hot-reload:request-snapshot", + } satisfies HotReloadIpcMessage); + const snapshot = await Promise.race([ + p, + new Promise((resolve) => setTimeout(() => resolve(null), 200)), + ]); + if (snapshot !== null) lastSnapshot = snapshot; + } catch { + // ignore + } finally { + snapshotWaiter = null; + } + } + + const prev = child; + child = null; + await stopChild(prev, restartSignal, restartTimeoutMs); + restarting = false; + start(); + }; + + const onSigint = () => void close().finally(() => process.exit(0)); + process.once("SIGINT", onSigint); + + const start = () => { + if (closing) return; + + const env: Record = { ...process.env } as Record; + if (options.env) { + for (const [key, value] of Object.entries(options.env)) { + if (value === undefined) { + delete env[key]; + } else { + env[key] = value; + } + } + } + + if (preserveState && lastSnapshot !== null) { + const encoded = encodeSnapshot(lastSnapshot); + if (encoded) env[SNAPSHOT_ENV_KEY] = encoded; + } + + child = Bun.spawn({ + cmd, + cwd: options.cwd ?? process.cwd(), + env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + serialization: "json", + ipc: (message) => { + const m = message as HotReloadIpcMessage; + if ( + m && + typeof m === "object" && + "type" in m && + (m as any).type === "btuin:hot-reload:snapshot" + ) { + snapshotWaiter?.((m as any).snapshot); + } + }, + }); + + child.exited + .then((code) => { + if (closing) return; + if (restarting) return; + void close().finally(() => process.exit(code ?? 0)); + }) + .catch(() => { + if (closing) return; + if (restarting) return; + void close().finally(() => process.exit(1)); + }); + }; + + disposeWatchers = createWatchers(options.watch, () => void restart()); + tcpServer = options.tcp ? createTcpReloadServer(options.tcp, () => void restart()) : null; + + start(); + + return { + restart, + close, + isRunning: () => child !== null, + }; +} diff --git a/src/dev/index.ts b/src/dev/index.ts new file mode 100644 index 0000000..6d4051d --- /dev/null +++ b/src/dev/index.ts @@ -0,0 +1,2 @@ +export * from "./hot-reload"; +export * from "./hot-reload-state"; diff --git a/src/devtools/controller.ts b/src/devtools/controller.ts new file mode 100644 index 0000000..8792048 --- /dev/null +++ b/src/devtools/controller.ts @@ -0,0 +1,43 @@ +import type { KeyEvent } from "../terminal/types/key-event"; +import type { ConsoleCaptureHandle } from "../terminal/capture"; +import { setupDevtoolsLogStreaming } from "./log-stream"; +import type { DevtoolsOptions } from "./types"; +import { setupDevtoolsServer, type DevtoolsSnapshot } from "./server"; + +export interface DevtoolsController { + handleKey(event: KeyEvent): boolean; + wrapView(root: import("../view/types/elements").ViewElement): import("../view/types/elements").ViewElement; + onLayout?(snapshot: DevtoolsSnapshot): void; + dispose(): void; +} + +export function createDevtoolsController(options: DevtoolsOptions | undefined): DevtoolsController { + const enabled = options?.enabled ?? false; + + const streaming = setupDevtoolsLogStreaming(options); + + const capture: ConsoleCaptureHandle | null = enabled ? streaming.capture : null; + const server = enabled ? setupDevtoolsServer(options, () => capture) : null; + + return { + handleKey: (event) => { + void event; + return false; + }, + + wrapView: (root) => root, + + onLayout: (snapshot) => { + server?.setSnapshot(snapshot); + }, + + dispose: () => { + try { + server?.dispose(); + } catch { + // ignore + } + streaming.dispose(); + }, + }; +} diff --git a/src/devtools/index.ts b/src/devtools/index.ts new file mode 100644 index 0000000..508929b --- /dev/null +++ b/src/devtools/index.ts @@ -0,0 +1,10 @@ +export { useLog } from "./use-log"; +export type { UseLogOptions, UseLogResult } from "./use-log"; + +export { createDevtoolsController } from "./controller"; +export type { DevtoolsController } from "./controller"; + +export { createJsonlFileLogStreamer, createJsonlTcpLogStreamer } from "./stream"; +export type { LogStreamer } from "./stream"; + +export type { DevtoolsOptions } from "./types"; diff --git a/src/devtools/log-stream.ts b/src/devtools/log-stream.ts new file mode 100644 index 0000000..504cd98 --- /dev/null +++ b/src/devtools/log-stream.ts @@ -0,0 +1,66 @@ +import { getConsoleCaptureInstance, type ConsoleCaptureHandle } from "../terminal/capture"; +import { createJsonlFileLogStreamer, createJsonlTcpLogStreamer, type LogStreamer } from "./stream"; +import type { DevtoolsOptions } from "./types"; + +export interface DevtoolsLogStreaming { + capture: ConsoleCaptureHandle | null; + dispose(): void; +} + +export function setupDevtoolsLogStreaming( + options: DevtoolsOptions | undefined, + onLine?: () => void, +): DevtoolsLogStreaming { + const enabled = options?.enabled ?? false; + if (!enabled) { + return { capture: null, dispose: () => {} }; + } + + const capture = getConsoleCaptureInstance({ maxLines: options?.maxLogLines ?? 1000 }); + + const streamers: LogStreamer[] = []; + const filePath = options?.stream?.file; + if (filePath) streamers.push(createJsonlFileLogStreamer(filePath)); + + const tcp = options?.stream?.tcp; + if (tcp) { + streamers.push( + createJsonlTcpLogStreamer({ + host: tcp.host, + port: tcp.port, + onListen: tcp.onListen, + backlog: tcp.backlog, + }), + ); + } + + const cleanupSubscribe = capture.subscribe((line) => { + onLine?.(); + for (const streamer of streamers) { + try { + streamer.writeLine(line); + } catch { + // ignore + } + } + }); + + return { + capture, + dispose: () => { + try { + cleanupSubscribe(); + } catch { + // ignore + } + + for (const streamer of streamers.splice(0)) { + try { + streamer.dispose(); + } catch { + // ignore + } + } + }, + }; +} diff --git a/src/devtools/server.ts b/src/devtools/server.ts new file mode 100644 index 0000000..7b53a77 --- /dev/null +++ b/src/devtools/server.ts @@ -0,0 +1,482 @@ +import type { ServerWebSocket } from "bun"; +import type { ComputedLayout } from "../layout-engine/types"; +import type { ConsoleCaptureHandle, ConsoleLine } from "../terminal/capture"; +import { isBlock, isText, type ViewElement } from "../view/types/elements"; +import type { DevtoolsOptions } from "./types"; + +export interface DevtoolsSnapshot { + size: { rows: number; cols: number }; + rootElement: ViewElement; + layoutMap: ComputedLayout; +} + +type LayoutBox = { x: number; y: number; width: number; height: number }; + +type ViewNode = { + key: string; + type: string; + text?: string; + children?: ViewNode[]; +}; + +type BrowserSnapshot = { + timestamp: number; + size: { rows: number; cols: number }; + tree: ViewNode; + layout: Record; +}; + +export interface DevtoolsServerHandle { + getInfo(): { host: string; port: number; url: string } | null; + setSnapshot(snapshot: DevtoolsSnapshot): void; + dispose(): void; +} + +function getKey(el: ViewElement): string { + return (el.key ?? el.identifier ?? "") as string; +} + +function serializeViewTree(root: ViewElement): { tree: ViewNode; keys: Set } { + const keys = new Set(); + + const walk = (el: ViewElement): ViewNode => { + const key = getKey(el); + keys.add(key); + + if (isText(el)) { + return { key, type: el.type, text: el.content }; + } + + if (isBlock(el)) { + return { + key, + type: el.type, + children: el.children.map(walk), + }; + } + + return { key, type: el.type }; + }; + + return { tree: walk(root), keys }; +} + +function buildBrowserSnapshot(snapshot: DevtoolsSnapshot): BrowserSnapshot { + const { tree, keys } = serializeViewTree(snapshot.rootElement); + const layout: Record = {}; + + for (const key of keys) { + const entry = snapshot.layoutMap[key]; + if (!entry) continue; + layout[key] = { x: entry.x, y: entry.y, width: entry.width, height: entry.height }; + } + + return { + timestamp: Date.now(), + size: snapshot.size, + tree, + layout, + }; +} + +function htmlDocument(): string { + return ` + + + + + btuin DevTools + + + +
+
btuin DevTools
+ connecting… + + WS: +
+
+
+
+

Snapshot

+
+ + +
+
+
+
+ + + (none) +
+
+
+ +
+
+

Logs

+
+
+
+ + +`; +} + +function safeJson(payload: any): string { + try { + return JSON.stringify(payload); + } catch { + return JSON.stringify({ type: "error", message: "failed to serialize payload" }); + } +} + +export function setupDevtoolsServer( + options: DevtoolsOptions | undefined, + getCapture: () => ConsoleCaptureHandle | null, +): DevtoolsServerHandle | null { + const cfg = options?.server; + if (!cfg) return null; + + const host = cfg.host ?? "127.0.0.1"; + const port = cfg.port ?? 0; + + const clients = new Set>(); + let nextClientId = 1; + let cleanupSubscribe: (() => void) | null = null; + let snapshot: BrowserSnapshot | null = null; + let info: { host: string; port: number; url: string } | null = null; + + let server: ReturnType | null = null; + try { + server = Bun.serve<{ id: number }>({ + hostname: host, + port, + fetch(req, s) { + const url = new URL(req.url); + if (url.pathname === "/ws") { + const ok = s.upgrade(req, { data: { id: nextClientId++ } }); + return ok ? undefined : new Response("upgrade failed", { status: 400 }); + } + if (url.pathname === "/" || url.pathname === "/index.html") { + return new Response(htmlDocument(), { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + return new Response("not found", { status: 404 }); + }, + websocket: { + open(ws) { + clients.add(ws); + + const capture = getCapture(); + if (capture) { + try { + ws.send( + safeJson({ + type: "logs", + lines: capture.getLines(), + }), + ); + } catch { + // ignore + } + } + if (snapshot) { + try { + ws.send(safeJson({ type: "snapshot", snapshot })); + } catch { + // ignore + } + } + }, + close(ws) { + clients.delete(ws); + }, + message(ws, message) { + const text = typeof message === "string" ? message : new TextDecoder().decode(message); + let msg: any = null; + try { + msg = JSON.parse(text); + } catch { + return; + } + if (!msg || typeof msg !== "object") return; + if (msg.type === "requestSnapshot" && snapshot) { + try { + ws.send(safeJson({ type: "snapshot", snapshot })); + } catch { + // ignore + } + } + }, + }, + }); + + const resolvedHost = server.hostname ?? host; + const resolvedPort = server.port ?? port; + const url = `http://${resolvedHost}:${resolvedPort}`; + info = { host: resolvedHost, port: resolvedPort, url }; + try { + cfg.onListen?.(info); + } catch { + // ignore + } + } catch { + // ignore server errors to avoid crashing the app + server = null; + } + + const broadcast = (payload: any) => { + if (clients.size === 0) return; + const text = safeJson(payload); + for (const ws of clients) { + try { + ws.send(text); + } catch { + try { + ws.close(); + } catch { + // ignore + } + clients.delete(ws); + } + } + }; + + const ensureSubscribed = () => { + if (cleanupSubscribe) return; + const capture = getCapture(); + if (!capture) return; + cleanupSubscribe = capture.subscribe((line: ConsoleLine) => { + broadcast({ type: "log", line }); + }); + }; + + ensureSubscribed(); + + return { + getInfo: () => info, + setSnapshot: (s) => { + snapshot = buildBrowserSnapshot(s); + broadcast({ type: "snapshot", snapshot }); + ensureSubscribed(); + }, + dispose: () => { + try { + cleanupSubscribe?.(); + } catch { + // ignore + } + cleanupSubscribe = null; + + for (const ws of clients) { + try { + ws.close(); + } catch { + // ignore + } + } + clients.clear(); + + try { + server?.stop(true); + } catch { + // ignore + } + server = null; + snapshot = null; + info = null; + }, + }; +} diff --git a/src/devtools/stream.ts b/src/devtools/stream.ts new file mode 100644 index 0000000..836daa3 --- /dev/null +++ b/src/devtools/stream.ts @@ -0,0 +1,130 @@ +import { mkdirSync, appendFileSync } from "node:fs"; +import path from "node:path"; +import type { ConsoleLine } from "../terminal/capture"; + +export interface LogStreamer { + writeLine(line: ConsoleLine): void; + dispose(): void; +} + +function ensureParentDir(filePath: string) { + const dir = path.dirname(filePath); + mkdirSync(dir, { recursive: true }); +} + +/** + * Streams console lines to a file as JSONL (newline-delimited JSON). + */ +export function createJsonlFileLogStreamer(filePath: string): LogStreamer { + ensureParentDir(filePath); + + return { + writeLine: (line) => { + const payload = JSON.stringify(line); + appendFileSync(filePath, payload + "\n", "utf8"); + }, + dispose: () => {}, + }; +} + +/** + * Streams console lines to connected TCP clients as JSONL. + */ +export function createJsonlTcpLogStreamer(options?: { + host?: string; + port?: number; + onListen?: (info: { host: string; port: number }) => void; + backlog?: number; +}): LogStreamer { + const host = options?.host ?? "127.0.0.1"; + const port = options?.port ?? 0; + const backlogLimit = Math.max(0, options?.backlog ?? 200); + + const sockets = new Set>(); + const backlog: string[] = []; + + let listener: Bun.TCPSocketListener | null = null; + try { + listener = Bun.listen({ + hostname: host, + port, + data: undefined, + socket: { + open(socket) { + sockets.add(socket); + + if (backlogLimit > 0 && backlog.length > 0) { + for (const payload of backlog) { + try { + socket.write(payload); + } catch { + // ignore + } + } + } + }, + close(socket) { + sockets.delete(socket); + }, + error(socket) { + sockets.delete(socket); + try { + socket.end(); + } catch { + // ignore + } + }, + }, + }); + + try { + options?.onListen?.({ host: listener.hostname, port: listener.port }); + } catch { + // ignore + } + } catch { + // ignore server errors to avoid crashing the app + } + + return { + writeLine: (line) => { + const payload = JSON.stringify(line) + "\n"; + if (backlogLimit > 0) { + backlog.push(payload); + if (backlog.length > backlogLimit) { + backlog.splice(0, backlog.length - backlogLimit); + } + } + + if (sockets.size === 0) return; + for (const socket of sockets) { + try { + socket.write(payload); + } catch { + sockets.delete(socket); + try { + socket.end(); + } catch { + // ignore + } + } + } + }, + dispose: () => { + for (const socket of sockets) { + try { + socket.end(); + } catch { + // ignore + } + } + sockets.clear(); + backlog.length = 0; + try { + listener?.stop(true); + } catch { + // ignore + } + }, + }; +} diff --git a/src/devtools/types.ts b/src/devtools/types.ts new file mode 100644 index 0000000..2fb1c2e --- /dev/null +++ b/src/devtools/types.ts @@ -0,0 +1,80 @@ +export interface DevtoolsOptions { + /** + * Enable built-in DevTools. + * + * Note: DevTools does not render an in-TUI panel. + * @default false + */ + enabled?: boolean; + + /** + * Maximum number of log lines kept in memory. + * @default 1000 + */ + maxLogLines?: number; + + /** + * Stream logs outside the TUI (no external window opened). + * Useful for `tail -f` or piping into other tools. + */ + stream?: { + /** + * Append console lines as JSONL (one JSON object per line). + * Example: `tail -f /tmp/btuin-devtools.log | jq -r .text` + */ + file?: string; + + /** + * Stream console lines as JSONL over TCP. + * Designed for local debugging: connect with `nc 127.0.0.1 `. + */ + tcp?: { + /** + * Bind host. + * @default "127.0.0.1" + */ + host?: string; + + /** + * Bind port. Use 0 to pick an ephemeral port. + * @default 0 + */ + port?: number; + + /** + * Called after the server starts listening (useful when `port: 0`). + */ + onListen?: (info: { host: string; port: number }) => void; + + /** + * Number of most recent log lines to keep and flush to newly connected clients. + * Helps avoid missing logs around connect timing. + * @default 200 + */ + backlog?: number; + }; + }; + + /** + * Start a local browser DevTools server. + * Serves a tiny UI + WebSocket event stream (logs + snapshots). + */ + server?: { + /** + * Bind host. + * @default "127.0.0.1" + */ + host?: string; + + /** + * Bind port. Use 0 to pick an ephemeral port. + * @default 0 + */ + port?: number; + + /** + * Called after the server starts listening (useful when `port: 0`). + */ + onListen?: (info: { host: string; port: number; url: string }) => void; + }; +} diff --git a/src/devtools/use-log.ts b/src/devtools/use-log.ts new file mode 100644 index 0000000..63cb0c2 --- /dev/null +++ b/src/devtools/use-log.ts @@ -0,0 +1,82 @@ +import { shallowRef } from "../reactivity"; +import type { Ref } from "../reactivity/ref"; +import { getCurrentInstance } from "../components/lifecycle"; +import { + getConsoleCaptureInstance, + type ConsoleCaptureHandle, + type ConsoleLine, +} from "../terminal/capture"; + +export interface UseLogOptions { + /** + * Maximum number of lines stored in the shared console buffer. + * Note: This is applied only when the singleton capture is first created. + */ + maxLines?: number; + + /** + * Include stdout lines. + * @default true + */ + stdout?: boolean; + + /** + * Include stderr lines. + * @default true + */ + stderr?: boolean; +} + +export interface UseLogResult { + lines: Ref; + clear: () => void; + dispose: () => void; + capture: ConsoleCaptureHandle; +} + +function filterLines( + all: ConsoleLine[], + options: { stdout: boolean; stderr: boolean }, +): ConsoleLine[] { + if (options.stdout && options.stderr) return all; + if (options.stdout) return all.filter((l) => l.type === "stdout"); + if (options.stderr) return all.filter((l) => l.type === "stderr"); + return []; +} + +/** + * Reactive access to captured console output. + * + * Intended usage: call inside a component `init()` (auto-disposes on unmount). + * If called outside a component init, call `dispose()` manually. + */ +export function useLog(options: UseLogOptions = {}): UseLogResult { + const stdout = options.stdout ?? true; + const stderr = options.stderr ?? true; + + const capture = getConsoleCaptureInstance({ maxLines: options.maxLines }); + const lines = shallowRef(filterLines(capture.getLines(), { stdout, stderr })); + + const refresh = () => { + lines.value = filterLines(capture.getLines(), { stdout, stderr }); + }; + + const dispose = capture.subscribe(() => { + refresh(); + }); + + const instance = getCurrentInstance(); + if (instance) { + instance.effects.push(dispose); + } + + return { + lines, + clear: () => { + capture.clear(); + refresh(); + }, + dispose, + capture, + }; +} diff --git a/src/index.ts b/src/index.ts index 5ead392..6b641dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,8 @@ */ export { createApp, App } from "./runtime"; +export * from "./dev"; +export * from "./devtools"; export { defineComponent } from "./components"; export * from "./view"; diff --git a/src/runtime/app.ts b/src/runtime/app.ts index 1f67f70..559b94b 100644 --- a/src/runtime/app.ts +++ b/src/runtime/app.ts @@ -215,6 +215,7 @@ export function app any>( onExit: config.onExit, profile: config.profile, inputParser: config.inputParser, + devtools: config.devtools, }); } diff --git a/src/runtime/loop.ts b/src/runtime/loop.ts index 1b4ac17..b500bfc 100644 --- a/src/runtime/loop.ts +++ b/src/runtime/loop.ts @@ -5,6 +5,7 @@ import { createInlineDiffRenderer } from "../renderer"; import { layout } from "../layout"; import { Block } from "../view/primitives"; import type { ViewElement } from "../view/types/elements"; +import { createDevtoolsController, type DevtoolsController } from "../devtools/controller"; import { createRenderer } from "./render-loop"; import { createErrorContext, createErrorHandler } from "./error-boundary"; import type { AppContext } from "./context"; @@ -15,6 +16,7 @@ export class LoopManager implements ILoopManager { private handleError: ReturnType; private cleanupTerminalFn: (() => void) | null = null; private cleanupOutputListeners: (() => void)[] = []; + private devtools: DevtoolsController | null = null; constructor(context: AppContext, handleError: ReturnType) { this.ctx = context; @@ -34,6 +36,9 @@ export class LoopManager implements ILoopManager { const pendingKeyEvents: KeyEvent[] = []; + this.devtools = createDevtoolsController(this.ctx.options.devtools); + this.cleanupOutputListeners.push(() => this.devtools?.dispose()); + terminal.onKey((event: KeyEvent) => { if (!state.mounted) { pendingKeyEvents.push(event); @@ -41,6 +46,8 @@ export class LoopManager implements ILoopManager { } try { + if (this.devtools?.handleKey(event)) return; + const handled = handleComponentKey(state.mounted, event); if (!handled && (event.sequence === "\x03" || (event.ctrl && event.name === "c"))) { app.exit(0, "sigint"); @@ -67,9 +74,21 @@ export class LoopManager implements ILoopManager { write: terminal.write, view: (): ViewElement => { if (!state.mounted) return Block(); - return renderComponent(state.mounted); + const root = renderComponent(state.mounted); + return this.devtools?.wrapView(root) ?? root; }, getState: () => ({}), + onLayout: ({ size, rootElement, layoutMap }) => { + try { + this.devtools?.onLayout?.({ + size, + rootElement, + layoutMap, + }); + } catch { + // ignore + } + }, handleError: this.handleError, profiler: profiler.isEnabled() ? profiler : undefined, deps: inline @@ -175,6 +194,8 @@ export class LoopManager implements ILoopManager { state.disposeResize(); updaters.disposeResize(null); } + + this.devtools = null; } cleanupTerminal() { diff --git a/src/runtime/render-loop.ts b/src/runtime/render-loop.ts index 8866699..36a39ed 100644 --- a/src/runtime/render-loop.ts +++ b/src/runtime/render-loop.ts @@ -55,6 +55,12 @@ export interface RenderLoopConfig { view: (state: State) => ViewElement; /** Function to get current state */ getState: () => State; + /** Optional hook after layout is computed (before render) */ + onLayout?: (args: { + size: TerminalSize; + rootElement: ViewElement; + layoutMap: ComputedLayout; + }) => void; /** Error handler */ handleError: (context: import("./error-boundary").ErrorContext) => void; /** Optional profiler */ @@ -147,6 +153,12 @@ export function createRenderer(config: RenderLoopConfig) { prevLayoutResult = layoutResult; prevLayoutSizeKey = layoutSizeKey; + try { + config.onLayout?.({ size: state.currentSize, rootElement, layoutMap: layoutResult }); + } catch { + // ignore devtools hook failures + } + let buf = pool.acquire(); if (buf === state.prevBuffer) { // Ensure prev/next buffers differ; diffing the same instance yields no output. diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 74a8fe6..3bf4f66 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -6,6 +6,8 @@ import type { TerminalAdapter } from "./terminal-adapter"; import type { PlatformAdapter } from "./platform-adapter"; import type { ProfileOptions } from "./profiler"; +import type { DevtoolsOptions } from "../devtools/types"; + export interface ILoopManager { start(rows: number, cols: number): void; stop(): void; @@ -22,6 +24,7 @@ export type AppConfig = { onExit?: () => void; profile?: ProfileOptions; inputParser?: InputParser; + devtools?: DevtoolsOptions; init: (ctx: ComponentInitContext) => State; render: (state: State) => ViewElement; }; @@ -53,4 +56,5 @@ export type CreateAppOptions = { platform?: PlatformAdapter; profile?: ProfileOptions; inputParser?: InputParser; + devtools?: DevtoolsOptions; }; diff --git a/src/terminal/capture.ts b/src/terminal/capture.ts index efe19d8..30adf13 100644 --- a/src/terminal/capture.ts +++ b/src/terminal/capture.ts @@ -341,6 +341,12 @@ export interface ConsoleCaptureHandle { * Stop capturing and clean up listeners. */ dispose(): void; + + /** + * Subscribe to newly captured console lines. + * Returns a cleanup function. + */ + subscribe(listener: (line: ConsoleLine) => void): () => void; } /** @@ -358,22 +364,36 @@ export interface ConsoleCaptureHandle { export function createConsoleCapture(options?: { maxLines?: number }): ConsoleCaptureHandle { const maxLines = options?.maxLines ?? 1000; const lines: ConsoleLine[] = []; + const subscribers = new Set<(line: ConsoleLine) => void>(); + + const notify = (line: ConsoleLine) => { + for (const listener of subscribers) { + try { + listener(line); + } catch { + // ignore subscriber errors + } + } + }; // Capture stdout const cleanupStdout = onStdout((text) => { const textLines = text.split("\n"); for (const line of textLines) { if (line) { - lines.push({ + const entry: ConsoleLine = { text: line, type: "stdout", timestamp: Date.now(), - }); + }; + lines.push(entry); // Limit buffer size if (lines.length > maxLines) { lines.shift(); } + + notify(entry); } } }); @@ -383,16 +403,19 @@ export function createConsoleCapture(options?: { maxLines?: number }): ConsoleCa const textLines = text.split("\n"); for (const line of textLines) { if (line) { - lines.push({ + const entry: ConsoleLine = { text: line, type: "stderr", timestamp: Date.now(), - }); + }; + lines.push(entry); // Limit buffer size if (lines.length > maxLines) { lines.shift(); } + + notify(entry); } } }); @@ -411,8 +434,14 @@ export function createConsoleCapture(options?: { maxLines?: number }): ConsoleCa dispose: () => { cleanupStdout(); cleanupStderr(); + subscribers.clear(); lines.length = 0; }, + + subscribe: (listener) => { + subscribers.add(listener); + return () => subscribers.delete(listener); + }, }; } diff --git a/src/types/index.ts b/src/types/index.ts index 1503ceb..98f9b4a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -25,14 +25,25 @@ export type { MountOptions, RenderMode, } from "../runtime/types"; +export type { DevtoolsOptions } from "../devtools/types"; +export type { + HotReloadProcessHandle, + HotReloadTcpOptions, + HotReloadWatchOptions, + RunHotReloadProcessOptions, + TcpReloadServerHandle, +} from "../dev/hot-reload"; +export type { EnableHotReloadStateOptions } from "../dev/hot-reload-state"; export type { PlatformAdapter } from "../runtime/platform-adapter"; export type { TerminalAdapter } from "../runtime/terminal-adapter"; export type { FrameMetrics, ProfileOptions, ProfileOutput } from "../runtime/profiler"; +export type { UseLogOptions, UseLogResult } from "../devtools"; export type { Buffer2D, ColorValue, OutlineOptions } from "../renderer/types"; export type { InputParser } from "../terminal/parser/types"; export type { KeyEvent, KeyHandler as TerminalKeyHandler } from "../terminal/types/key-event"; +export type { ConsoleLine } from "../terminal/capture"; export type { BaseView, ViewProps } from "../view/base"; export type { FocusContext, FocusHandler, FocusTarget } from "../view/types/focus"; diff --git a/tests/units/cli/args.test.ts b/tests/units/cli/args.test.ts new file mode 100644 index 0000000..3730a6e --- /dev/null +++ b/tests/units/cli/args.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "bun:test"; +import { parseBtuinCliArgs } from "@/cli"; + +describe("btuin cli args", () => { + it("should default to help", () => { + expect(parseBtuinCliArgs([])).toEqual({ kind: "help" }); + }); + + it("should parse dev entry and passthrough args", () => { + const parsed = parseBtuinCliArgs(["dev", "examples/devtools.ts", "--", "--foo", "bar"]); + expect(parsed.kind).toBe("dev"); + if (parsed.kind !== "dev") return; + expect(parsed.entry).toBe("examples/devtools.ts"); + expect(parsed.childArgs).toEqual(["--foo", "bar"]); + expect(parsed.tcp.enabled).toBe(true); + expect(parsed.preserveState).toBe(true); + }); + + it("should parse watch and tcp options", () => { + const parsed = parseBtuinCliArgs([ + "dev", + "src/main.ts", + "--watch", + "src", + "--watch", + "examples", + "--debounce", + "123", + "--tcp-host", + "0.0.0.0", + "--tcp-port", + "9229", + ]); + expect(parsed.kind).toBe("dev"); + if (parsed.kind !== "dev") return; + expect(parsed.watch).toEqual(["src", "examples"]); + expect(parsed.debounceMs).toBe(123); + expect(parsed.tcp).toEqual({ enabled: true, host: "0.0.0.0", port: 9229 }); + }); + + it("should disable tcp", () => { + const parsed = parseBtuinCliArgs(["dev", "src/main.ts", "--no-tcp"]); + expect(parsed.kind).toBe("dev"); + if (parsed.kind !== "dev") return; + expect(parsed.tcp).toEqual({ enabled: false }); + }); + + it("should disable preserve state", () => { + const parsed = parseBtuinCliArgs(["dev", "src/main.ts", "--no-preserve-state"]); + expect(parsed.kind).toBe("dev"); + if (parsed.kind !== "dev") return; + expect(parsed.preserveState).toBe(false); + }); +}); diff --git a/tests/units/dev/hot-reload-state.test.ts b/tests/units/dev/hot-reload-state.test.ts new file mode 100644 index 0000000..e881555 --- /dev/null +++ b/tests/units/dev/hot-reload-state.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test"; +import { enableHotReloadState } from "@/dev"; + +describe("hot reload state", () => { + it("should apply env snapshot and respond to IPC snapshot request", () => { + const snapshot = { count: 123 }; + process.env.BTUIN_HOT_RELOAD_SNAPSHOT = Buffer.from(JSON.stringify(snapshot), "utf8").toString( + "base64", + ); + + const sent: any[] = []; + (process as any).send = (msg: any) => { + sent.push(msg); + }; + + let applied: unknown = null; + enableHotReloadState({ + getSnapshot: () => ({ ok: true }), + applySnapshot: (s) => { + applied = s; + }, + }); + + expect(applied).toEqual(snapshot); + + process.emit("message", { type: "btuin:hot-reload:request-snapshot" }); + expect(sent).toContainEqual({ type: "btuin:hot-reload:snapshot", snapshot: { ok: true } }); + }); +}); diff --git a/tests/units/dev/hot-reload.test.ts b/tests/units/dev/hot-reload.test.ts new file mode 100644 index 0000000..37add9c --- /dev/null +++ b/tests/units/dev/hot-reload.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "bun:test"; +import net from "node:net"; +import { createTcpReloadServer } from "@/dev"; + +describe("hot reload", () => { + it("should trigger reload via TCP", async () => { + let onListen: { host: string; port: number } | null = null; + let reloadCount = 0; + + const server = createTcpReloadServer( + { + host: "127.0.0.1", + port: 0, + onListen: (info) => { + onListen = info; + }, + }, + () => { + reloadCount++; + }, + ); + + try { + for (let i = 0; i < 50 && !onListen; i++) { + await new Promise((r) => setTimeout(r, 10)); + } + + // Some sandboxed environments prohibit binding to TCP ports (EPERM). + // In that case, treat as a no-op and skip assertions. + if (!onListen) return; + + await new Promise((resolve, reject) => { + const socket = net.connect({ host: onListen!.host, port: onListen!.port }, () => { + socket.write("reload\n"); + }); + + const timer = setTimeout(() => { + try { + socket.destroy(); + } catch {} + reject(new Error("timeout waiting for reload")); + }, 500); + + socket.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + + const check = () => { + if (reloadCount > 0) { + clearTimeout(timer); + try { + socket.destroy(); + } catch {} + resolve(); + } else { + setTimeout(check, 5); + } + }; + check(); + }); + + expect(reloadCount).toBe(1); + } finally { + await server.close(); + } + }); +}); diff --git a/tests/units/runtime/app.test.ts b/tests/units/runtime/app.test.ts index eed115a..a77aa01 100644 --- a/tests/units/runtime/app.test.ts +++ b/tests/units/runtime/app.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, afterEach, beforeAll } from "bun:test"; import { ref } from "@/reactivity"; import { Block, Text } from "@/view/primitives"; import type { AppType as App, KeyEvent, TerminalAdapter } from "@/types"; +import { disposeSingletonCapture } from "@/terminal/capture"; const keyHandlers: any[] = []; @@ -26,7 +27,7 @@ describe("createApp", () => { }; }, getTerminalSize: () => ({ rows: 24, cols: 80 }), - disposeSingletonCapture: () => {}, + disposeSingletonCapture, write: (_output: string) => {}, }; const platform = { @@ -123,6 +124,31 @@ describe("createApp", () => { expect(keyValue).toBe("a"); }); + it("should not intercept F12 even when devtools is enabled", async () => { + let keyValue = ""; + appInstance = app({ + terminal, + platform, + devtools: { enabled: true }, + init({ onKey }) { + onKey((k: KeyEvent) => { + keyValue = k.name; + }); + return {}; + }, + render: () => Block(Text("test")), + }); + + await appInstance.mount(); + + const onKeyCallback = (global as any).__btuin_onKeyCallback; + onKeyCallback?.({ name: "f12", sequence: "\x1b[24~", ctrl: false, meta: false, shift: false }); + expect(keyValue).toBe("f12"); + + onKeyCallback?.({ name: "a", sequence: "a", ctrl: false, meta: false, shift: false }); + expect(keyValue).toBe("a"); + }); + it("should mount in inline mode without clearing the screen", async () => { const calls: { clearScreen: number; writes: string[] } = { clearScreen: 0, writes: [] }; diff --git a/tests/units/runtime/devtools-server.test.ts b/tests/units/runtime/devtools-server.test.ts new file mode 100644 index 0000000..d3fa261 --- /dev/null +++ b/tests/units/runtime/devtools-server.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { createConsoleCapture } from "@/terminal/capture"; +import { setupDevtoolsServer } from "@/devtools/server"; +import { Block, Text } from "@/view/primitives"; +import type { ComputedLayout } from "@/layout-engine/types"; + +describe("DevTools browser server", () => { + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + + beforeEach(() => { + process.stdout.write = (() => true) as any; + process.stderr.write = (() => true) as any; + }); + + afterEach(() => { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + }); + + it("should serve HTML and stream logs over WebSocket", async () => { + const capture = createConsoleCapture({ maxLines: 50 }); + + const server = setupDevtoolsServer( + { + enabled: true, + server: { + host: "127.0.0.1", + port: 0, + }, + }, + () => capture, + ); + + try { + // Some sandboxed environments prohibit binding to ports (EPERM). + const listenInfo = server?.getInfo(); + if (!listenInfo) return; + + const res = await fetch(listenInfo.url); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("btuin DevTools"); + + const wsUrl = listenInfo.url.replace(/^http/, "ws") + "/ws"; + + const received = await new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + const timer = setTimeout(() => reject(new Error("timeout")), 1000); + + ws.addEventListener("open", () => { + process.stdout.write("hello\n"); + }); + ws.addEventListener("message", (ev) => { + try { + const msg = JSON.parse(String(ev.data)); + if (msg?.type === "log" && msg?.line?.text === "hello") { + clearTimeout(timer); + try { + ws.close(); + } catch {} + resolve(msg); + } + } catch { + // ignore + } + }); + ws.addEventListener("error", (e) => { + clearTimeout(timer); + reject(e); + }); + }); + + expect(received.type).toBe("log"); + expect(received.line.text).toBe("hello"); + expect(received.line.type).toBe("stdout"); + + const root = Block(Text("X").setKey("child")).setKey("root"); + const layoutMap: ComputedLayout = { + root: { x: 0, y: 0, width: 10, height: 3 }, + child: { x: 1, y: 1, width: 1, height: 1 }, + }; + server?.setSnapshot({ size: { rows: 10, cols: 40 }, rootElement: root, layoutMap }); + } finally { + try { + server?.dispose(); + } catch {} + capture.dispose(); + } + }); +}); diff --git a/tests/units/runtime/devtools-stream.test.ts b/tests/units/runtime/devtools-stream.test.ts new file mode 100644 index 0000000..88c9a1c --- /dev/null +++ b/tests/units/runtime/devtools-stream.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { app } from "@/runtime/app"; +import { Block, Text } from "@/view/primitives"; +import { disposeSingletonCapture, patchConsole, stopCapture } from "@/terminal/capture"; +import type { TerminalAdapter } from "@/types"; +import net from "node:net"; + +describe("devtools stream", () => { + const filePath = join(tmpdir(), `btuin-devtools-${Date.now()}-${Math.random()}.log`); + + const keyHandlers: any[] = []; + const terminal: TerminalAdapter = { + setBracketedPaste: () => {}, + setupRawMode: () => {}, + clearScreen: () => {}, + moveCursor: () => {}, + cleanupWithoutClear: () => {}, + patchConsole: () => () => {}, + startCapture: () => {}, + stopCapture: () => {}, + onKey: (callback: any) => { + keyHandlers.push(callback); + }, + getTerminalSize: () => ({ rows: 10, cols: 40 }), + disposeSingletonCapture, + write: () => {}, + }; + const platform = { + onStdoutResize: () => () => {}, + onExit: () => {}, + onSigint: () => {}, + onSigterm: () => {}, + exit: () => {}, + }; + + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + + beforeEach(() => { + keyHandlers.length = 0; + process.stdout.write = (() => true) as any; + process.stderr.write = (() => true) as any; + try { + rmSync(filePath, { force: true }); + } catch {} + }); + + afterEach(() => { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + try { + disposeSingletonCapture(); + } catch {} + try { + stopCapture(); + } catch {} + try { + rmSync(filePath, { force: true }); + } catch {} + }); + + it("should write captured console logs as JSONL to file", async () => { + const unpatch = patchConsole(); + const inst = app({ + terminal, + platform, + devtools: { enabled: true, stream: { file: filePath } }, + init: () => ({}), + render: () => Block(Text("X")), + }); + + await inst.mount({ rows: 10, cols: 40 }); + console.log("hello"); + inst.unmount(); + unpatch(); + + const content = readFileSync(filePath, "utf8"); + const first = content.split("\n").find(Boolean); + expect(first).toBeTruthy(); + const parsed = JSON.parse(first!); + expect(parsed.text).toBe("hello"); + expect(parsed.type).toBe("stdout"); + expect(typeof parsed.timestamp).toBe("number"); + }); + + it("should stream captured console logs as JSONL over TCP", async () => { + const unpatch = patchConsole(); + + let listenInfo: { host: string; port: number } | null = null; + const inst = app({ + terminal, + platform, + devtools: { + enabled: true, + stream: { + tcp: { + host: "127.0.0.1", + port: 0, + onListen: (info) => { + listenInfo = info; + }, + }, + }, + }, + init: () => ({}), + render: () => Block(Text("X")), + }); + + try { + await inst.mount({ rows: 10, cols: 40 }); + + for (let i = 0; i < 50 && !listenInfo; i++) { + await new Promise((r) => setTimeout(r, 10)); + } + + // Some sandboxed environments prohibit binding to TCP ports (EPERM). + // In that case, treat as a no-op and skip assertions. + if (!listenInfo) return; + + const received = await new Promise((resolve, reject) => { + const socket = net.connect({ host: listenInfo!.host, port: listenInfo!.port }, () => { + socket.setEncoding("utf8"); + console.log("hello"); + }); + + let buf = ""; + const timer = setTimeout(() => { + try { + socket.destroy(); + } catch {} + reject(new Error("timeout waiting for TCP log line")); + }, 500); + + socket.on("data", (chunk) => { + buf += chunk; + const line = buf.split("\n").find(Boolean); + if (line) { + clearTimeout(timer); + try { + socket.destroy(); + } catch {} + resolve(line); + } + }); + socket.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + }); + + const parsed = JSON.parse(received); + expect(parsed.text).toBe("hello"); + expect(parsed.type).toBe("stdout"); + expect(typeof parsed.timestamp).toBe("number"); + } finally { + inst.unmount(); + unpatch(); + } + }); +}); diff --git a/tests/units/runtime/use-log.test.ts b/tests/units/runtime/use-log.test.ts new file mode 100644 index 0000000..72d2174 --- /dev/null +++ b/tests/units/runtime/use-log.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { createComponent, mountComponent, unmountComponent } from "@/components"; +import { Block } from "@/view/primitives"; +import { + patchConsole, + startCapture, + stopCapture, + disposeSingletonCapture, +} from "@/terminal/capture"; +import { useLog, type UseLogResult } from "@/devtools"; + +describe("useLog", () => { + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + + beforeEach(() => { + process.stdout.write = (() => true) as any; + process.stderr.write = (() => true) as any; + }); + + afterEach(() => { + try { + disposeSingletonCapture(); + } catch {} + try { + stopCapture(); + } catch {} + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + }); + + it("should update lines when console output is captured", () => { + const unpatch = patchConsole(); + startCapture(); + + let log: UseLogResult | null = null; + const Comp = createComponent({ + init: () => { + log = useLog({ maxLines: 50 }); + }, + render: () => Block(), + }); + + const mounted = mountComponent(Comp); + + console.log("hello"); + + expect(log).not.toBeNull(); + expect(log!.lines.value.at(-1)?.text).toBe("hello"); + expect(log!.lines.value.at(-1)?.type).toBe("stdout"); + + unmountComponent(mounted); + unpatch(); + }); +}); diff --git a/tests/units/terminal/capture.test.ts b/tests/units/terminal/capture.test.ts index 7136efa..e7d6cb6 100644 --- a/tests/units/terminal/capture.test.ts +++ b/tests/units/terminal/capture.test.ts @@ -146,6 +146,19 @@ describe("Output Capture", () => { capture.dispose(); }); + + it("should notify subscribers for new lines", (done) => { + const capture = createConsoleCapture({ maxLines: 10 }); + const dispose = capture.subscribe((line) => { + expect(line.text).toBe("hello"); + expect(line.type).toBe("stdout"); + dispose(); + capture.dispose(); + done(); + }); + + process.stdout.write("hello\n"); + }); }); describe("getConsoleCaptureInstance", () => { From 684e47fa8c9192a3a4120d8fb0514d00cb551ada Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:33:41 +0900 Subject: [PATCH 02/16] Update AGENTS.md with test and Bun notes --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index b9dc9b9..01df0e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,9 @@ This repo uses `mise` to pin toolchains (see `mise.toml`). - TypeScript (ESM) with strict type-checking (`tsconfig.json`). - Prefer small, composable modules and re-export via `src/**/index.ts`. - Use lowercase filenames; use `kebab-case` for multi-word files (e.g., `render-loop.ts`). +- Run `mise run test && mise run precommit` after any edit. - Run `mise run format` before opening a PR; let `oxfmt` decide whitespace. +- This project targets Bun-native development; optimizing to remove Bun dependencies from the core package is not a goal. ## Testing Guidelines From 9d52addead7cb7c7e9ff11f5615a0f4aa1c0313b Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Wed, 24 Dec 2025 18:12:01 +0900 Subject: [PATCH 03/16] Move useLog to hooks and lazily load DevTools Move useLog and its types from src/devtools to a new src/hooks and re-export hooks from the root index. Make the DevTools controller optional and lazily imported by the runtime (LoopManager) with a prepare hook. Add CLI flags to disable DevTools or auto-open the browser and add env var support. Update docs, examples, and tests to match the new structure and lazy initialization --- docs/devtools.ja.md | 158 +------------------- docs/devtools.md | 158 +------------------- examples/counter.ts | 7 +- examples/devtools.ts | 39 +---- examples/hot-reload.ts | 15 -- package.json | 1 + scripts/profiler-limit.spec.ts | 2 +- src/cli/args.ts | 16 ++ src/cli/main.ts | 49 ++++++ src/dev/hot-reload.ts | 62 +++++++- src/dev/index.ts | 1 - src/devtools/controller.ts | 4 +- src/devtools/index.ts | 9 -- src/devtools/log-stream.ts | 10 +- src/hooks/index.ts | 1 + src/hooks/types.ts | 1 + src/{devtools => hooks}/use-log.ts | 22 ++- src/index.ts | 2 +- src/runtime/app.ts | 44 ++++++ src/runtime/loop.ts | 55 ++++++- src/runtime/types.ts | 19 ++- src/types/index.ts | 10 +- tests/units/dev/hot-reload.test.ts | 4 +- tests/units/runtime/devtools-stream.test.ts | 16 +- tests/units/runtime/use-log.test.ts | 8 +- tests/units/terminal/capture.test.ts | 6 +- 26 files changed, 319 insertions(+), 400 deletions(-) delete mode 100644 examples/hot-reload.ts create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/types.ts rename src/{devtools => hooks}/use-log.ts (82%) diff --git a/docs/devtools.ja.md b/docs/devtools.ja.md index fd79365..d63b261 100644 --- a/docs/devtools.ja.md +++ b/docs/devtools.ja.md @@ -1,129 +1,20 @@ # DevTools -btuin には、TUI 開発時の観測性(ログ確認など)に特化した DevTools があります。 - -- ブラウザ DevTools(ローカルサーバ + WebSocket) -- 外部へログをストリーミング(file / TCP)して `tail -f` や `nc` で別ターミナルから閲覧 - -## 有効化 - -`createApp({ devtools: ... })` で有効化します: - -```ts -import { createApp, ui } from "btuin"; - -const app = createApp({ - devtools: { enabled: true }, - init: () => ({}), - render: () => ui.Text("Hello"), -}); -``` +btuin には、TUI 開発時にアプリを観測するための軽量なブラウザ UI があります。 ## ブラウザ DevTools(おすすめ) -ローカルの DevTools サーバを起動します: - -```ts -import { createApp, ui } from "btuin"; - -const app = createApp({ - devtools: { - enabled: true, - server: { - host: "127.0.0.1", - port: 0, - onListen: ({ url }) => console.log(`[devtools] open ${url}`), - }, - }, - init: () => ({}), - render: () => ui.Text("Hello"), -}); -``` +開発用ランナー(`btuin dev ...`)を使う場合、ブラウザ DevTools は自動で有効化されます(無効化は `--no-devtools`)。 +また、DevTools の URL をブラウザで自動で開きます(無効化は `--no-open-browser`)。 表示された URL をブラウザで開くと、ログとスナップショットが確認できます。 スナップショットは **Preview**(レイアウトのボックス + テキスト)と **JSON**(生の payload)の両方で確認できます。 -## `useLog()` フック - -`useLog()` は capture した console 出力をリアクティブに参照するためのフックです(ログUIを自作したい場合に使えます)。 - -オプション: - -- `devtools.maxLogLines`(デフォルト: `1000`) - -```ts -import { defineComponent, useLog, ui } from "btuin"; - -export const LogView = defineComponent({ - setup() { - const log = useLog(); - return () => ui.Text(`lines: ${log.lines.value.length}`); - }, -}); -``` - -注意: - -- 基本はコンポーネント `init()` / `setup()` 内で呼ぶ想定(unmount で自動 cleanup)。 -- それ以外の場所で呼ぶ場合は `dispose()` を手動で呼んでください。 - -## file へストリーミング(JSONL) - -1行1イベントの JSONL 形式で追記します: - -```ts -devtools: { - enabled: true, - stream: { file: "/tmp/btuin-devtools.log" }, -} -``` - -例: - -```bash -tail -f /tmp/btuin-devtools.log | jq -r '.type + " " + .text' -``` - -フォーマット(1行=1イベント): - -```json -{ "text": "hello", "type": "stdout", "timestamp": 1730000000000 } -``` - -## TCP でストリーミング(JSONL) - -ローカルで TCP サーバを起動し、接続クライアントへ JSONL を流します: - -```ts -devtools: { - enabled: true, - stream: { - tcp: { - host: "127.0.0.1", - port: 9229, - backlog: 200, - onListen: ({ host, port }) => console.log(`DevTools TCP: ${host}:${port}`), - }, - }, -} -``` - -別ターミナルから接続: - -```bash -nc 127.0.0.1 9229 | jq -r '.type + " " + .text' -``` - -Backlog: +コードを変更したくない場合は、環境変数でも有効化できます: -- `backlog` は直近のログをメモリに保持し、新規接続時に先頭へフラッシュするための行数です。 -- 接続前後のタイミングでログを取りこぼしにくくします。 - -セキュリティ注意: - -- 特別な理由がなければ `127.0.0.1` にバインドしてください。 -- stdout/stderr が流れるので、公開ポートにする場合は漏洩リスクを理解した上で運用してください。 +- `BTUIN_DEVTOOLS=1`(有効化) +- `BTUIN_DEVTOOLS_HOST` / `BTUIN_DEVTOOLS_PORT`(任意) # ホットリロード(開発用ランナー) @@ -165,44 +56,11 @@ btuin dev src/main.ts -- --foo bar btuin dev examples/devtools.ts --no-preserve-state ``` -または、ランナー用のスクリプトを作ります: - -```ts -import { runHotReloadProcess } from "btuin"; - -runHotReloadProcess({ - command: "bun", - args: ["examples/devtools.ts"], - watch: { paths: ["src", "examples"] }, -}); -``` - -実行: - -```bash -bun run examples/hot-reload.ts -``` +注意: ホットリロードは `btuin dev`(dev runner)が適用します。 ## TCPトリガ(任意) -`btuin dev` はデフォルトで TCP を有効化(ポートは自動選択)します。コード側で明示設定することもできます: - -```ts -import { runHotReloadProcess } from "btuin"; - -runHotReloadProcess({ - command: "bun", - args: ["examples/devtools.ts"], - watch: { paths: ["src", "examples"] }, - tcp: { - host: "127.0.0.1", - port: 0, - onListen: ({ host, port }) => { - process.stderr.write(`[btuin] hot-reload tcp: ${host}:${port}\n`); - }, - }, -}); -``` +`btuin dev` はデフォルトで TCP を有効化(ポートは自動選択)します。 別ターミナルからトリガ: diff --git a/docs/devtools.md b/docs/devtools.md index b9461c5..07c733e 100644 --- a/docs/devtools.md +++ b/docs/devtools.md @@ -1,129 +1,20 @@ # DevTools -btuin includes a lightweight DevTools layer focused on observability during TUI development. - -- Browser DevTools (local server + WebSocket) -- Stream logs externally (file / TCP) so you can `tail -f` or `nc` from another terminal - -## Enable - -Enable DevTools via `createApp({ devtools: ... })`: - -```ts -import { createApp, ui } from "btuin"; - -const app = createApp({ - devtools: { enabled: true }, - init: () => ({}), - render: () => ui.Text("Hello"), -}); -``` +btuin includes a lightweight browser UI for observing your app during TUI development. ## Browser DevTools (recommended) -Start the local DevTools server: - -```ts -import { createApp, ui } from "btuin"; - -const app = createApp({ - devtools: { - enabled: true, - server: { - host: "127.0.0.1", - port: 0, - onListen: ({ url }) => console.log(`[devtools] open ${url}`), - }, - }, - init: () => ({}), - render: () => ui.Text("Hello"), -}); -``` +If you use the dev runner (`btuin dev ...`), browser DevTools is auto-enabled (disable with `--no-devtools`). +The runner also auto-opens the DevTools URL in your browser (disable with `--no-open-browser`). Open the printed URL in your browser. It shows logs and a snapshot stream. The Snapshot view includes a simple **Preview** (layout boxes + text) and a **JSON** view (raw snapshot payload). -## `useLog()` hook - -`useLog()` exposes captured console output as reactive state (useful for building your own log UI). - -Options: - -- `devtools.maxLogLines` (default: `1000`) - -```ts -import { defineComponent, useLog, ui } from "btuin"; - -export const LogView = defineComponent({ - setup() { - const log = useLog(); - return () => ui.Text(`lines: ${log.lines.value.length}`); - }, -}); -``` - -Notes: - -- Intended to be called inside component `init()`/`setup()` (auto-disposed on unmount). -- If you call it outside component initialization, call `dispose()` yourself. - -## Stream logs to a file (JSONL) - -Append each captured line as JSONL: - -```ts -devtools: { - enabled: true, - stream: { file: "/tmp/btuin-devtools.log" }, -} -``` - -Example: - -```bash -tail -f /tmp/btuin-devtools.log | jq -r '.type + " " + .text' -``` - -Format (one line per event): - -```json -{ "text": "hello", "type": "stdout", "timestamp": 1730000000000 } -``` - -## Stream logs over TCP (JSONL) - -Start a local TCP server and stream JSONL to connected clients: - -```ts -devtools: { - enabled: true, - stream: { - tcp: { - host: "127.0.0.1", - port: 9229, - backlog: 200, - onListen: ({ host, port }) => console.log(`DevTools TCP: ${host}:${port}`), - }, - }, -} -``` - -Connect from another terminal: - -```bash -nc 127.0.0.1 9229 | jq -r '.type + " " + .text' -``` - -Backlog: +You can also enable it without code by setting env vars: -- `backlog` is the number of most recent log lines kept in memory and flushed to new clients. -- This helps avoid missing logs around connect timing. - -Security notes: - -- Bind to `127.0.0.1` unless you explicitly want remote access. -- Do not expose the port publicly unless you accept leaking stdout/stderr content. +- `BTUIN_DEVTOOLS=1` (enable) +- `BTUIN_DEVTOOLS_HOST` / `BTUIN_DEVTOOLS_PORT` (optional) # Hot Reload (Dev Runner) @@ -165,44 +56,11 @@ Disable state preservation: btuin dev examples/devtools.ts --no-preserve-state ``` -Or create a small runner script: - -```ts -import { runHotReloadProcess } from "btuin"; - -runHotReloadProcess({ - command: "bun", - args: ["examples/devtools.ts"], - watch: { paths: ["src", "examples"] }, -}); -``` - -Run it: - -```bash -bun run examples/hot-reload.ts -``` +Note: hot reload is applied by `btuin dev` (dev runner). ## TCP Trigger (Optional) -`btuin dev` enables TCP by default (ephemeral port). You can also configure it in code: - -```ts -import { runHotReloadProcess } from "btuin"; - -runHotReloadProcess({ - command: "bun", - args: ["examples/devtools.ts"], - watch: { paths: ["src", "examples"] }, - tcp: { - host: "127.0.0.1", - port: 0, - onListen: ({ host, port }) => { - process.stderr.write(`[btuin] hot-reload tcp: ${host}:${port}\n`); - }, - }, -}); -``` +`btuin dev` enables TCP by default (ephemeral port). Trigger reload from another terminal: diff --git a/examples/counter.ts b/examples/counter.ts index e108fdd..b6a683c 100644 --- a/examples/counter.ts +++ b/examples/counter.ts @@ -1,19 +1,16 @@ -import { createApp, ref, watchEffect } from "@/index"; +import { createApp, ref } from "@/index"; import { Text, VStack } from "@/view"; const app = createApp({ init({ onKey, setExitOutput, runtime }) { const count = ref(0); onKey((k) => { + setExitOutput(count.value.toString()); if (k.name === "up") count.value++; if (k.name === "down") count.value--; if (k.name === "q") runtime.exit(0); }); - watchEffect(() => { - setExitOutput(count.value.toString()); - }); - return { count }; }, render({ count }) { diff --git a/examples/devtools.ts b/examples/devtools.ts index eed9f44..7b879ee 100644 --- a/examples/devtools.ts +++ b/examples/devtools.ts @@ -1,31 +1,9 @@ -import { createApp, enableHotReloadState, ref, useLog } from "@/index"; +import { createApp, enableHotReloadState, ref } from "@/index"; import { Text, VStack } from "@/view"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; - -const logFile = join(tmpdir(), "btuin-devtools.log"); const app = createApp({ - devtools: { - enabled: true, - maxLogLines: 1000, - server: { - host: "127.0.0.1", - port: 0, - onListen: ({ url }) => console.log(`[devtools] open ${url}`), - }, - stream: { - file: logFile, - tcp: { - host: "127.0.0.1", - port: 9229, - backlog: 200, - }, - }, - }, init({ onKey, onTick, runtime }) { const count = ref(0); - const log = useLog({ maxLines: 200 }); enableHotReloadState({ getSnapshot: () => ({ count: count.value }), @@ -49,21 +27,16 @@ const app = createApp({ if (count.value % 10 === 0) console.log(`[tick] count=${count.value}`); }, 1000); - return { count, log }; + return { count }; }, - render({ count, log }) { - const tail = log.lines.value.slice(-8); + render({ count }) { return VStack([ Text("DevTools example").foreground("cyan"), - Text("DevTools: browser UI + log streaming"), + Text("DevTools: browser UI (no app code changes)"), + Text("Run: btuin dev examples/devtools.ts (auto enables + opens DevTools)"), + Text("Or: BTUIN_DEVTOOLS=1 bun examples/devtools.ts"), Text("Keys: Up/Down=counter l=console.log e=console.error q=quit"), - Text(`File stream: ${logFile}`), - Text("TCP stream: nc 127.0.0.1 9229 | jq -r '.type + \" \" + .text'"), Text(`count: ${count.value}`).foreground("yellow"), - Text("LogTail (useLog):").foreground("cyan"), - ...tail.map((line, i) => - Text(`${line.type === "stderr" ? "ERR" : "LOG"} ${line.text}`).setKey(`log-tail-${i}`), - ), ]) .width("100%") .height("100%"); diff --git a/examples/hot-reload.ts b/examples/hot-reload.ts deleted file mode 100644 index 596aff4..0000000 --- a/examples/hot-reload.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { runHotReloadProcess } from "@/dev"; - -runHotReloadProcess({ - command: "bun", - args: ["examples/devtools.ts"], - watch: { paths: ["src", "examples"] }, - tcp: { - host: "127.0.0.1", - port: 0, - onListen: ({ host, port }) => { - process.stderr.write(`[btuin] hot-reload tcp: ${host}:${port}\n`); - process.stderr.write(`[btuin] trigger: printf 'reload\\n' | nc ${host} ${port}\n`); - }, - }, -}); diff --git a/package.json b/package.json index f03fb0b..1186026 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ ".": "./src/index.ts", "./components": "./src/components/index.ts", "./dev": "./src/dev/index.ts", + "./hooks": "./src/hooks/index.ts", "./layout": "./src/layout/index.ts", "./layout-engine": "./src/layout-engine/index.ts", "./reactivity": "./src/reactivity/index.ts", diff --git a/scripts/profiler-limit.spec.ts b/scripts/profiler-limit.spec.ts index bbf10d0..b94f4ba 100644 --- a/scripts/profiler-limit.spec.ts +++ b/scripts/profiler-limit.spec.ts @@ -100,7 +100,7 @@ describe("Scalability Limit Test (Statistical)", async () => { thresholds.forEach((t) => (results[t.fps] = [])); for (let i = 0; i < ITERATIONS; i++) { - Bun.stdout.write(` Run ${i + 1}/${ITERATIONS}... `); + process.stdout.write(` Run ${i + 1}/${ITERATIONS}... `); Bun.gc(true); diff --git a/src/cli/args.ts b/src/cli/args.ts index 70ecfad..fa0a364 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -9,6 +9,8 @@ export type BtuinCliParsed = watch: string[]; debounceMs?: number; preserveState: boolean; + devtools: { enabled: boolean }; + openBrowser: boolean; tcp: { enabled: false } | { enabled: true; host?: string; port?: number }; }; @@ -39,6 +41,8 @@ export function parseBtuinCliArgs(argv: string[]): BtuinCliParsed { let tcpHost: string | undefined; let tcpPort: number | undefined; let preserveState = true; + let devtoolsEnabled = true; + let openBrowser = true; let passthrough = false; for (let i = 0; i < rest.length; i++) { @@ -81,6 +85,16 @@ export function parseBtuinCliArgs(argv: string[]): BtuinCliParsed { continue; } + if (a === "--no-devtools") { + devtoolsEnabled = false; + continue; + } + + if (a === "--no-open-browser") { + openBrowser = false; + continue; + } + if (a === "--no-preserve-state") { preserveState = false; continue; @@ -126,6 +140,8 @@ export function parseBtuinCliArgs(argv: string[]): BtuinCliParsed { watch, debounceMs, preserveState, + devtools: { enabled: devtoolsEnabled }, + openBrowser, tcp: tcpEnabled ? { enabled: true, host: tcpHost, port: tcpPort } : { enabled: false }, }; } diff --git a/src/cli/main.ts b/src/cli/main.ts index ee9b610..b2f49bd 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -22,6 +22,8 @@ function printHelp() { " --debounce Debounce fs events (default: 50)", " --cwd Child working directory (default: process.cwd())", " --no-preserve-state Disable state preservation (default: enabled)", + " --no-devtools Disable browser DevTools auto-enable", + " --no-open-browser Do not auto-open DevTools URL in browser", " --no-tcp Disable TCP reload trigger", " --tcp-host TCP bind host (default: 127.0.0.1)", " --tcp-port TCP bind port (default: 0)", @@ -112,12 +114,59 @@ export async function btuinCli(argv: string[]) { }; } + const devtoolsEnv = (() => { + if (!parsed.devtools.enabled) { + return { + BTUIN_DEVTOOLS: undefined, + BTUIN_DEVTOOLS_HOST: undefined, + BTUIN_DEVTOOLS_PORT: undefined, + }; + } + + const env: Record = { BTUIN_DEVTOOLS: "1" }; + + // Keep DevTools URL stable across hot-reload restarts by pinning host/port once. + const host = process.env.BTUIN_DEVTOOLS_HOST ?? "127.0.0.1"; + const portFromEnv = process.env.BTUIN_DEVTOOLS_PORT; + if (!process.env.BTUIN_DEVTOOLS_HOST) env.BTUIN_DEVTOOLS_HOST = host; + + if (!portFromEnv) { + try { + // Reserve an ephemeral port, then close immediately. Best-effort. + const listener = Bun.listen({ + hostname: host, + port: 0, + socket: { + open() {}, + data() {}, + close() {}, + error() {}, + }, + }); + const port = listener.port; + try { + listener.stop(true); + } catch { + // ignore + } + env.BTUIN_DEVTOOLS_PORT = String(port); + env.BTUIN_DEVTOOLS_HOST = host; + } catch { + // ignore; child will pick an ephemeral port + } + } + + return env; + })(); + runHotReloadProcess({ command: "bun", args: [entryAbs, ...parsed.childArgs], cwd, watch: { paths: watchPaths, debounceMs: parsed.debounceMs }, preserveState: parsed.preserveState, + env: devtoolsEnv, + openDevtoolsBrowser: parsed.openBrowser && parsed.devtools.enabled, tcp: parsed.tcp.enabled ? { host: tcp!.host, diff --git a/src/dev/hot-reload.ts b/src/dev/hot-reload.ts index 5db944d..a42bf72 100644 --- a/src/dev/hot-reload.ts +++ b/src/dev/hot-reload.ts @@ -64,6 +64,12 @@ export interface RunHotReloadProcessOptions { */ env?: Record; + /** + * Auto-open the DevTools browser URL when the child reports it over IPC. + * @default true + */ + openDevtoolsBrowser?: boolean; + /** * Preserve state across restarts (opt-in from the app via `enableHotReloadState`). * @default true @@ -101,7 +107,8 @@ export interface HotReloadProcessHandle { type TcpReloadMessage = "reload" | { type: "reload" }; type HotReloadIpcMessage = | { type: "btuin:hot-reload:request-snapshot" } - | { type: "btuin:hot-reload:snapshot"; snapshot: unknown }; + | { type: "btuin:hot-reload:snapshot"; snapshot: unknown } + | { type: "btuin:devtools:listen"; info: { host: string; port: number; url: string } }; const SNAPSHOT_ENV_KEY = "BTUIN_HOT_RELOAD_SNAPSHOT"; @@ -269,6 +276,7 @@ export function runHotReloadProcess(options: RunHotReloadProcessOptions): HotRel const restartSignal = options.restartSignal ?? "SIGTERM"; const restartTimeoutMs = options.restartTimeoutMs ?? 1500; const preserveState = options.preserveState ?? true; + const openDevtoolsBrowser = options.openDevtoolsBrowser ?? true; let closing = false; let restarting = false; @@ -277,6 +285,7 @@ export function runHotReloadProcess(options: RunHotReloadProcessOptions): HotRel let tcpServer: TcpReloadServerHandle | null = null; let lastSnapshot: unknown = null; let snapshotWaiter: ((snapshot: unknown) => void) | null = null; + let lastOpenedDevtoolsUrl: string | null = null; const close = async () => { if (closing) return; @@ -358,6 +367,36 @@ export function runHotReloadProcess(options: RunHotReloadProcessOptions): HotRel serialization: "json", ipc: (message) => { const m = message as HotReloadIpcMessage; + if ( + m && + typeof m === "object" && + "type" in m && + (m as any).type === "btuin:devtools:listen" + ) { + const info = (m as any).info as { host?: unknown; port?: unknown; url?: unknown }; + if ( + info && + typeof info === "object" && + typeof info.url === "string" && + typeof info.host === "string" && + typeof info.port === "number" + ) { + try { + Bun.stderr.write(`[btuin] devtools: ${info.url}\n`); + } catch { + // ignore + } + if (openDevtoolsBrowser && lastOpenedDevtoolsUrl !== info.url) { + lastOpenedDevtoolsUrl = info.url; + try { + openUrlInBrowser(info.url); + } catch { + // ignore + } + } + } + return; + } if ( m && typeof m === "object" && @@ -393,3 +432,24 @@ export function runHotReloadProcess(options: RunHotReloadProcessOptions): HotRel isRunning: () => child !== null, }; } + +function openUrlInBrowser(url: string) { + const platform = process.platform; + const cmd = + platform === "darwin" + ? ["open", url] + : platform === "win32" + ? ["cmd", "/c", "start", "", url] + : ["xdg-open", url]; + + try { + Bun.spawn({ + cmd, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + } catch { + // ignore + } +} diff --git a/src/dev/index.ts b/src/dev/index.ts index 6d4051d..1fff3a6 100644 --- a/src/dev/index.ts +++ b/src/dev/index.ts @@ -1,2 +1 @@ -export * from "./hot-reload"; export * from "./hot-reload-state"; diff --git a/src/devtools/controller.ts b/src/devtools/controller.ts index 8792048..f730b36 100644 --- a/src/devtools/controller.ts +++ b/src/devtools/controller.ts @@ -6,7 +6,9 @@ import { setupDevtoolsServer, type DevtoolsSnapshot } from "./server"; export interface DevtoolsController { handleKey(event: KeyEvent): boolean; - wrapView(root: import("../view/types/elements").ViewElement): import("../view/types/elements").ViewElement; + wrapView( + root: import("../view/types/elements").ViewElement, + ): import("../view/types/elements").ViewElement; onLayout?(snapshot: DevtoolsSnapshot): void; dispose(): void; } diff --git a/src/devtools/index.ts b/src/devtools/index.ts index 508929b..ad0102e 100644 --- a/src/devtools/index.ts +++ b/src/devtools/index.ts @@ -1,10 +1 @@ -export { useLog } from "./use-log"; -export type { UseLogOptions, UseLogResult } from "./use-log"; - -export { createDevtoolsController } from "./controller"; -export type { DevtoolsController } from "./controller"; - -export { createJsonlFileLogStreamer, createJsonlTcpLogStreamer } from "./stream"; -export type { LogStreamer } from "./stream"; - export type { DevtoolsOptions } from "./types"; diff --git a/src/devtools/log-stream.ts b/src/devtools/log-stream.ts index 504cd98..3132961 100644 --- a/src/devtools/log-stream.ts +++ b/src/devtools/log-stream.ts @@ -1,4 +1,4 @@ -import { getConsoleCaptureInstance, type ConsoleCaptureHandle } from "../terminal/capture"; +import { createConsoleCapture, type ConsoleCaptureHandle } from "../terminal/capture"; import { createJsonlFileLogStreamer, createJsonlTcpLogStreamer, type LogStreamer } from "./stream"; import type { DevtoolsOptions } from "./types"; @@ -16,7 +16,7 @@ export function setupDevtoolsLogStreaming( return { capture: null, dispose: () => {} }; } - const capture = getConsoleCaptureInstance({ maxLines: options?.maxLogLines ?? 1000 }); + const capture = createConsoleCapture({ maxLines: options?.maxLogLines ?? 1000 }); const streamers: LogStreamer[] = []; const filePath = options?.stream?.file; @@ -61,6 +61,12 @@ export function setupDevtoolsLogStreaming( // ignore } } + + try { + capture.dispose(); + } catch { + // ignore + } }, }; } diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..9506e09 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export { useLog } from "./use-log"; diff --git a/src/hooks/types.ts b/src/hooks/types.ts new file mode 100644 index 0000000..71acdbd --- /dev/null +++ b/src/hooks/types.ts @@ -0,0 +1 @@ +export type { UseLogOptions, UseLogResult } from "./use-log"; diff --git a/src/devtools/use-log.ts b/src/hooks/use-log.ts similarity index 82% rename from src/devtools/use-log.ts rename to src/hooks/use-log.ts index 63cb0c2..6ccc133 100644 --- a/src/devtools/use-log.ts +++ b/src/hooks/use-log.ts @@ -2,15 +2,14 @@ import { shallowRef } from "../reactivity"; import type { Ref } from "../reactivity/ref"; import { getCurrentInstance } from "../components/lifecycle"; import { - getConsoleCaptureInstance, + createConsoleCapture, type ConsoleCaptureHandle, type ConsoleLine, } from "../terminal/capture"; export interface UseLogOptions { /** - * Maximum number of lines stored in the shared console buffer. - * Note: This is applied only when the singleton capture is first created. + * Maximum number of lines stored in the console buffer. */ maxLines?: number; @@ -54,17 +53,30 @@ export function useLog(options: UseLogOptions = {}): UseLogResult { const stdout = options.stdout ?? true; const stderr = options.stderr ?? true; - const capture = getConsoleCaptureInstance({ maxLines: options.maxLines }); + const capture = createConsoleCapture({ maxLines: options.maxLines }); const lines = shallowRef(filterLines(capture.getLines(), { stdout, stderr })); const refresh = () => { lines.value = filterLines(capture.getLines(), { stdout, stderr }); }; - const dispose = capture.subscribe(() => { + const unsubscribe = capture.subscribe(() => { refresh(); }); + const dispose = () => { + try { + unsubscribe(); + } catch { + // ignore + } + try { + capture.dispose(); + } catch { + // ignore + } + }; + const instance = getCurrentInstance(); if (instance) { instance.effects.push(dispose); diff --git a/src/index.ts b/src/index.ts index 6b641dd..a122282 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,9 +4,9 @@ export { createApp, App } from "./runtime"; export * from "./dev"; -export * from "./devtools"; export { defineComponent } from "./components"; export * from "./view"; +export * from "./hooks/"; export { onBeforeUpdate, onKey, onMounted, onTick, onUnmounted, onUpdated } from "./components"; diff --git a/src/runtime/app.ts b/src/runtime/app.ts index 559b94b..15f52d5 100644 --- a/src/runtime/app.ts +++ b/src/runtime/app.ts @@ -96,6 +96,8 @@ export function App(root: Component, options: CreateAppOptions = { options.errorLog, ); + context.options.devtools = resolveDevtoolsOptions(context.options.devtools); + context.loopManager = new LoopManager(context, handleError); const rows = mountOptions.rows ?? 0; @@ -104,6 +106,10 @@ export function App(root: Component, options: CreateAppOptions = { updaters.renderMode(inline ? "inline" : "fullscreen"); updaters.inlineCleanupOnExit(mountOptions.inlineCleanupOnExit ?? false); + // Initialize devtools (e.g. start the browser server) before the TUI begins rendering. + // This matches the "hot-reload tcp" logs style: it prints once and doesn't overlay the UI. + await context.loopManager.prepare?.(); + updaters.unpatchConsole(terminal.patchConsole()); terminal.startCapture(); terminal.setupRawMode(); @@ -220,3 +226,41 @@ export function app any>( } export const createApp = app; + +function resolveDevtoolsOptions( + explicit: CreateAppOptions["devtools"], +): CreateAppOptions["devtools"] { + if (explicit) return explicit; + + const env = process.env.BTUIN_DEVTOOLS; + const enabled = + env === "1" || env === "true" || env === "yes" || env === "on" || env === "enabled"; + if (!enabled) return undefined; + + const host = process.env.BTUIN_DEVTOOLS_HOST; + const portRaw = process.env.BTUIN_DEVTOOLS_PORT; + const port = portRaw ? Number(portRaw) : undefined; + + return { + enabled: true, + server: { + host: host && host.length > 0 ? host : undefined, + port: Number.isInteger(port) && (port as number) >= 0 ? (port as number) : undefined, + onListen: (info: { host: string; port: number; url: string }) => { + try { + process.stderr.write(`[btuin] devtools: ${info.url}\n`); + } catch { + // ignore + } + try { + const send = (process as any).send as undefined | ((message: unknown) => void); + if (typeof send === "function") { + send({ type: "btuin:devtools:listen", info }); + } + } catch { + // ignore + } + }, + }, + }; +} diff --git a/src/runtime/loop.ts b/src/runtime/loop.ts index b500bfc..1011d66 100644 --- a/src/runtime/loop.ts +++ b/src/runtime/loop.ts @@ -5,24 +5,71 @@ import { createInlineDiffRenderer } from "../renderer"; import { layout } from "../layout"; import { Block } from "../view/primitives"; import type { ViewElement } from "../view/types/elements"; -import { createDevtoolsController, type DevtoolsController } from "../devtools/controller"; import { createRenderer } from "./render-loop"; import { createErrorContext, createErrorHandler } from "./error-boundary"; import type { AppContext } from "./context"; import type { ILoopManager } from "./types"; +type DevtoolsControllerLike = { + handleKey(event: KeyEvent): boolean; + wrapView(root: ViewElement): ViewElement; + onLayout?(snapshot: { + size: { rows: number; cols: number }; + rootElement: ViewElement; + layoutMap: any; + }): void; + dispose(): void; +}; + export class LoopManager implements ILoopManager { private ctx: AppContext; private handleError: ReturnType; private cleanupTerminalFn: (() => void) | null = null; private cleanupOutputListeners: (() => void)[] = []; - private devtools: DevtoolsController | null = null; + private devtools: DevtoolsControllerLike | null = null; + private devtoolsInit: Promise | null = null; + private stopped = false; constructor(context: AppContext, handleError: ReturnType) { this.ctx = context; this.handleError = handleError; } + private initDevtools() { + if (this.devtoolsInit) return; + + const options = this.ctx.options.devtools; + if (!options) return; + + this.devtoolsInit = (async () => { + try { + const mod: any = await import("../" + "devtools/controller"); + const factory: undefined | ((opts: unknown) => DevtoolsControllerLike) = + mod?.createDevtoolsController; + if (!factory) return; + const controller = factory(options); + if (this.stopped) { + try { + controller.dispose(); + } catch { + // ignore + } + return; + } + + this.devtools = controller; + this.cleanupOutputListeners.push(() => controller.dispose()); + } catch { + // devtools is optional + } + })(); + } + + prepare(): Promise { + this.initDevtools(); + return this.devtoolsInit ?? Promise.resolve(); + } + start(rows: number, cols: number) { const { state, updaters, terminal, platform, profiler, app } = this.ctx; @@ -36,8 +83,7 @@ export class LoopManager implements ILoopManager { const pendingKeyEvents: KeyEvent[] = []; - this.devtools = createDevtoolsController(this.ctx.options.devtools); - this.cleanupOutputListeners.push(() => this.devtools?.dispose()); + this.initDevtools(); terminal.onKey((event: KeyEvent) => { if (!state.mounted) { @@ -177,6 +223,7 @@ export class LoopManager implements ILoopManager { stop() { const { state, updaters } = this.ctx; + this.stopped = true; if (state.renderEffect) { stop(state.renderEffect); updaters.renderEffect(null); diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 3bf4f66..a8f4d58 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -6,12 +6,15 @@ import type { TerminalAdapter } from "./terminal-adapter"; import type { PlatformAdapter } from "./platform-adapter"; import type { ProfileOptions } from "./profiler"; -import type { DevtoolsOptions } from "../devtools/types"; - export interface ILoopManager { start(rows: number, cols: number): void; stop(): void; cleanupTerminal?(): void; + /** + * Optional async preparation step (used by dev tooling to initialize + * sidecar servers before the TUI begins rendering). + */ + prepare?(): Promise; } export type RenderMode = "fullscreen" | "inline"; @@ -24,7 +27,11 @@ export type AppConfig = { onExit?: () => void; profile?: ProfileOptions; inputParser?: InputParser; - devtools?: DevtoolsOptions; + /** + * Internal/optional: dev runners may set this (e.g. via env) to enable extra tooling. + * The core runtime treats it as opaque. + */ + devtools?: unknown; init: (ctx: ComponentInitContext) => State; render: (state: State) => ViewElement; }; @@ -56,5 +63,9 @@ export type CreateAppOptions = { platform?: PlatformAdapter; profile?: ProfileOptions; inputParser?: InputParser; - devtools?: DevtoolsOptions; + /** + * Internal/optional: dev runners may set this (e.g. via env) to enable extra tooling. + * The core runtime treats it as opaque. + */ + devtools?: unknown; }; diff --git a/src/types/index.ts b/src/types/index.ts index 98f9b4a..f6b7a12 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -25,19 +25,11 @@ export type { MountOptions, RenderMode, } from "../runtime/types"; -export type { DevtoolsOptions } from "../devtools/types"; -export type { - HotReloadProcessHandle, - HotReloadTcpOptions, - HotReloadWatchOptions, - RunHotReloadProcessOptions, - TcpReloadServerHandle, -} from "../dev/hot-reload"; export type { EnableHotReloadStateOptions } from "../dev/hot-reload-state"; export type { PlatformAdapter } from "../runtime/platform-adapter"; export type { TerminalAdapter } from "../runtime/terminal-adapter"; export type { FrameMetrics, ProfileOptions, ProfileOutput } from "../runtime/profiler"; -export type { UseLogOptions, UseLogResult } from "../devtools"; +export type * from "../hooks/types"; export type { Buffer2D, ColorValue, OutlineOptions } from "../renderer/types"; diff --git a/tests/units/dev/hot-reload.test.ts b/tests/units/dev/hot-reload.test.ts index 37add9c..13fe09e 100644 --- a/tests/units/dev/hot-reload.test.ts +++ b/tests/units/dev/hot-reload.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import net from "node:net"; -import { createTcpReloadServer } from "@/dev"; +import { createTcpReloadServer } from "@/dev/hot-reload"; describe("hot reload", () => { it("should trigger reload via TCP", async () => { @@ -11,7 +11,7 @@ describe("hot reload", () => { { host: "127.0.0.1", port: 0, - onListen: (info) => { + onListen: (info: { host: string; port: number }) => { onListen = info; }, }, diff --git a/tests/units/runtime/devtools-stream.test.ts b/tests/units/runtime/devtools-stream.test.ts index 88c9a1c..e963545 100644 --- a/tests/units/runtime/devtools-stream.test.ts +++ b/tests/units/runtime/devtools-stream.test.ts @@ -73,11 +73,23 @@ describe("devtools stream", () => { }); await inst.mount({ rows: 10, cols: 40 }); + + // DevTools is lazily loaded; yield once to let the controller initialize. + await new Promise((r) => setTimeout(r, 0)); console.log("hello"); inst.unmount(); unpatch(); - const content = readFileSync(filePath, "utf8"); + const content = await (async () => { + for (let i = 0; i < 50; i++) { + try { + return readFileSync(filePath, "utf8"); + } catch { + await new Promise((r) => setTimeout(r, 5)); + } + } + return readFileSync(filePath, "utf8"); + })(); const first = content.split("\n").find(Boolean); expect(first).toBeTruthy(); const parsed = JSON.parse(first!); @@ -99,7 +111,7 @@ describe("devtools stream", () => { tcp: { host: "127.0.0.1", port: 0, - onListen: (info) => { + onListen: (info: { host: string; port: number }) => { listenInfo = info; }, }, diff --git a/tests/units/runtime/use-log.test.ts b/tests/units/runtime/use-log.test.ts index 72d2174..b5b2741 100644 --- a/tests/units/runtime/use-log.test.ts +++ b/tests/units/runtime/use-log.test.ts @@ -7,13 +7,15 @@ import { stopCapture, disposeSingletonCapture, } from "@/terminal/capture"; -import { useLog, type UseLogResult } from "@/devtools"; +import { useLog, type UseLogResult } from "@/hooks/use-log"; describe("useLog", () => { - const originalStdoutWrite = process.stdout.write; - const originalStderrWrite = process.stderr.write; + let originalStdoutWrite: typeof process.stdout.write; + let originalStderrWrite: typeof process.stderr.write; beforeEach(() => { + originalStdoutWrite = process.stdout.write; + originalStderrWrite = process.stderr.write; process.stdout.write = (() => true) as any; process.stderr.write = (() => true) as any; }); diff --git a/tests/units/terminal/capture.test.ts b/tests/units/terminal/capture.test.ts index e7d6cb6..dd84c7b 100644 --- a/tests/units/terminal/capture.test.ts +++ b/tests/units/terminal/capture.test.ts @@ -17,12 +17,14 @@ import { expect, describe, it, beforeEach, afterEach } from "bun:test"; describe("Output Capture", () => { // Mock process.stdout.write and process.stderr.write - const originalStdoutWrite = process.stdout.write; - const originalStderrWrite = process.stderr.write; + let originalStdoutWrite: typeof process.stdout.write; + let originalStderrWrite: typeof process.stderr.write; let stdoutOutput = ""; let stderrOutput = ""; beforeEach(() => { + originalStdoutWrite = process.stdout.write; + originalStderrWrite = process.stderr.write; stdoutOutput = ""; stderrOutput = ""; process.stdout.write = ((str: string) => { From e03ad35be795e156231397daaaeefcf5ecee2784 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Wed, 24 Dec 2025 18:28:20 +0900 Subject: [PATCH 04/16] Add DevTools inspector HTML UI Embed a new inspector.html frontend and import it into the devtools server (as text) so the server can serve the DevTools UI and handle logs/snapshots over WebSocket. --- src/devtools/inspector.html | 394 ++++++++++++++++++++++++++++++++++++ src/devtools/server.ts | 237 +--------------------- 2 files changed, 396 insertions(+), 235 deletions(-) create mode 100644 src/devtools/inspector.html diff --git a/src/devtools/inspector.html b/src/devtools/inspector.html new file mode 100644 index 0000000..34ab21f --- /dev/null +++ b/src/devtools/inspector.html @@ -0,0 +1,394 @@ + + + + + + btuin DevTools + + + +
+
btuin DevTools
+ connecting… + + WS: +
+
+
+

Logs

+
+
+
+
+

Snapshot

+
+ + +
+
+
+
+ + + (none) +
+
+
+ +
+
+ + + diff --git a/src/devtools/server.ts b/src/devtools/server.ts index 7b53a77..c8fb7ca 100644 --- a/src/devtools/server.ts +++ b/src/devtools/server.ts @@ -3,6 +3,7 @@ import type { ComputedLayout } from "../layout-engine/types"; import type { ConsoleCaptureHandle, ConsoleLine } from "../terminal/capture"; import { isBlock, isText, type ViewElement } from "../view/types/elements"; import type { DevtoolsOptions } from "./types"; +import htmlDocument from "./inspector.html" with { type: "text" }; export interface DevtoolsSnapshot { size: { rows: number; cols: number }; @@ -79,240 +80,6 @@ function buildBrowserSnapshot(snapshot: DevtoolsSnapshot): BrowserSnapshot { }; } -function htmlDocument(): string { - return ` - - - - - btuin DevTools - - - -
-
btuin DevTools
- connecting… - - WS: -
-
-
-
-

Snapshot

-
- - -
-
-
-
- - - (none) -
-
-
- -
-
-

Logs

-
-
-
- - -`; -} - function safeJson(payload: any): string { try { return JSON.stringify(payload); @@ -349,7 +116,7 @@ export function setupDevtoolsServer( return ok ? undefined : new Response("upgrade failed", { status: 400 }); } if (url.pathname === "/" || url.pathname === "/index.html") { - return new Response(htmlDocument(), { + return new Response(`${htmlDocument}`, { headers: { "content-type": "text/html; charset=utf-8" }, }); } From cc129726f3e94f10954d591062c31248c75d7284 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Wed, 24 Dec 2025 18:28:39 +0900 Subject: [PATCH 05/16] devtools compleate --- docs/roadmap.ja.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/roadmap.ja.md b/docs/roadmap.ja.md index 8cec4c6..26d4a9f 100644 --- a/docs/roadmap.ja.md +++ b/docs/roadmap.ja.md @@ -9,12 +9,12 @@ - [ ] マウス入力(SGR など)をランタイムへ統合(有効化/無効化・イベント形式の確定) - [ ] ヒットテスト(`ComputedLayout` と座標の照合、重なり順の決定) - [ ] バブリング/伝播(子→親、キャンセル可能なイベントモデル) -- [ ] Developer Tools +- [x] Developer Tools - [x] シェル統合 - [x] stdout/stderr capture 基盤(listener/console patch/テストモード): `src/terminal/capture.ts` - [x] `useLog`(capture → reactive state)でログUIを作る - - [ ] デバッグ - - [ ] インスペクターモード(境界線/座標/サイズ可視化) + - [x] デバッグ + - [x] インスペクターモード(境界線/座標/サイズ可視化) - [x] ホットリロード - [x] 配布 - [x] GitHub Release 用 tarball 生成(`src/layout-engine/native/` 同梱): `.github/workflows/release.yml` From 8ca3e7553d53dc0eec4d2f74fddd2897134d0c6e Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Thu, 25 Dec 2025 00:58:37 +0900 Subject: [PATCH 06/16] Remove hot-reload state support Drop the enableHotReloadState API, its CLI option (--no-preserve-state), related tests, types export, and example usage. Add BTUIN_DEVTOOLS_CONTROLLER env var and wiring: the CLI pins a controller file path into the child env, and the runtime resolves/imports a devtools controller from that spec. Move/adjust hot-reload module imports to src/cli and update docs. --- docs/devtools.ja.md | 35 +---------- docs/devtools.md | 35 +---------- examples/devtools.ts | 11 +--- src/cli/args.ts | 8 --- src/{dev => cli}/hot-reload.ts | 0 src/cli/main.ts | 20 ++++-- src/dev/hot-reload-state.ts | 77 ------------------------ src/dev/index.ts | 1 - src/devtools/inspector.html | 5 +- src/index.ts | 1 - src/runtime/loop.ts | 13 +++- src/types/index.ts | 1 - tests/units/cli/args.test.ts | 8 +-- tests/units/dev/hot-reload-state.test.ts | 29 --------- tests/units/dev/hot-reload.test.ts | 68 --------------------- 15 files changed, 35 insertions(+), 277 deletions(-) rename src/{dev => cli}/hot-reload.ts (100%) delete mode 100644 src/dev/hot-reload-state.ts delete mode 100644 src/dev/index.ts delete mode 100644 tests/units/dev/hot-reload-state.test.ts delete mode 100644 tests/units/dev/hot-reload.test.ts diff --git a/docs/devtools.ja.md b/docs/devtools.ja.md index d63b261..f8ebac0 100644 --- a/docs/devtools.ja.md +++ b/docs/devtools.ja.md @@ -15,6 +15,7 @@ btuin には、TUI 開発時にアプリを観測するための軽量なブラ - `BTUIN_DEVTOOLS=1`(有効化) - `BTUIN_DEVTOOLS_HOST` / `BTUIN_DEVTOOLS_PORT`(任意) +- `BTUIN_DEVTOOLS_CONTROLLER`(任意 / controller の module spec/path) # ホットリロード(開発用ランナー) @@ -41,23 +42,10 @@ btuin dev src/main.ts -- --foo bar - `--watch `(複数指定可) - `--debounce `(デフォルト: `50`) - `--cwd `(デフォルト: `process.cwd()`) -- `--no-preserve-state`(デフォルト: preserve 有効) - `--no-tcp`(TCP リロードトリガー無効化) - `--tcp-host `(デフォルト: `127.0.0.1`) - `--tcp-port `(デフォルト: `0`) -## リスタート時のステート保持 - -アプリ側で `enableHotReloadState()` を使うと、リスタート間で状態を引き継げます。 - -ステート保持を無効化: - -```bash -btuin dev examples/devtools.ts --no-preserve-state -``` - -注意: ホットリロードは `btuin dev`(dev runner)が適用します。 - ## TCPトリガ(任意) `btuin dev` はデフォルトで TCP を有効化(ポートは自動選択)します。 @@ -74,23 +62,4 @@ JSONLでもOK: printf '{"type":"reload"}\n' | nc 127.0.0.1 ``` -## ステート保持(任意 / opt-in) - -この方式はプロセスを再起動するため、通常はメモリ上の状態はリセットされます。 - -再起動後も状態を引き継ぎたい場合は、アプリ側で opt-in します: - -```ts -import { enableHotReloadState, ref } from "btuin"; - -const count = ref(0); - -enableHotReloadState({ - getSnapshot: () => ({ count: count.value }), - applySnapshot: (snapshot) => { - if (!snapshot || typeof snapshot !== "object") return; - const maybe = (snapshot as any).count; - if (typeof maybe === "number") count.value = maybe; - }, -}); -``` +注意: ホットリロードは `btuin dev`(dev runner)が適用します。 diff --git a/docs/devtools.md b/docs/devtools.md index 07c733e..f2e4b79 100644 --- a/docs/devtools.md +++ b/docs/devtools.md @@ -15,6 +15,7 @@ You can also enable it without code by setting env vars: - `BTUIN_DEVTOOLS=1` (enable) - `BTUIN_DEVTOOLS_HOST` / `BTUIN_DEVTOOLS_PORT` (optional) +- `BTUIN_DEVTOOLS_CONTROLLER` (optional; module spec/path for the controller) # Hot Reload (Dev Runner) @@ -41,23 +42,10 @@ Options: - `--watch ` (repeatable) - `--debounce ` (default: `50`) - `--cwd ` (default: `process.cwd()`) -- `--no-preserve-state` (default: preserve enabled) - `--no-tcp` (disable TCP reload trigger) - `--tcp-host ` (default: `127.0.0.1`) - `--tcp-port ` (default: `0`) -## Preserve state across restarts - -Use `enableHotReloadState()` in your app to opt into state preservation. - -Disable state preservation: - -```bash -btuin dev examples/devtools.ts --no-preserve-state -``` - -Note: hot reload is applied by `btuin dev` (dev runner). - ## TCP Trigger (Optional) `btuin dev` enables TCP by default (ephemeral port). @@ -74,23 +62,4 @@ Or JSONL: printf '{"type":"reload"}\n' | nc 127.0.0.1 ``` -## Preserving State (Opt-in) - -Because the runner restarts the process, in-memory state resets by default. - -If you want to preserve state across restarts, opt in from your app: - -```ts -import { enableHotReloadState, ref } from "btuin"; - -const count = ref(0); - -enableHotReloadState({ - getSnapshot: () => ({ count: count.value }), - applySnapshot: (snapshot) => { - if (!snapshot || typeof snapshot !== "object") return; - const maybe = (snapshot as any).count; - if (typeof maybe === "number") count.value = maybe; - }, -}); -``` +Note: hot reload is applied by `btuin dev` (dev runner). diff --git a/examples/devtools.ts b/examples/devtools.ts index 7b879ee..48b5c2c 100644 --- a/examples/devtools.ts +++ b/examples/devtools.ts @@ -1,19 +1,10 @@ -import { createApp, enableHotReloadState, ref } from "@/index"; +import { createApp, ref } from "@/index"; import { Text, VStack } from "@/view"; const app = createApp({ init({ onKey, onTick, runtime }) { const count = ref(0); - enableHotReloadState({ - getSnapshot: () => ({ count: count.value }), - applySnapshot: (snapshot) => { - if (!snapshot || typeof snapshot !== "object") return; - const maybe = (snapshot as any).count; - if (typeof maybe === "number") count.value = maybe; - }, - }); - onKey((k) => { if (k.name === "up") count.value++; if (k.name === "down") count.value--; diff --git a/src/cli/args.ts b/src/cli/args.ts index fa0a364..c810d43 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -8,7 +8,6 @@ export type BtuinCliParsed = cwd?: string; watch: string[]; debounceMs?: number; - preserveState: boolean; devtools: { enabled: boolean }; openBrowser: boolean; tcp: { enabled: false } | { enabled: true; host?: string; port?: number }; @@ -40,7 +39,6 @@ export function parseBtuinCliArgs(argv: string[]): BtuinCliParsed { let tcpEnabled = true; let tcpHost: string | undefined; let tcpPort: number | undefined; - let preserveState = true; let devtoolsEnabled = true; let openBrowser = true; @@ -95,11 +93,6 @@ export function parseBtuinCliArgs(argv: string[]): BtuinCliParsed { continue; } - if (a === "--no-preserve-state") { - preserveState = false; - continue; - } - if (a === "--tcp-host") { const v = takeFlagValue(rest, i, "--tcp-host"); tcpHost = v; @@ -139,7 +132,6 @@ export function parseBtuinCliArgs(argv: string[]): BtuinCliParsed { cwd, watch, debounceMs, - preserveState, devtools: { enabled: devtoolsEnabled }, openBrowser, tcp: tcpEnabled ? { enabled: true, host: tcpHost, port: tcpPort } : { enabled: false }, diff --git a/src/dev/hot-reload.ts b/src/cli/hot-reload.ts similarity index 100% rename from src/dev/hot-reload.ts rename to src/cli/hot-reload.ts diff --git a/src/cli/main.ts b/src/cli/main.ts index b2f49bd..807ec13 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -1,8 +1,8 @@ import { existsSync, readFileSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { runHotReloadProcess } from "../dev/hot-reload"; import { parseBtuinCliArgs } from "./args"; +import { runHotReloadProcess } from "./hot-reload"; function printHelp() { process.stderr.write( @@ -21,7 +21,6 @@ function printHelp() { " --watch Add watch path (repeatable)", " --debounce Debounce fs events (default: 50)", " --cwd Child working directory (default: process.cwd())", - " --no-preserve-state Disable state preservation (default: enabled)", " --no-devtools Disable browser DevTools auto-enable", " --no-open-browser Do not auto-open DevTools URL in browser", " --no-tcp Disable TCP reload trigger", @@ -115,15 +114,29 @@ export async function btuinCli(argv: string[]) { } const devtoolsEnv = (() => { + const out: Record = {}; + if (!parsed.devtools.enabled) { return { + ...out, BTUIN_DEVTOOLS: undefined, BTUIN_DEVTOOLS_HOST: undefined, BTUIN_DEVTOOLS_PORT: undefined, + BTUIN_DEVTOOLS_CONTROLLER: undefined, }; } - const env: Record = { BTUIN_DEVTOOLS: "1" }; + const env: Record = { ...out, BTUIN_DEVTOOLS: "1" }; + + // Let the runtime load DevTools via an env-provided module spec/path. + // This avoids hard-coding an internal import path, which makes future package split easier. + try { + env.BTUIN_DEVTOOLS_CONTROLLER = fileURLToPath( + new URL("../devtools/controller.ts", import.meta.url), + ); + } catch { + // ignore + } // Keep DevTools URL stable across hot-reload restarts by pinning host/port once. const host = process.env.BTUIN_DEVTOOLS_HOST ?? "127.0.0.1"; @@ -164,7 +177,6 @@ export async function btuinCli(argv: string[]) { args: [entryAbs, ...parsed.childArgs], cwd, watch: { paths: watchPaths, debounceMs: parsed.debounceMs }, - preserveState: parsed.preserveState, env: devtoolsEnv, openDevtoolsBrowser: parsed.openBrowser && parsed.devtools.enabled, tcp: parsed.tcp.enabled diff --git a/src/dev/hot-reload-state.ts b/src/dev/hot-reload-state.ts deleted file mode 100644 index f7503ee..0000000 --- a/src/dev/hot-reload-state.ts +++ /dev/null @@ -1,77 +0,0 @@ -type HotReloadIpcMessage = - | { type: "btuin:hot-reload:request-snapshot" } - | { type: "btuin:hot-reload:snapshot"; snapshot: unknown }; - -const SNAPSHOT_ENV_KEY = "BTUIN_HOT_RELOAD_SNAPSHOT"; - -function decodeSnapshot(encoded: string): unknown | null { - try { - const json = Buffer.from(encoded, "base64").toString("utf8"); - return JSON.parse(json) as unknown; - } catch { - return null; - } -} - -export interface EnableHotReloadStateOptions { - /** - * Create a JSON-serializable snapshot of your app state. - * This will be requested by the hot-reload runner before restart. - */ - getSnapshot: () => unknown; - - /** - * Restore from a previous snapshot (if present). - * Called once when this helper is first invoked. - */ - applySnapshot?: (snapshot: unknown) => void; -} - -let current: EnableHotReloadStateOptions | null = null; -let appliedEnvSnapshot = false; -let messageHandlerRegistered = false; - -/** - * Opt-in helper to preserve state across process restarts when using `btuin dev`. - * - * How it works: - * - The runner requests a snapshot via Bun IPC before restarting the process. - * - The runner passes the snapshot to the next process via `BTUIN_HOT_RELOAD_SNAPSHOT`. - */ -export function enableHotReloadState(options: EnableHotReloadStateOptions) { - current = options; - - if (!appliedEnvSnapshot && options.applySnapshot) { - const encoded = process.env[SNAPSHOT_ENV_KEY]; - if (encoded) { - const snapshot = decodeSnapshot(encoded); - if (snapshot !== null) { - try { - options.applySnapshot(snapshot); - } catch { - // ignore - } - } - } - appliedEnvSnapshot = true; - } - - const maybeSend = (process as any).send as undefined | ((msg: HotReloadIpcMessage) => void); - if (!maybeSend) return; - - if (messageHandlerRegistered) return; - messageHandlerRegistered = true; - - process.on("message", (message: any) => { - const m = message as HotReloadIpcMessage; - if (!m || typeof m !== "object" || !("type" in m)) return; - if ((m as any).type !== "btuin:hot-reload:request-snapshot") return; - if (!current) return; - - try { - maybeSend({ type: "btuin:hot-reload:snapshot", snapshot: current.getSnapshot() }); - } catch { - // ignore - } - }); -} diff --git a/src/dev/index.ts b/src/dev/index.ts deleted file mode 100644 index 1fff3a6..0000000 --- a/src/dev/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./hot-reload-state"; diff --git a/src/devtools/inspector.html b/src/devtools/inspector.html index 34ab21f..62a3613 100644 --- a/src/devtools/inspector.html +++ b/src/devtools/inspector.html @@ -185,10 +185,7 @@

Snapshot

- + (none)
diff --git a/src/index.ts b/src/index.ts index a122282..64365e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,6 @@ */ export { createApp, App } from "./runtime"; -export * from "./dev"; export { defineComponent } from "./components"; export * from "./view"; export * from "./hooks/"; diff --git a/src/runtime/loop.ts b/src/runtime/loop.ts index 1011d66..fd9192d 100644 --- a/src/runtime/loop.ts +++ b/src/runtime/loop.ts @@ -9,6 +9,8 @@ import { createRenderer } from "./render-loop"; import { createErrorContext, createErrorHandler } from "./error-boundary"; import type { AppContext } from "./context"; import type { ILoopManager } from "./types"; +import { pathToFileURL } from "node:url"; +import { resolve } from "node:path"; type DevtoolsControllerLike = { handleKey(event: KeyEvent): boolean; @@ -43,7 +45,7 @@ export class LoopManager implements ILoopManager { this.devtoolsInit = (async () => { try { - const mod: any = await import("../" + "devtools/controller"); + const mod: any = await import(resolveDevtoolsControllerModule()); const factory: undefined | ((opts: unknown) => DevtoolsControllerLike) = mod?.createDevtoolsController; if (!factory) return; @@ -250,3 +252,12 @@ export class LoopManager implements ILoopManager { this.cleanupTerminalFn = null; } } + +function resolveDevtoolsControllerModule(): string { + const spec = process.env.BTUIN_DEVTOOLS_CONTROLLER; + if (!spec) return "../" + "devtools/controller"; + if (spec.startsWith("file:")) return spec; + if (spec.startsWith("/")) return pathToFileURL(spec).href; + if (spec.startsWith(".")) return pathToFileURL(resolve(process.cwd(), spec)).href; + return spec; +} diff --git a/src/types/index.ts b/src/types/index.ts index f6b7a12..466bacc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -25,7 +25,6 @@ export type { MountOptions, RenderMode, } from "../runtime/types"; -export type { EnableHotReloadStateOptions } from "../dev/hot-reload-state"; export type { PlatformAdapter } from "../runtime/platform-adapter"; export type { TerminalAdapter } from "../runtime/terminal-adapter"; export type { FrameMetrics, ProfileOptions, ProfileOutput } from "../runtime/profiler"; diff --git a/tests/units/cli/args.test.ts b/tests/units/cli/args.test.ts index 3730a6e..f2ac0c9 100644 --- a/tests/units/cli/args.test.ts +++ b/tests/units/cli/args.test.ts @@ -13,7 +13,6 @@ describe("btuin cli args", () => { expect(parsed.entry).toBe("examples/devtools.ts"); expect(parsed.childArgs).toEqual(["--foo", "bar"]); expect(parsed.tcp.enabled).toBe(true); - expect(parsed.preserveState).toBe(true); }); it("should parse watch and tcp options", () => { @@ -45,10 +44,5 @@ describe("btuin cli args", () => { expect(parsed.tcp).toEqual({ enabled: false }); }); - it("should disable preserve state", () => { - const parsed = parseBtuinCliArgs(["dev", "src/main.ts", "--no-preserve-state"]); - expect(parsed.kind).toBe("dev"); - if (parsed.kind !== "dev") return; - expect(parsed.preserveState).toBe(false); - }); + // preserve-state support intentionally removed }); diff --git a/tests/units/dev/hot-reload-state.test.ts b/tests/units/dev/hot-reload-state.test.ts deleted file mode 100644 index e881555..0000000 --- a/tests/units/dev/hot-reload-state.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { enableHotReloadState } from "@/dev"; - -describe("hot reload state", () => { - it("should apply env snapshot and respond to IPC snapshot request", () => { - const snapshot = { count: 123 }; - process.env.BTUIN_HOT_RELOAD_SNAPSHOT = Buffer.from(JSON.stringify(snapshot), "utf8").toString( - "base64", - ); - - const sent: any[] = []; - (process as any).send = (msg: any) => { - sent.push(msg); - }; - - let applied: unknown = null; - enableHotReloadState({ - getSnapshot: () => ({ ok: true }), - applySnapshot: (s) => { - applied = s; - }, - }); - - expect(applied).toEqual(snapshot); - - process.emit("message", { type: "btuin:hot-reload:request-snapshot" }); - expect(sent).toContainEqual({ type: "btuin:hot-reload:snapshot", snapshot: { ok: true } }); - }); -}); diff --git a/tests/units/dev/hot-reload.test.ts b/tests/units/dev/hot-reload.test.ts deleted file mode 100644 index 13fe09e..0000000 --- a/tests/units/dev/hot-reload.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import net from "node:net"; -import { createTcpReloadServer } from "@/dev/hot-reload"; - -describe("hot reload", () => { - it("should trigger reload via TCP", async () => { - let onListen: { host: string; port: number } | null = null; - let reloadCount = 0; - - const server = createTcpReloadServer( - { - host: "127.0.0.1", - port: 0, - onListen: (info: { host: string; port: number }) => { - onListen = info; - }, - }, - () => { - reloadCount++; - }, - ); - - try { - for (let i = 0; i < 50 && !onListen; i++) { - await new Promise((r) => setTimeout(r, 10)); - } - - // Some sandboxed environments prohibit binding to TCP ports (EPERM). - // In that case, treat as a no-op and skip assertions. - if (!onListen) return; - - await new Promise((resolve, reject) => { - const socket = net.connect({ host: onListen!.host, port: onListen!.port }, () => { - socket.write("reload\n"); - }); - - const timer = setTimeout(() => { - try { - socket.destroy(); - } catch {} - reject(new Error("timeout waiting for reload")); - }, 500); - - socket.on("error", (err) => { - clearTimeout(timer); - reject(err); - }); - - const check = () => { - if (reloadCount > 0) { - clearTimeout(timer); - try { - socket.destroy(); - } catch {} - resolve(); - } else { - setTimeout(check, 5); - } - }; - check(); - }); - - expect(reloadCount).toBe(1); - } finally { - await server.close(); - } - }); -}); From 3fcf6b74959ff270b4d36c9579f90f97bbd3c3f5 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Thu, 25 Dec 2025 01:04:03 +0900 Subject: [PATCH 07/16] Restructure Japanese roadmap and expand DevTools --- docs/roadmap.ja.md | 58 +++++++++++++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/docs/roadmap.ja.md b/docs/roadmap.ja.md index 26d4a9f..c4c685f 100644 --- a/docs/roadmap.ja.md +++ b/docs/roadmap.ja.md @@ -1,29 +1,49 @@ # ロードマップ -- [x] 入力 - - [x] 入力パーサーをステートフル化(チャンク分割耐性): `src/terminal/parser/ansi.ts` +- [x] **入力** + - [x] 入力パーサーをステートフル化(チャンク分割耐性) - [x] `ESC` 単体 vs `Alt+Key` の曖昧さを解消 - - [x] ブラケットペーストを「1イベント」に正規化: `src/terminal/parser/ansi.ts` + - [x] ブラケットペーストを「1イベント」に正規化 - [x] ブラケットペーストの on/off をランタイムへ統合 -- [ ] マウス - - [ ] マウス入力(SGR など)をランタイムへ統合(有効化/無効化・イベント形式の確定) +- [ ] **マウス** + - [ ] マウス入力(SGR など)をランタイムへ統合 - [ ] ヒットテスト(`ComputedLayout` と座標の照合、重なり順の決定) - [ ] バブリング/伝播(子→親、キャンセル可能なイベントモデル) -- [x] Developer Tools - - [x] シェル統合 - - [x] stdout/stderr capture 基盤(listener/console patch/テストモード): `src/terminal/capture.ts` - - [x] `useLog`(capture → reactive state)でログUIを作る - - [x] デバッグ - - [x] インスペクターモード(境界線/座標/サイズ可視化) - - [x] ホットリロード +- [ ] **Developer Tools** + - [x] シェル統合(stdout/stderr capture, `useLog`) + - [x] デバッグ基盤(インスペクターモード, ホットリロード) + - [ ] **レイアウトエンジンの詳細可視化** + - [ ] Flexbox プロパティのインスペクト(`flex-grow`, `padding` 等の表示) + - [ ] 計算済みボックスモデルのカラー表示(Margin/Padding の視覚化) + - [ ] Z-Index / 階層構造の 3D 可視化 + - [ ] **パフォーマンス・プロファイリング** + - [ ] レンダリング・タイムライン(FFI境界・レイアウト計算・Diff生成の計測) + - [ ] リアクティビティ・グラフ(どの Ref がどのコンポーネントを更新したかの可視化) + - [ ] **双方向デバッグ** + - [ ] ブラウザ側からのリアクティブ・ステート(Ref)の直接書き換え + - [ ] リモートキーイベント送信(ブラウザ仮想キーボードからの入力注入) +- [ ] **アーキテクチャ・最適化** + - [ ] **FFI通信の効率化** + - [ ] フルシリアライズの回避(Dirty Checking による部分的なレイアウト更新) + - [ ] **大規模描画サポート** + - [ ] 仮想ウィンドウ化(Virtual Scrolling)による数万行のリスト表示 + - [ ] スクロールリージョン(DECSTBM)を活用した高速スクロール + - [ ] **リアクティビティの高度化** + - [ ] Effect Scope の導入(コンポーネントに紐付いた Effect の自動追跡・破棄) +- [ ] **開発体験 (DX) / 大規模開発サポート** + - [ ] **状態共有パターン** + - [ ] `Provide/Inject` または `Context API` 相当の依存注入機能 +- [ ] **安全性・堅牢性** + - [x] FFI 境界の同期テスト + - [ ] 致命的エラー時のセーフティネット(パニック時の Raw Mode 強制解除) +- [ ] **AI・アクセシビリティ** + - [ ] セマンティック・メタデータのサポート(AIエージェントや将来のA11y支援用) +- [ ] コンポーネント + - [ ] `TextInput` を実用レベルへ(編集・カーソル移動・IME) + - [ ] `ScrollView` / `ListView`(仮想スクロール、マウスホイール連動) - [x] 配布 - - [x] GitHub Release 用 tarball 生成(`src/layout-engine/native/` 同梱): `.github/workflows/release.yml` - - [x] `npm pack` の成果物を展開し、`src/layout-engine/native/` と `src/layout-engine/index.ts` の解決が噛み合うことを自動チェック + - [x] GitHub Release 用 tarball 生成 + - [x] npm pack 成果物の整合性チェック - [x] Inline モード -- [ ] コンポーネント - - [ ] `TextInput` を実用レベルへ(編集・カーソル移動・IME確定後の反映) - - [ ] `ScrollView` / `ListView`(必要に応じて仮想スクロール、マウスホイール連動) -- [x] 安全性 - - [x] FFI 境界の同期テスト(Rust 定数/構造体 ↔ JS 定義)を CI に追加 - [ ] ドキュメント / スターター - [ ] `examples/` の拡充 From bd5a531f9fcb42d16b74bb7bddcd5be12be1b235 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Thu, 25 Dec 2025 16:19:07 +0900 Subject: [PATCH 08/16] Add inspector example and enhance DevTools Remove legacy examples/devtools.ts and add a new examples/inspector.ts demo (layout/style/text panels, logs, Japanese and emoji content). Update devtools inspector HTML/CSS to improve preview/layout panels, selection rings and visual styles. Revise server snapshot logic to serialize layout and view style info and compute absolute layout boxes for the browser preview. --- examples/devtools.ts | 37 -- examples/inspector.ts | 264 ++++++++ src/devtools/inspector.html | 1138 ++++++++++++++++++++++++++++++----- src/devtools/server.ts | 124 +++- 4 files changed, 1340 insertions(+), 223 deletions(-) delete mode 100644 examples/devtools.ts create mode 100644 examples/inspector.ts diff --git a/examples/devtools.ts b/examples/devtools.ts deleted file mode 100644 index 48b5c2c..0000000 --- a/examples/devtools.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { createApp, ref } from "@/index"; -import { Text, VStack } from "@/view"; - -const app = createApp({ - init({ onKey, onTick, runtime }) { - const count = ref(0); - - onKey((k) => { - if (k.name === "up") count.value++; - if (k.name === "down") count.value--; - if (k.name === "l") console.log(`[app] count=${count.value}`); - if (k.name === "e") console.error(`[app] error (count=${count.value})`); - if (k.name === "q") runtime.exit(0); - }); - - onTick(() => { - // Keep some background noise for tail/stream demos. - if (count.value % 10 === 0) console.log(`[tick] count=${count.value}`); - }, 1000); - - return { count }; - }, - render({ count }) { - return VStack([ - Text("DevTools example").foreground("cyan"), - Text("DevTools: browser UI (no app code changes)"), - Text("Run: btuin dev examples/devtools.ts (auto enables + opens DevTools)"), - Text("Or: BTUIN_DEVTOOLS=1 bun examples/devtools.ts"), - Text("Keys: Up/Down=counter l=console.log e=console.error q=quit"), - Text(`count: ${count.value}`).foreground("yellow"), - ]) - .width("100%") - .height("100%"); - }, -}); - -await app.mount(); diff --git a/examples/inspector.ts b/examples/inspector.ts new file mode 100644 index 0000000..ea57dc7 --- /dev/null +++ b/examples/inspector.ts @@ -0,0 +1,264 @@ +import { createApp, ref } from "@/index"; +import { truncateTextWidth, wrapTextWidth } from "@/renderer"; +import { HStack, Text, VStack, ZStack } from "@/view"; +import { Block } from "@/view/primitives"; + +const BOX_W = 24; +const LOG_LIMIT = 18; + +function OutlineBox( + title: string, + children: Parameters[0], + width?: number | string, +) { + const box = VStack(children).outline({ color: 243, style: "single", title }); + if (width !== undefined) box.width(width); + return box; +} + +function Toggle(label: string, active: boolean) { + const el = Text(` ${label} `); + if (active) { + el.background(236).foreground(231); + } + return Block(el); +} + +function TextBox({ + title, + content, + width, + wrap, + fg, + bg, + outlineStyle, +}: { + title: string; + content: string; + width: number; + wrap: boolean; + fg?: string | number; + bg?: string | number; + outlineStyle?: "single" | "double"; +}) { + const innerW = Math.max(1, width - 2); + const lines = wrap ? wrapTextWidth(content, innerW) : [truncateTextWidth(content, innerW)]; + + const header = Text(title).foreground("cyan"); + const body = VStack(lines.map((line) => Text(line))); + + const box = VStack([header, body]) + .width(width) + .padding(0) + .foreground(fg ?? 253) + .outline({ + color: 244, + style: outlineStyle ?? "single", + title, + }); + if (bg !== undefined) { + box.background(bg); + } + return box; +} + +function Tag(label: string, bg: number, fg = 16) { + const el = Text(` ${label} `).background(bg).foreground(fg); + el.setKey(`tag:${label}`); + return el; +} + +const app = createApp({ + init({ onKey, onTick, runtime }) { + const counter = ref(0); + const selected = ref<"layout" | "style" | "text">("layout"); + const sidebarWide = ref(true); + const logs = ref([]); + + const addLog = (line: string) => { + const next = logs.value.slice(-LOG_LIMIT + 1); + next.push(line); + logs.value = next; + }; + + onKey((k) => { + if (k.name === "up") counter.value++; + if (k.name === "down") counter.value--; + if (k.name === "1") selected.value = "layout"; + if (k.name === "2") selected.value = "style"; + if (k.name === "3") selected.value = "text"; + if (k.name === "s") sidebarWide.value = !sidebarWide.value; + if (k.name === "l") { + const msg = `[ログ] counter=${counter.value}`; + addLog(msg); + console.log(msg); + } + if (k.name === "e") { + const msg = `[error] counter=${counter.value}`; + addLog(msg); + console.error(msg); + } + if (k.name === "q") runtime.exit(0); + }); + + onTick(() => { + if (counter.value % 10 === 0) { + addLog(`[tick] カウンター=${counter.value}`); + } + if (counter.value % 15 === 0) console.log(`[tick] counter=${counter.value}`); + }, 1000); + + return { counter, selected, sidebarWide, logs }; + }, + + render({ counter, selected, sidebarWide, logs }) { + const sidebarW = sidebarWide.value ? "22%" : "16%"; + const menu = OutlineBox( + "menu", + [ + Text("インスペクター / Inspector").foreground(81), + Text("Keys: 1/2/3 tab"), + Text("s sidebar"), + Text("↑/↓ counter"), + Text("l log e error q quit"), + Text("Tabs / タブ").foreground(81), + Toggle("1 Layout", selected.value === "layout"), + Toggle("2 Style", selected.value === "style"), + Toggle("3 Text", selected.value === "text"), + Text("Tags").foreground(81), + Tag("bg=17", 17), + Tag("bg=52", 52), + Tag("bg=88", 88), + Tag("bg=124", 124), + ], + sidebarW, + ).height("100%"); + menu.style.margin = [0, 0, 0, 0]; + + const header = HStack([ + Text(`counter ${counter.value}`).foreground(229), + Text("btuin DevTools / Inspector").foreground(81), + Text("状態: 実行中 / running").foreground(247), + Tag("stack=z", 90, 16), + ]) + + .outline({ color: 243, style: "single", title: "status" }); + header.style.margin = [0, 0, 0, 0]; + + const complexLayout = OutlineBox( + "layout panel", + [ + Text("複雑レイアウト / Complex layout").foreground(81), + HStack([ + OutlineBox("fixed", [Text("Fixed 8x3"), Text("固定幅")], 8).height(3), + Block(Text("Grow flex=1")) + .grow(1) + .height(3) + .outline({ color: 244, style: "single", title: "grow" }), + Block(Text("Abs overlay")).width(10).height(3), + ]), + ZStack([ + Block(Text("ZStack base")).width(20).height(5), + Block(Text("Overlay A")) + .width(10) + .height(3) + .outline({ color: 244, style: "double", title: "overlay" }), + Block(Text("Overlay B")) + .width(8) + .height(2) + .outline({ color: 245, style: "single", title: "overlay b" }), + ]) + .width(22) + .height(5) + .outline({ color: 244, style: "single", title: "zstack" }), + ], + undefined, + ); + + const diverseStyle = OutlineBox( + "style panel", + [ + Text("多様なスタイル / Diverse style").foreground(81), + HStack([ + Block(Text("padding=[1,2,1,2]")) + .padding([1, 2, 1, 2]) + .outline({ color: 244, style: "single", title: "pad array" }), + Block(Text("bg=magenta fg=white")) + .padding(1) + .background("magenta") + .foreground("white") + .outline({ color: "cyan", style: "double", title: "named colors" }), + ]), + (() => { + const wrapRow = Block( + Tag("flexWrap=wrap", 24, 231), + Tag("tag", 31, 231), + Tag("long-tag", 32, 231), + Tag("日本語", 33, 231), + Tag("emoji👩🏽‍💻", 34, 231), + Tag("1234567890", 35, 231), + ) + .direction("row") + .width("100%") + .outline({ color: 244, style: "single", title: "flexWrap (visual)" }); + wrapRow.style.flexWrap = "wrap"; + return wrapRow; + })(), + ], + undefined, + ); + + const mixedText = + "ASCII: The quick brown fox jumps over the lazy dog. / " + + "CJK: 日本語の文章と漢字、かな、カナ。 / " + + "Emoji: 👩🏽‍💻🧪✨ / " + + "Wide: WIDE TEXT / " + + "Mixed: hello世界123"; + + const textPanel = OutlineBox("text panel", [ + Text("文字 / Text (ASCII + CJK + emoji)").foreground(81), + VStack([ + TextBox({ + title: "No wrap / truncate", + content: mixedText, + width: BOX_W, + wrap: false, + fg: 253, + outlineStyle: "single", + }).setKey("textbox:nowrap"), + TextBox({ + title: "Wrap / wrapTextWidth", + content: mixedText, + width: BOX_W, + wrap: true, + fg: 253, + outlineStyle: "double", + }).setKey("textbox:wrap"), + ]), + ]); + + const content = + selected.value === "layout" + ? complexLayout + : selected.value === "style" + ? diverseStyle + : textPanel; + + const logsBox = OutlineBox( + "logs", + [ + Text("ログ / Logs").foreground(81), + ...logs.value.map((line, idx) => Text(`${String(idx + 1).padStart(2, "0")} ${line}`)), + ], + "24%", + ).height("100%"); + + const main = VStack([header, content.grow(1)]) + .grow(1) + .height("100%"); + + return HStack([menu, main, logsBox]).height("100%"); + }, +}); + +await app.mount(); diff --git a/src/devtools/inspector.html b/src/devtools/inspector.html index 62a3613..a232195 100644 --- a/src/devtools/inspector.html +++ b/src/devtools/inspector.html @@ -4,6 +4,7 @@ btuin DevTools + -
-
btuin DevTools
- connecting… - - WS: -
-
-
-

Logs

-
-
-
-
-

Snapshot

-
- - +
+
+
btuin DevTools
+ {{ status }} + + WS: {{ wsUrl }} +
+
+
+

Logs

+
+
+ {{ line.display }} +
-
-
-
- - - (none) +
+
+
+

Snapshot

+
+ + +
-
-
- - +
+
+ + + + + {{ previewSize }} +
+
+
+
+
+
+ Selected: {{ selectedKey || "(none)" }} + Type: {{ selectedType }} +
+
+ Styles + + + + + +
{{ row.key }}{{ row.section ? "" : row.value }}
+
+
+
+
{{ snapshotText }}
+ +
diff --git a/src/devtools/server.ts b/src/devtools/server.ts index c8fb7ca..557b2fd 100644 --- a/src/devtools/server.ts +++ b/src/devtools/server.ts @@ -1,5 +1,6 @@ import type { ServerWebSocket } from "bun"; -import type { ComputedLayout } from "../layout-engine/types"; +import type { ComputedLayout, LayoutStyle } from "../layout-engine/types"; +import type { OutlineOptions } from "../renderer/types"; import type { ConsoleCaptureHandle, ConsoleLine } from "../terminal/capture"; import { isBlock, isText, type ViewElement } from "../view/types/elements"; import type { DevtoolsOptions } from "./types"; @@ -13,11 +14,66 @@ export interface DevtoolsSnapshot { type LayoutBox = { x: number; y: number; width: number; height: number }; +type LayoutStyleKey = + | "display" + | "position" + | "width" + | "height" + | "minWidth" + | "minHeight" + | "maxWidth" + | "maxHeight" + | "layoutBoundary" + | "padding" + | "margin" + | "flexDirection" + | "flexWrap" + | "flexGrow" + | "flexShrink" + | "flexBasis" + | "justifyContent" + | "alignItems" + | "alignSelf" + | "gap"; + +const layoutStyleKeys: readonly LayoutStyleKey[] = [ + "display", + "position", + "width", + "height", + "minWidth", + "minHeight", + "maxWidth", + "maxHeight", + "layoutBoundary", + "padding", + "margin", + "flexDirection", + "flexWrap", + "flexGrow", + "flexShrink", + "flexBasis", + "justifyContent", + "alignItems", + "alignSelf", + "gap", +] as const; +type LayoutStyleInfo = Partial>; + +type ViewStyleInfo = { + foreground?: string | number; + background?: string | number; + outline?: Pick; + stack?: "z"; +}; + type ViewNode = { key: string; type: string; text?: string; children?: ViewNode[]; + layoutStyle?: LayoutStyleInfo; + viewStyle?: ViewStyleInfo; }; type BrowserSnapshot = { @@ -37,40 +93,68 @@ function getKey(el: ViewElement): string { return (el.key ?? el.identifier ?? "") as string; } -function serializeViewTree(root: ViewElement): { tree: ViewNode; keys: Set } { - const keys = new Set(); +function pickLayoutStyle(style: ViewElement["style"] | undefined): LayoutStyleInfo | undefined { + if (!style) return undefined; + const out: LayoutStyleInfo = {}; + for (const key of layoutStyleKeys) { + if (style[key] !== undefined) { + (out as any)[key] = style[key]; + } + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function pickViewStyle(style: ViewElement["style"] | undefined): ViewStyleInfo | undefined { + if (!style) return undefined; + const out: ViewStyleInfo = {}; + + if (style.foreground !== undefined) out.foreground = style.foreground; + if (style.background !== undefined) out.background = style.background; + if (style.stack !== undefined) out.stack = style.stack; + + if (style.outline) { + const { color, style: outlineStyle, title } = style.outline; + out.outline = { color, style: outlineStyle, title }; + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +function buildBrowserSnapshot(snapshot: DevtoolsSnapshot): BrowserSnapshot { + const layout: Record = {}; - const walk = (el: ViewElement): ViewNode => { + const walk = (el: ViewElement, parentX: number, parentY: number): ViewNode => { const key = getKey(el); - keys.add(key); + const entry = snapshot.layoutMap[key]; + const absX = (entry?.x ?? 0) + parentX; + const absY = (entry?.y ?? 0) + parentY; + const width = entry?.width ?? 0; + const height = entry?.height ?? 0; + if (entry) { + layout[key] = { x: absX, y: absY, width, height }; + } + + const layoutStyle = pickLayoutStyle(el.style); + const viewStyle = pickViewStyle(el.style); if (isText(el)) { - return { key, type: el.type, text: el.content }; + return { key, type: el.type, text: el.content, layoutStyle, viewStyle }; } if (isBlock(el)) { return { key, type: el.type, - children: el.children.map(walk), + layoutStyle, + viewStyle, + children: el.children.map((child) => walk(child, absX, absY)), }; } - return { key, type: el.type }; + return { key, type: el.type, layoutStyle, viewStyle }; }; - return { tree: walk(root), keys }; -} - -function buildBrowserSnapshot(snapshot: DevtoolsSnapshot): BrowserSnapshot { - const { tree, keys } = serializeViewTree(snapshot.rootElement); - const layout: Record = {}; - - for (const key of keys) { - const entry = snapshot.layoutMap[key]; - if (!entry) continue; - layout[key] = { x: entry.x, y: entry.y, width: entry.width, height: entry.height }; - } + const tree = walk(snapshot.rootElement, 0, 0); return { timestamp: Date.now(), From 5a839e983f4ddda2a4ed4106220242c8c27bb625 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Thu, 25 Dec 2025 16:32:07 +0900 Subject: [PATCH 09/16] Check off two layout engine roadmap items Mark Flexbox property inspection and computed box-model color visualization as completed in docs/roadmap.ja.md --- docs/roadmap.ja.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.ja.md b/docs/roadmap.ja.md index c4c685f..cd6fcfa 100644 --- a/docs/roadmap.ja.md +++ b/docs/roadmap.ja.md @@ -13,8 +13,8 @@ - [x] シェル統合(stdout/stderr capture, `useLog`) - [x] デバッグ基盤(インスペクターモード, ホットリロード) - [ ] **レイアウトエンジンの詳細可視化** - - [ ] Flexbox プロパティのインスペクト(`flex-grow`, `padding` 等の表示) - - [ ] 計算済みボックスモデルのカラー表示(Margin/Padding の視覚化) + - [x] Flexbox プロパティのインスペクト(`flex-grow`, `padding` 等の表示) + - [x] 計算済みボックスモデルのカラー表示(Margin/Padding の視覚化) - [ ] Z-Index / 階層構造の 3D 可視化 - [ ] **パフォーマンス・プロファイリング** - [ ] レンダリング・タイムライン(FFI境界・レイアウト計算・Diff生成の計測) From 016f397c36d4f775ccac7039d32296bd9fb940c2 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Thu, 25 Dec 2025 17:15:57 +0900 Subject: [PATCH 10/16] Add 3D Z-Index visualization and panning Add a 3D preview mode with a toggle and depth slider. Apply translateZ transforms, per-depth opacity/shadows and 3D stage styling. Add pointer-based panning (middle/right drag or modifier), pointer capture handling and key listeners. Update roadmap to mark feature complete. --- docs/roadmap.ja.md | 2 +- src/devtools/inspector.html | 181 ++++++++++++++++++++++++++++++++---- 2 files changed, 166 insertions(+), 17 deletions(-) diff --git a/docs/roadmap.ja.md b/docs/roadmap.ja.md index cd6fcfa..8936a8b 100644 --- a/docs/roadmap.ja.md +++ b/docs/roadmap.ja.md @@ -15,7 +15,7 @@ - [ ] **レイアウトエンジンの詳細可視化** - [x] Flexbox プロパティのインスペクト(`flex-grow`, `padding` 等の表示) - [x] 計算済みボックスモデルのカラー表示(Margin/Padding の視覚化) - - [ ] Z-Index / 階層構造の 3D 可視化 + - [x] Z-Index / 階層構造の 3D 可視化 - [ ] **パフォーマンス・プロファイリング** - [ ] レンダリング・タイムライン(FFI境界・レイアウト計算・Diff生成の計測) - [ ] リアクティビティ・グラフ(どの Ref がどのコンポーネントを更新したかの可視化) diff --git a/src/devtools/inspector.html b/src/devtools/inspector.html index a232195..dfbf227 100644 --- a/src/devtools/inspector.html +++ b/src/devtools/inspector.html @@ -172,6 +172,16 @@ overflow: auto; min-height: 240px; flex: 1 1 auto; + cursor: grab; + } + #previewScroll.is-panning { + cursor: grabbing; + user-select: none; + } + #previewScroll.is-3d { + perspective: 960px; + perspective-origin: top left; + padding: 120px 160px 180px 120px; } #previewPanel { display: flex; @@ -181,7 +191,11 @@ } #previewStage { position: relative; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Noto Sans Mono CJK JP", "Noto Sans Mono", "M PLUS 1 Code", + "BIZ UDGothic", "MS Gothic", "Osaka-Mono", Osaka, "Liberation Mono", + "Courier New", monospace; font-size: 12px; line-height: 1.25; background-color: var(--cell-bg); @@ -198,6 +212,11 @@ ); background-size: var(--cellw, 9px) var(--cellh, 18px); } + #previewStage.is-3d { + transform-style: preserve-3d; + transform-origin: top left; + transform: rotateX(38deg) rotateZ(-2deg); + } .node { position: absolute; box-sizing: border-box; @@ -300,12 +319,27 @@

Snapshot

+ + + Mid/Right Drag {{ previewSize }} -
+
@@ -741,12 +775,6 @@

Snapshot

const frag = document.createDocumentFragment(); let paintIndex = 0; - const pushLayer = (el) => { - if (!el) return; - paintIndex += 1; - el.style.zIndex = String(paintIndex); - frag.appendChild(el); - }; const defaultBg = getComputedStyle(previewStage).backgroundColor || "transparent"; const bgCache = new Map(); @@ -768,6 +796,58 @@

Snapshot

return defaultBg; }; const items = flattenTree(snapshot.tree); + const depthByKey = new Map(); + for (const item of items) { + if (item?.node?.key) depthByKey.set(item.node.key, item.depth); + } + const applyDepth = (el, key) => { + if (!state.showDepth || !el) return; + const depth = depthByKey.get(key) ?? 0; + const z = depth * state.depthStep; + const shift = Math.round(depth * state.depthStep * 0.18); + el.style.transform = + "translateZ(" + + z + + "px) translateX(" + + shift + + "px) translateY(" + + shift + + "px)"; + el.style.transformStyle = "preserve-3d"; + const depthClamped = Math.min(10, depth); + if (!el.classList.contains("selection")) { + const opacity = Math.max(0.55, 1 - depthClamped * 0.05); + el.style.opacity = String(opacity); + } + if ( + el.classList.contains("node") && + !el.classList.contains("outline-text") && + !el.classList.contains("text") + ) { + el.style.boxShadow = + "0 " + + depthClamped + + "px " + + depthClamped * 2 + + "px rgba(0, 0, 0, 0.35)"; + } + if (el.classList.contains("text")) { + el.style.textShadow = + "0 " + + Math.max(1, depthClamped) + + "px " + + Math.max(2, depthClamped * 2) + + "px rgba(0, 0, 0, 0.45)"; + } + }; + const pushLayer = (el, key) => { + if (!el) return; + if (key) applyDepth(el, key); + paintIndex += 1; + el.style.zIndex = String(paintIndex); + frag.appendChild(el); + }; + const pushLayerForKey = (key) => (el) => pushLayer(el, key); for (const item of items) { const node = item.node; const box = @@ -786,7 +866,7 @@

Snapshot

const viewStyle = node.viewStyle || null; if (state.showMargin && style && style.margin !== undefined) { appendMarginRings( - pushLayer, + pushLayerForKey(node.key), rbox, normalizeInsets(style.margin), state.cellW, @@ -805,11 +885,11 @@

Snapshot

state.cellW, state.cellH, ); - if (bg) pushLayer(bg); + if (bg) pushLayer(bg, node.key); } if (state.showPadding && style && style.padding !== undefined) { appendPaddingRings( - pushLayer, + pushLayerForKey(node.key), rbox, normalizeInsets(style.padding), state.cellW, @@ -820,7 +900,7 @@

Snapshot

const outlineColor = colorToCss(viewStyle.outline.color) || "rgba(148, 163, 184, 0.8)"; appendOutlineText( - pushLayer, + pushLayerForKey(node.key), rbox, viewStyle.outline.style, outlineColor, @@ -862,7 +942,7 @@

Snapshot

boxEl.style.width = rbox.width * state.cellW + "px"; boxEl.style.height = rbox.height * state.cellH + "px"; boxEl.title = title; - pushLayer(boxEl); + pushLayer(boxEl, node.key); } for (let i = 0; i < lineCount; i += 1) { const raw = lines[i] ?? ""; @@ -880,7 +960,7 @@

Snapshot

if (fg) el.style.color = fg; el.textContent = clipped.text; el.title = title; - pushLayer(el); + pushLayer(el, node.key); } } else { const el = document.createElement("div"); @@ -891,7 +971,7 @@

Snapshot

el.style.width = rbox.width * state.cellW + "px"; el.style.height = rbox.height * state.cellH + "px"; el.title = title; - pushLayer(el); + pushLayer(el, node.key); } } if (state.selectedKey && snapshot.layout?.[state.selectedKey]) { @@ -914,7 +994,7 @@

Snapshot

); if (sel) { sel.style.pointerEvents = "none"; - pushLayer(sel); + pushLayer(sel, state.selectedKey); } } previewStage.appendChild(frag); @@ -957,7 +1037,17 @@

Snapshot

showBoxes: true, showMargin: true, showPadding: true, + showDepth: false, + depthStep: 14, previewSize: "(none)", + isPanning: false, + panMoved: false, + panStartX: 0, + panStartY: 0, + panScrollLeft: 0, + panScrollTop: 0, + panPointerId: null, + panModifierActive: false, logs: [], logSeq: 0, snapshotText: "(none)", @@ -988,10 +1078,16 @@

Snapshot

this.socket.addEventListener("message", (ev) => { this.onSocketMessage(ev); }); + window.addEventListener("keydown", this.onKeyDown); + window.addEventListener("keyup", this.onKeyUp); this.applyZoom(); this.updateInspector(); }, + beforeUnmount() { + window.removeEventListener("keydown", this.onKeyDown); + window.removeEventListener("keyup", this.onKeyUp); + }, watch: { zoom() { this.applyZoom(); @@ -1007,11 +1103,60 @@

Snapshot

this.updateInspector(); renderPreview(this, this.latestSnapshot); }, + showDepth() { + renderPreview(this, this.latestSnapshot); + }, + depthStep() { + if (this.showDepth) { + renderPreview(this, this.latestSnapshot); + } + }, }, methods: { setTab(tab) { this.tab = tab; }, + onPanStart(ev) { + if (!this.showDepth) return; + const isLeft = ev.button === 0; + const isAltButton = ev.button === 1 || ev.button === 2; + if (isLeft && !this.panModifierActive) return; + if (!isLeft && !isAltButton) return; + const scrollEl = ev.currentTarget; + if (!(scrollEl instanceof HTMLElement)) return; + this.isPanning = true; + this.panMoved = true; + this.panStartX = ev.clientX; + this.panStartY = ev.clientY; + this.panScrollLeft = scrollEl.scrollLeft; + this.panScrollTop = scrollEl.scrollTop; + ev.preventDefault(); + this.panPointerId = ev.pointerId; + scrollEl.setPointerCapture(ev.pointerId); + }, + onPanMove(ev) { + if (!this.isPanning || ev.pointerId !== this.panPointerId) return; + const scrollEl = ev.currentTarget; + if (!(scrollEl instanceof HTMLElement)) return; + if (!scrollEl) return; + const dx = ev.clientX - this.panStartX; + const dy = ev.clientY - this.panStartY; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this.panMoved = true; + scrollEl.scrollLeft = this.panScrollLeft - dx; + scrollEl.scrollTop = this.panScrollTop - dy; + }, + onPanEnd(ev) { + if (!this.isPanning) return; + const scrollEl = ev.currentTarget; + if (scrollEl instanceof HTMLElement && this.panPointerId !== null) { + scrollEl.releasePointerCapture(this.panPointerId); + } + this.panPointerId = null; + this.isPanning = false; + setTimeout(() => { + this.panMoved = false; + }, 0); + }, requestSnapshot() { try { this.socket?.send(JSON.stringify({ type: "requestSnapshot" })); @@ -1024,6 +1169,10 @@

Snapshot

} }, onPreviewClick(ev) { + if (this.panMoved) { + this.panMoved = false; + return; + } const target = ev.target; if (!(target instanceof Element)) return; const nodeEl = target.closest(".node"); From 5a4f6363d26b95ec118063195308a8e2ad71b263 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Thu, 25 Dec 2025 17:48:57 +0900 Subject: [PATCH 11/16] Add profiler streaming to devtools --- docs/roadmap.ja.md | 2 +- examples/inspector.ts | 1 + src/devtools/controller.ts | 5 + src/devtools/inspector.html | 302 ++++++++++++++++++++++++++++++++++-- src/devtools/server.ts | 19 +++ src/runtime/loop.ts | 22 +++ src/runtime/profiler.ts | 17 ++ 7 files changed, 357 insertions(+), 11 deletions(-) diff --git a/docs/roadmap.ja.md b/docs/roadmap.ja.md index 8936a8b..e7c9a9e 100644 --- a/docs/roadmap.ja.md +++ b/docs/roadmap.ja.md @@ -17,7 +17,7 @@ - [x] 計算済みボックスモデルのカラー表示(Margin/Padding の視覚化) - [x] Z-Index / 階層構造の 3D 可視化 - [ ] **パフォーマンス・プロファイリング** - - [ ] レンダリング・タイムライン(FFI境界・レイアウト計算・Diff生成の計測) + - [x] レンダリング・タイムライン(FFI境界・レイアウト計算・Diff生成の計測) - [ ] リアクティビティ・グラフ(どの Ref がどのコンポーネントを更新したかの可視化) - [ ] **双方向デバッグ** - [ ] ブラウザ側からのリアクティブ・ステート(Ref)の直接書き換え diff --git a/examples/inspector.ts b/examples/inspector.ts index ea57dc7..f3fa1c9 100644 --- a/examples/inspector.ts +++ b/examples/inspector.ts @@ -69,6 +69,7 @@ function Tag(label: string, bg: number, fg = 16) { } const app = createApp({ + profile: { enabled: true }, init({ onKey, onTick, runtime }) { const counter = ref(0); const selected = ref<"layout" | "style" | "text">("layout"); diff --git a/src/devtools/controller.ts b/src/devtools/controller.ts index f730b36..2d91108 100644 --- a/src/devtools/controller.ts +++ b/src/devtools/controller.ts @@ -3,6 +3,7 @@ import type { ConsoleCaptureHandle } from "../terminal/capture"; import { setupDevtoolsLogStreaming } from "./log-stream"; import type { DevtoolsOptions } from "./types"; import { setupDevtoolsServer, type DevtoolsSnapshot } from "./server"; +import type { FrameMetrics } from "../runtime/profiler"; export interface DevtoolsController { handleKey(event: KeyEvent): boolean; @@ -10,6 +11,7 @@ export interface DevtoolsController { root: import("../view/types/elements").ViewElement, ): import("../view/types/elements").ViewElement; onLayout?(snapshot: DevtoolsSnapshot): void; + onProfileFrame?(frame: FrameMetrics): void; dispose(): void; } @@ -32,6 +34,9 @@ export function createDevtoolsController(options: DevtoolsOptions | undefined): onLayout: (snapshot) => { server?.setSnapshot(snapshot); }, + onProfileFrame: (frame) => { + server?.setProfileFrame(frame); + }, dispose: () => { try { diff --git a/src/devtools/inspector.html b/src/devtools/inspector.html index dfbf227..7cbf9da 100644 --- a/src/devtools/inspector.html +++ b/src/devtools/inspector.html @@ -117,6 +117,29 @@ gap: 8px; height: 100%; } + .panel-stack { + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; + min-height: 0; + } + .panel-section { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 0; + border: 1px solid #1f2937; + border-radius: 8px; + padding: 8px; + background: #0b1118; + } + .panel-section.logs { + flex: 1.2 1 0; + } + .panel-section.profiler { + flex: 0.8 1 0; + } .inspect { border: 1px solid #1f2937; border-radius: 8px; @@ -164,6 +187,97 @@ color: #93c5fd; font-weight: 700; } + .loglist { + overflow: auto; + min-height: 0; + flex: 1 1 auto; + } + .profile-summary { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + font-size: 12px; + color: #cbd5e1; + } + .profile-summary .pill { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + } + .profile-legend { + display: flex; + flex-wrap: wrap; + gap: 10px; + font-size: 11px; + color: #94a3b8; + } + .profile-legend .legend-item { + display: inline-flex; + gap: 6px; + align-items: center; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + } + .profile-legend .swatch { + width: 10px; + height: 10px; + border-radius: 3px; + } + .profile-legend .swatch.layout { + background: #38bdf8; + } + .profile-legend .swatch.render { + background: #34d399; + } + .profile-legend .swatch.diff { + background: #f59e0b; + } + .profile-legend .swatch.write { + background: #f472b6; + } + .profile-list { + flex: 1 1 auto; + overflow: auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: 6px; + padding-right: 4px; + } + .profile-row { + display: grid; + grid-template-columns: 52px 1fr 56px; + align-items: center; + gap: 8px; + font-size: 11px; + color: #cbd5e1; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + } + .profile-meta { + opacity: 0.7; + } + .profile-bar { + display: flex; + height: 10px; + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 6px; + overflow: hidden; + } + .profile-seg.layout { + background: #38bdf8; + } + .profile-seg.render { + background: #34d399; + } + .profile-seg.diff { + background: #f59e0b; + } + .profile-seg.write { + background: #f472b6; + } + .profile-ms { + text-align: right; + color: #e5e7eb; + } #previewScroll { border: 1px solid #1f2937; border-radius: 8px; @@ -278,16 +392,80 @@ WS: {{ wsUrl }}
-
-

Logs

-
-
- {{ line.display }} +
+
+
+

Logs

+ {{ logs.length }} lines +
+
+
+ {{ line.display }} +
+
+
+
+
+

Profiler

+ {{ profileStatus }} +
+
+ Avg {{ formatMs(profileAvgMs) }} + Max {{ formatMs(profileMaxMs) }} + FPS {{ profileFps }} + Nodes {{ profileLast?.nodeCount ?? "-" }} + Bytes {{ profileLast?.outputBytes ?? "-" }} +
+
+ Layout + Render + Diff + Write +
+
+
+ #{{ frame.id }} +
+ + + + +
+ {{ formatMs(frame.frameMs) }} +
@@ -372,6 +550,8 @@

Snapshot