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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ const app = createApp({
await app.mount();
```

## Inline モード

ターミナル全体を消さずに描画します:

```ts
await app.mount({ inline: true, inlineCleanupOnExit: true });
```

## API概要

- `createApp(options)`: アプリケーションインスタンスを作成します。
Expand All @@ -61,6 +69,7 @@ await app.mount();
## リンク

- [**ドキュメント**](./docs/) (アーキテクチャ, ロードマップ)
- [**Inline モード**](./docs/inline-mode.ja.md)
- [**GitHub**](https://github.com/HALQME/btuin) (ソースコード, Issue)

## 言語
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
30 changes: 30 additions & 0 deletions docs/architecture.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,33 @@ const myMockTerminalAdapter: Partial<TerminalAdapter> = {

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 が再描画されます。
30 changes: 30 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,33 @@ const myMockTerminalAdapter: Partial<TerminalAdapter> = {

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.
2 changes: 1 addition & 1 deletion docs/roadmap.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`(必要に応じて仮想スクロール、マウスホイール連動)
Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions examples/inline-progress.ts
Original file line number Diff line number Diff line change
@@ -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 });
89 changes: 75 additions & 14 deletions src/runtime/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export class LoopManager implements ILoopManager {
private ctx: AppContext;
private handleError: ReturnType<typeof createErrorHandler>;
private cleanupTerminalFn: (() => void) | null = null;
private cleanupOutputListeners: (() => void)[] = [];

constructor(context: AppContext, handleError: ReturnType<typeof createErrorHandler>) {
this.ctx = context;
Expand Down Expand Up @@ -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,
Expand All @@ -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());

Expand Down Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions src/runtime/terminal-adapter.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading