From 2d652e2a8259623d4b5945586af66021f20321f2 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:58:46 +0900 Subject: [PATCH] Add inline mode with stdout/stderr passthrough Integrate an inline diff renderer into the render loop and suspend the UI temporarily when stdout/stderr is emitted. Add terminal adapter hooks (onStdout/onStderr/writeStdout/writeStderr) and wire capture passthrough. Include docs, README entries, an example (inline-progress) and unit tests for output passthrough and write coalescing. --- README.ja.md | 9 ++ README.md | 9 ++ docs/architecture.ja.md | 30 +++++ docs/architecture.md | 30 +++++ docs/roadmap.ja.md | 2 +- docs/roadmap.md | 2 +- examples/inline-progress.ts | 31 +++++ src/runtime/loop.ts | 89 +++++++++++-- src/runtime/terminal-adapter.ts | 27 ++++ tests/units/runtime/inline-output.test.ts | 150 ++++++++++++++++++++++ 10 files changed, 363 insertions(+), 16 deletions(-) create mode 100644 examples/inline-progress.ts create mode 100644 tests/units/runtime/inline-output.test.ts diff --git a/README.ja.md b/README.ja.md index 3f93024..ed47d60 100644 --- a/README.ja.md +++ b/README.ja.md @@ -48,6 +48,14 @@ const app = createApp({ await app.mount(); ``` +## Inline モード + +ターミナル全体を消さずに描画します: + +```ts +await app.mount({ inline: true, inlineCleanupOnExit: true }); +``` + ## API概要 - `createApp(options)`: アプリケーションインスタンスを作成します。 @@ -61,6 +69,7 @@ await app.mount(); ## リンク - [**ドキュメント**](./docs/) (アーキテクチャ, ロードマップ) +- [**Inline モード**](./docs/inline-mode.ja.md) - [**GitHub**](https://github.com/HALQME/btuin) (ソースコード, Issue) ## 言語 diff --git a/README.md b/README.md index 9f7fbab..7b6e1dd 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,14 @@ const app = createApp({ await app.mount(); ``` +## Inline Mode + +Render without clearing the whole screen: + +```ts +await app.mount({ inline: true, inlineCleanupOnExit: true }); +``` + ## API Overview - `createApp(options)`: Creates an application instance. @@ -61,6 +69,7 @@ await app.mount(); ## Links - [**Documentation**](./docs/) (Architecture, Roadmap) +- [**Inline Mode**](./docs/inline-mode.md) - [**GitHub**](https://github.com/HALQME/btuin) (Source Code, Issues) ## Language diff --git a/docs/architecture.ja.md b/docs/architecture.ja.md index 12822d7..2a7feb5 100644 --- a/docs/architecture.ja.md +++ b/docs/architecture.ja.md @@ -55,3 +55,33 @@ const myMockTerminalAdapter: Partial = { createApp({ terminal: myMockTerminalAdapter /* ... */ }); ``` + +# Inline モード + +Inline モードは、ターミナル全体を `clear` せずに現在のカーソル位置から UI を描画します。プロンプトや進捗表示など、スクロールバックを残したい用途に向いています。 + +## 基本 + +```ts +import { createApp, ui } from "btuin"; + +const app = createApp({ + init: () => ({}), + render: () => ui.Text("Hello (inline)"), +}); + +await app.mount({ inline: true }); +``` + +## 終了時のクリーンアップ + +- `inlineCleanupOnExit: false`(デフォルト): 最後に描画された UI をそのまま残します。 +- `inlineCleanupOnExit: true`: `exit()` / `unmount()` 時に inline UI を消します。 + +```ts +await app.mount({ inline: true, inlineCleanupOnExit: true }); +``` + +## stdout/stderr のパススルー + +デフォルトのターミナルアダプタで inline モードを使う場合、`process.stdout` / `process.stderr`(`console.log` 等)への出力は inline UI の上に表示され、出力後に UI が再描画されます。 diff --git a/docs/architecture.md b/docs/architecture.md index 61fecb4..2a601a3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,3 +55,33 @@ const myMockTerminalAdapter: Partial = { createApp({ terminal: myMockTerminalAdapter /* ... */ }); ``` + +# Inline Mode + +Inline mode renders the UI in-place (without clearing the whole terminal screen), making it suitable for prompts, progress indicators, or tools that should leave scrollback intact. + +## Basic usage + +```ts +import { createApp, ui } from "btuin"; + +const app = createApp({ + init: () => ({}), + render: () => ui.Text("Hello (inline)"), +}); + +await app.mount({ inline: true }); +``` + +## Cleanup behavior + +- `inlineCleanupOnExit: false` (default): leaves the last rendered UI in the terminal output. +- `inlineCleanupOnExit: true`: clears the inline UI on `exit()`/`unmount()`. + +```ts +await app.mount({ inline: true, inlineCleanupOnExit: true }); +``` + +## stdout/stderr passthrough + +When mounted in inline mode with the default terminal adapter, `process.stdout`/`process.stderr` output (including `console.log`) is printed above the inline UI and the UI is re-rendered afterwards. diff --git a/docs/roadmap.ja.md b/docs/roadmap.ja.md index 5a4158c..390f5ee 100644 --- a/docs/roadmap.ja.md +++ b/docs/roadmap.ja.md @@ -19,7 +19,7 @@ - [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` の解決が噛み合うことを自動チェック -- [ ] Inline モード +- [x] Inline モード - [ ] コンポーネント - [ ] `TextInput` を実用レベルへ(編集・カーソル移動・IME確定後の反映) - [ ] `ScrollView` / `ListView`(必要に応じて仮想スクロール、マウスホイール連動) diff --git a/docs/roadmap.md b/docs/roadmap.md index fc9b1e0..f68abdc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,7 +19,7 @@ - [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 -- [ ] Inline Mode +- [x] Inline Mode - [ ] Components - [ ] Bring `TextInput` to a practical level (editing, cursor movement, IME finalization) - [ ] `ScrollView` / `ListView` (virtual scrolling as needed, mouse wheel support) diff --git a/examples/inline-progress.ts b/examples/inline-progress.ts new file mode 100644 index 0000000..8c6234f --- /dev/null +++ b/examples/inline-progress.ts @@ -0,0 +1,31 @@ +import { createApp, ref } from "@/index"; +import { Text, VStack } from "@/view"; + +const app = createApp({ + init({ onKey, onTick, runtime, setExitOutput }) { + const progress = ref(0); + + onKey((k) => { + if (k.name === "q") runtime.exit(0); + }); + + onTick(() => { + progress.value = Math.min(100, progress.value + 1); + setExitOutput(`canceled (${progress.value}%)`); + if (progress.value >= 100) { + setExitOutput("done."); + runtime.exit(0); + } + }, 25); + + return { progress }; + }, + render({ progress }) { + return VStack([ + Text(`Progress: ${progress.value}%`), // + Text("Press q to quit"), + ]).width("100%"); + }, +}); + +await app.mount({ inline: true, inlineCleanupOnExit: true }); diff --git a/src/runtime/loop.ts b/src/runtime/loop.ts index 6b1d854..1b4ac17 100644 --- a/src/runtime/loop.ts +++ b/src/runtime/loop.ts @@ -14,6 +14,7 @@ export class LoopManager implements ILoopManager { private ctx: AppContext; private handleError: ReturnType; private cleanupTerminalFn: (() => void) | null = null; + private cleanupOutputListeners: (() => void)[] = []; constructor(context: AppContext, handleError: ReturnType) { this.ctx = context; @@ -49,6 +50,18 @@ export class LoopManager implements ILoopManager { } }); + const inline = + state.renderMode === "inline" + ? (() => { + const inline = createInlineDiffRenderer(); + this.cleanupTerminalFn = () => { + const seq = inline.cleanup(); + if (seq) terminal.write(seq); + }; + return inline; + })() + : null; + const renderer = createRenderer({ getSize, write: terminal.write, @@ -59,22 +72,61 @@ export class LoopManager implements ILoopManager { getState: () => ({}), handleError: this.handleError, profiler: profiler.isEnabled() ? profiler : undefined, - deps: - state.renderMode === "inline" - ? (() => { - const inline = createInlineDiffRenderer(); - this.cleanupTerminalFn = () => { - const seq = inline.cleanup(); - if (seq) terminal.write(seq); - }; - return { - renderDiff: inline.renderDiff, - layout: (root, containerSize) => layout(root, containerSize, { inline: true }), - }; - })() - : undefined, + deps: inline + ? { + renderDiff: inline.renderDiff, + layout: (root, containerSize) => layout(root, containerSize, { inline: true }), + } + : undefined, }); + if (inline) { + let uiSuspended = false; + let rerenderScheduled = false; + + const scheduleRerenderAfterOutput = () => { + if (rerenderScheduled) return; + rerenderScheduled = true; + queueMicrotask(() => { + rerenderScheduled = false; + if (!state.isMounted || state.isUnmounting) return; + if (state.renderMode !== "inline") return; + uiSuspended = false; + renderer.renderOnce(false); + }); + }; + + const clearUiOnce = () => { + if (uiSuspended) return; + uiSuspended = true; + const seq = inline.cleanup(); + if (seq) terminal.write(seq); + }; + + if (terminal.onStdout && terminal.writeStdout) { + this.cleanupOutputListeners.push( + terminal.onStdout((text) => { + if (!state.isMounted || state.isUnmounting) return; + if (state.renderMode !== "inline") return; + clearUiOnce(); + terminal.writeStdout?.(text); + scheduleRerenderAfterOutput(); + }), + ); + } + if (terminal.onStderr && terminal.writeStderr) { + this.cleanupOutputListeners.push( + terminal.onStderr((text) => { + if (!state.isMounted || state.isUnmounting) return; + if (state.renderMode !== "inline") return; + clearUiOnce(); + terminal.writeStderr?.(text); + scheduleRerenderAfterOutput(); + }), + ); + } + } + renderer.renderOnce(true); updaters.renderEffect(renderer.render()); @@ -110,6 +162,15 @@ export class LoopManager implements ILoopManager { stop(state.renderEffect); updaters.renderEffect(null); } + if (this.cleanupOutputListeners.length > 0) { + for (const dispose of this.cleanupOutputListeners.splice(0)) { + try { + dispose(); + } catch { + // ignore + } + } + } if (state.disposeResize) { state.disposeResize(); updaters.disposeResize(null); diff --git a/src/runtime/terminal-adapter.ts b/src/runtime/terminal-adapter.ts index d6b71b2..b0f5a0c 100644 --- a/src/runtime/terminal-adapter.ts +++ b/src/runtime/terminal-adapter.ts @@ -1,6 +1,7 @@ import type { KeyEvent } from "../terminal/types/key-event"; import * as terminal from "../terminal"; import type { InputParser } from "../terminal/parser/types"; +import { bypassStderrWrite, bypassStdoutWrite, onStderr, onStdout } from "../terminal/capture"; export interface TerminalAdapter { setupRawMode(): void; @@ -11,6 +12,24 @@ export interface TerminalAdapter { patchConsole(): () => void; startCapture(): void; stopCapture(): void; + /** + * Subscribe to captured stdout writes (when `startCapture()` is active). + * Used by inline mode to temporarily clear the UI, print output, then re-render. + */ + onStdout?(handler: (text: string) => void): () => void; + /** + * Subscribe to captured stderr writes (when `startCapture()` is active). + * Used by inline mode to temporarily clear the UI, print output, then re-render. + */ + onStderr?(handler: (text: string) => void): () => void; + /** + * Write to the real stdout even when capture is enabled. + */ + writeStdout?(text: string): void; + /** + * Write to the real stderr even when capture is enabled. + */ + writeStderr?(text: string): void; onKey(handler: (event: KeyEvent) => void): void; getTerminalSize(): { rows: number; cols: number }; disposeSingletonCapture(): void; @@ -38,6 +57,14 @@ export function createDefaultTerminalAdapter( patchConsole: terminal.patchConsole, startCapture: terminal.startCapture, stopCapture: terminal.stopCapture, + onStdout, + onStderr, + writeStdout: (text) => { + bypassStdoutWrite(text); + }, + writeStderr: (text) => { + bypassStderrWrite(text); + }, onKey: terminal.onKey, getTerminalSize: terminal.getTerminalSize, disposeSingletonCapture: terminal.disposeSingletonCapture, diff --git a/tests/units/runtime/inline-output.test.ts b/tests/units/runtime/inline-output.test.ts new file mode 100644 index 0000000..1ebb775 --- /dev/null +++ b/tests/units/runtime/inline-output.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, afterEach, beforeAll } from "bun:test"; +import { Block, Text } from "@/view/primitives"; +import type { AppType as App, TerminalAdapter } from "@/types"; + +describe("inline mode output passthrough", () => { + let appInstance: App; + let app: typeof import("@/runtime/app").app; + + afterEach(() => { + appInstance?.unmount(); + }); + + beforeAll(async () => { + ({ app } = await import("@/runtime/app")); + }); + + it("clears UI, prints stdout, then re-renders", async () => { + const events: Array<{ type: "ui" | "stdout"; text: string }> = []; + let stdoutListener: ((text: string) => void) | undefined; + + const terminal: TerminalAdapter = { + setBracketedPaste: () => {}, + setupRawMode: () => {}, + clearScreen: () => {}, + moveCursor: () => {}, + cleanupWithoutClear: () => {}, + patchConsole: () => () => {}, + startCapture: () => {}, + stopCapture: () => {}, + onStdout: (handler) => { + stdoutListener = handler; + return () => { + if (stdoutListener === handler) stdoutListener = undefined; + }; + }, + writeStdout: (text) => { + events.push({ type: "stdout", text }); + }, + onKey: () => {}, + getTerminalSize: () => ({ rows: 24, cols: 80 }), + disposeSingletonCapture: () => {}, + write: (output: string) => { + events.push({ type: "ui", text: output }); + }, + }; + + const platform = { + onStdoutResize: () => () => {}, + onExit: () => {}, + onSigint: () => {}, + onSigterm: () => {}, + exit: () => {}, + }; + + appInstance = app({ + terminal, + platform, + init() { + return {}; + }, + render() { + return Block(Text("A"), Text("B")).direction("column"); + }, + }); + + await appInstance.mount({ rows: 3, cols: 5, inline: true }); + expect(typeof stdoutListener).toBe("function"); + + const before = events.length; + stdoutListener!("hello\n"); + + expect(events.slice(before, before + 2).map((e) => e.type)).toEqual(["ui", "stdout"]); + expect(events[before]?.text.length).toBeGreaterThan(0); + expect(events[before + 1]?.text).toBe("hello\n"); + + await Promise.resolve(); + const after = events.length; + expect(after).toBeGreaterThan(before + 2); + expect(events.slice(before + 2).some((e) => e.type === "ui")).toBe(true); + }); + + it("coalesces multiple stdout writes into a single clear+rerender cycle", async () => { + const events: Array<{ type: "ui" | "stdout"; text: string }> = []; + let stdoutListener: ((text: string) => void) | undefined; + + const terminal: TerminalAdapter = { + setBracketedPaste: () => {}, + setupRawMode: () => {}, + clearScreen: () => {}, + moveCursor: () => {}, + cleanupWithoutClear: () => {}, + patchConsole: () => () => {}, + startCapture: () => {}, + stopCapture: () => {}, + onStdout: (handler) => { + stdoutListener = handler; + return () => { + if (stdoutListener === handler) stdoutListener = undefined; + }; + }, + writeStdout: (text) => { + events.push({ type: "stdout", text }); + }, + onKey: () => {}, + getTerminalSize: () => ({ rows: 24, cols: 80 }), + disposeSingletonCapture: () => {}, + write: (output: string) => { + events.push({ type: "ui", text: output }); + }, + }; + + const platform = { + onStdoutResize: () => () => {}, + onExit: () => {}, + onSigint: () => {}, + onSigterm: () => {}, + exit: () => {}, + }; + + appInstance = app({ + terminal, + platform, + init() { + return {}; + }, + render() { + return Block(Text("A"), Text("B")).direction("column"); + }, + }); + + await appInstance.mount({ rows: 3, cols: 5, inline: true }); + expect(typeof stdoutListener).toBe("function"); + + const before = events.length; + stdoutListener!("a"); + stdoutListener!("b"); + stdoutListener!("c\n"); + + const slice = events.slice(before); + const uiClears = slice.filter((e) => e.type === "ui"); + const stdoutWrites = slice.filter((e) => e.type === "stdout"); + expect(uiClears.length).toBe(1); + expect(stdoutWrites.map((e) => e.text).join("")).toBe("abc\n"); + + await Promise.resolve(); + const afterSlice = events.slice(before); + const uiWritesTotal = afterSlice.filter((e) => e.type === "ui").length; + expect(uiWritesTotal).toBe(2); + }); +});