feat(coding-agent): enabled component-based chat rendering with managed block lifecycles
- Added ChatBlock and ChatBlockHost with mount, finish, and dispose lifecycle callbacks. - Added InteractiveModeContext.present and resetTranscript APIs and used them to mount/repaint blocks. - Reworked controller rendering paths to emit command, event, extension, and selector outputs via ctx.present. - Implemented component and container dispose hooks in tui so loaders and child blocks cleanup timers/effects.
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Added a `ChatBlock` transcript primitive (`modes/components/chat-block.ts`) and a single `ctx.present(...)` sink (with `ctx.resetTranscript()`) so chat output is mounted in one place instead of the repeated `chatContainer.addChild(...)` + `ui.requestRender()` pattern scattered across controllers. `ChatBlock` carries a React/Svelte-style lifecycle — `onMount` starts effects, `onCleanup` registers teardown, `finish()` self-completes (stops timers and freezes the block at its final content), and `dispose()`/`resetTranscript()` tears everything down — so animated blocks own their own resources instead of leaking `setInterval`/`requestRender` bookkeeping into callers. The MCP "Connecting…" spinner is now such a block.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the directory grouping for `find`, `search`, `ast_grep`, `ast_edit`, and `lsp` diagnostics from a single flat `# dir/` heading per immediate directory to a multi-level tree that folds the common path prefix into one heading. Previously every group repeated the full directory path — so results rooted outside cwd printed the absolute prefix (e.g. `/Users/me/proj/`) on every heading and nested directories were never collapsed. Now a single-child directory chain folds into one heading (`# packages/pkg/src/`, including an absolute root for out-of-cwd results), subdirectories nest one `#` deeper (`## nested/` → `### child.ts`), and each directory's own files are listed before its subdirectories. TUI hyperlink reconstruction tracks the nested directory stack across the whole output so file and code-frame links keep resolving to the correct absolute paths.
|
||||
- Changed the plan-mode approval surface from an inline transcript block plus a separate bottom selector into a single fullscreen overlay (like `/copy`). The overlay owns its entire content via `ScrollView`: the plan renders once as Markdown and scrolls inside the outlined box (PageUp/PageDown, g/G), with the approval options and the model-tier slider beneath it. ↑/↓ move the option cursor, ←/→ drive the slider, Enter confirms, the external-editor key opens the plan, and Esc cancels — so a tall plan no longer competes with the selector for vertical space or clips its head on ED3-risk terminals.
|
||||
- Changed the interactive controllers (command, MCP, selector, extension-UI, event), debug panels, and the status/error/warning helpers to render chat output through `ctx.present(...)` instead of appending to `chatContainer` and calling `ui.requestRender()` directly; transcript rebuilds dispose live blocks via `ctx.resetTranscript()` so animated blocks' timers stop on reset.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -13,6 +18,7 @@
|
||||
- Fixed `omp dry-balance --bench` flooding the terminal with staircased, duplicated spinner/status lines (and an indented summary) when the tty has ONLCR/OPOST disabled (raw mode). The interactive progress region separated rows with a bare LF and repositioned with a column-preserving `\x1b[<n>A` cursor-up, both of which only land at column 0 when the terminal translates LF→CRLF; with that translation off, every 80 ms redraw cascaded down and to the right into scrollback. The live region now carriage-returns before every cleared row, terminates each row with CRLF, and caps each row to the terminal width so a wrapped line cannot desync the cursor-up from the logical line count.
|
||||
- Fixed inconsistent vertical spacing between transcript blocks: some blocks (tool results from `search`/`find` and other renderer-backed tools) rendered with a doubled gap (a leading `Spacer` plus the content box's own `paddingY`), while others (the grouped `read` card, file-mention lists, IRC cards) rendered with no gap at all. Vertical spacing is now owned entirely by the chat renderer: `TranscriptContainer` strips each block's plain-blank top/bottom edges and inserts exactly one blank line between consecutive blocks, so every block is separated by a single consistent gap regardless of which component produced it. Individual components (assistant/user/tool/read-group/bash/eval/skill/custom/hook/compaction/branch/todo-reminder/plan-review messages) no longer emit their own leading `Spacer`/`paddingY` for separation, and multi-row groups (IRC cards, file-mention lists, completed-job batches, and the bordered command/`/changelog`/`/context`/version/OAuth/debug panels) are wrapped as single `TranscriptBlock` children so the renderer spaces them as one unit. Background-colored box padding is preserved as block-internal design.
|
||||
- Fixed `resolve` with `action: "discard"` surfacing a hard `isError` "No pending action to resolve" failure to the model when the agent asked to cancel a staged action (e.g. an `ast_edit` preview) but nothing was pending. A discard is a request to reach the "no staged change" end-state, which already holds in that case, so it is now honored as a successful cancellation (`"Nothing to discard; no pending action remains."` with `details.action: "discard"`) instead of an error. `action: "apply"` with no pending action still errors.
|
||||
- Fixed the collapsed tool-output expand hint rendering double brackets (e.g. `((Ctrl+O for more))`) — the `EXPAND_HINT` text already carried its own parentheses and then `formatExpandHint` wrapped it again with the theme's bracket glyphs. The hint now resolves the key actually bound to `app.tools.expand` at render time and reads `⟨<key>: Expand⟩` (e.g. `⟨Ctrl+O: Expand⟩`), so a single bracket pair surrounds it and a user remap of the expand keybinding is reflected instead of a hard-coded `Ctrl+O`.
|
||||
|
||||
## [15.10.0] - 2026-06-06
|
||||
|
||||
|
||||
@@ -157,8 +157,7 @@ export class DebugSelectorComponent extends Container {
|
||||
block.addChild(
|
||||
new Text(theme.fg("muted", "Reproduce the performance issue, then press Enter to stop profiling."), 1, 0),
|
||||
);
|
||||
this.ctx.chatContainer.addChild(block);
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present(block);
|
||||
|
||||
// Wait for Enter keypress
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
@@ -207,14 +206,12 @@ export class DebugSelectorComponent extends Container {
|
||||
block.addChild(new Text(theme.fg("success", `${theme.status.success} Performance report saved`), 1, 0));
|
||||
block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
|
||||
block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
|
||||
this.ctx.chatContainer.addChild(block);
|
||||
this.ctx.present(block);
|
||||
} catch (err) {
|
||||
loader.stop();
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.showError(`Failed to create report: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
async #handleWorkReport(): Promise<void> {
|
||||
@@ -232,15 +229,13 @@ export class DebugSelectorComponent extends Container {
|
||||
|
||||
openPath(tmpPath);
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
new Text(theme.fg("dim", `Opened flamegraph (${workProfile.sampleCount} samples)`), 1, 0),
|
||||
);
|
||||
]);
|
||||
} catch (err) {
|
||||
this.ctx.showError(`Failed to open profile: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
async #handleDumpReport(): Promise<void> {
|
||||
@@ -267,14 +262,12 @@ export class DebugSelectorComponent extends Container {
|
||||
block.addChild(new Text(theme.fg("success", `${theme.status.success} Report bundle saved`), 1, 0));
|
||||
block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
|
||||
block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
|
||||
this.ctx.chatContainer.addChild(block);
|
||||
this.ctx.present(block);
|
||||
} catch (err) {
|
||||
loader.stop();
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.showError(`Failed to create report: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
async #handleMemoryReport(): Promise<void> {
|
||||
@@ -305,14 +298,12 @@ export class DebugSelectorComponent extends Container {
|
||||
block.addChild(new Text(theme.fg("success", `${theme.status.success} Memory report saved`), 1, 0));
|
||||
block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
|
||||
block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
|
||||
this.ctx.chatContainer.addChild(block);
|
||||
this.ctx.present(block);
|
||||
} catch (err) {
|
||||
loader.stop();
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.showError(`Failed to create report: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
async #handleViewLogs(): Promise<void> {
|
||||
@@ -368,12 +359,10 @@ export class DebugSelectorComponent extends Container {
|
||||
block.addChild(new DynamicBorder());
|
||||
block.addChild(new Text(formatted, 1, 0));
|
||||
block.addChild(new DynamicBorder());
|
||||
this.ctx.chatContainer.addChild(block);
|
||||
this.ctx.present(block);
|
||||
} catch (err) {
|
||||
this.ctx.showError(`Failed to collect system info: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
async #handleViewTerminalState(): Promise<void> {
|
||||
@@ -388,8 +377,7 @@ export class DebugSelectorComponent extends Container {
|
||||
block.addChild(new DynamicBorder());
|
||||
block.addChild(new Text(formatted, 1, 0));
|
||||
block.addChild(new DynamicBorder());
|
||||
this.ctx.chatContainer.addChild(block);
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present(block);
|
||||
}
|
||||
|
||||
async #handleViewProtocols(): Promise<void> {
|
||||
@@ -408,15 +396,14 @@ export class DebugSelectorComponent extends Container {
|
||||
TERMINAL.sendNotification(notification);
|
||||
}
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
new ProtocolProbeComponent({
|
||||
image: buildSampleImage(),
|
||||
imageBudget: this.ctx.ui.imageBudget,
|
||||
notificationSuppressed: suppressed,
|
||||
}),
|
||||
);
|
||||
this.ctx.ui.requestRender();
|
||||
]);
|
||||
}
|
||||
|
||||
async #handleTranscriptExport(): Promise<void> {
|
||||
@@ -488,21 +475,19 @@ export class DebugSelectorComponent extends Container {
|
||||
loader.stop();
|
||||
this.ctx.statusContainer.clear();
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
new Text(
|
||||
theme.fg("success", `${theme.status.success} Cleared ${result.removed} artifact directories`),
|
||||
1,
|
||||
0,
|
||||
),
|
||||
);
|
||||
]);
|
||||
} catch (err) {
|
||||
loader.stop();
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.showError(`Failed to clear cache: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
#getResolvedSettings(): Record<string, unknown> {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Container } from "@oh-my-pi/pi-tui";
|
||||
|
||||
/**
|
||||
* Capabilities a mounted {@link ChatBlock} may use against its host transcript.
|
||||
* Kept minimal so blocks never reach into the full TUI/InteractiveMode surface.
|
||||
*/
|
||||
export interface ChatBlockHost {
|
||||
/** Schedule a repaint of the transcript. */
|
||||
requestRender(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle-aware transcript block — the "return a block, let the host mount it"
|
||||
* primitive, modelled on React/Svelte component lifecycles.
|
||||
*
|
||||
* Producers build and return a `ChatBlock` instead of poking `chatContainer` and
|
||||
* `ui.requestRender()` directly. The host (`ctx.present`) appends it and calls
|
||||
* {@link mount}, which runs {@link onMount}; effects started there register
|
||||
* teardown via {@link onCleanup}. The block repaints through {@link requestRender}
|
||||
* — never touching the TUI — and tears down exactly once on {@link finish}
|
||||
* (self-complete: stop the animation, keep the final frame in the transcript) or
|
||||
* {@link dispose} (host discards it, e.g. a transcript reset).
|
||||
*
|
||||
* While mounted and unfinished a block reports `isTranscriptBlockFinalized() ===
|
||||
* false` so {@link "../components/transcript-container".TranscriptContainer}
|
||||
* keeps it in the live, repaintable region on ED3-risk terminals; after
|
||||
* `finish()`/`dispose()` it reports `true` and freezes at its final content.
|
||||
*/
|
||||
export abstract class ChatBlock extends Container {
|
||||
#host: ChatBlockHost | undefined;
|
||||
#cleanups: Array<() => void> = [];
|
||||
#active = false;
|
||||
#disposed = false;
|
||||
|
||||
/**
|
||||
* Run setup after the block is in the transcript: start timers/subscriptions
|
||||
* and register their teardown with {@link onCleanup}. Default: no-op (a block
|
||||
* whose content is fixed at construction needs no mount work).
|
||||
*/
|
||||
protected onMount(): void {}
|
||||
|
||||
/**
|
||||
* Register a teardown to run on {@link finish}/{@link dispose}, à la a
|
||||
* `useEffect` cleanup. If the block is already disposed the cleanup runs
|
||||
* immediately so callers never leak.
|
||||
*/
|
||||
protected onCleanup(cleanup: () => void): void {
|
||||
if (this.#disposed) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
this.#cleanups.push(cleanup);
|
||||
}
|
||||
|
||||
/** Ask the host to repaint. No-op before mount or after dispose. */
|
||||
protected requestRender(): void {
|
||||
this.#host?.requestRender();
|
||||
}
|
||||
|
||||
/** True between {@link mount} and {@link finish}/{@link dispose}. */
|
||||
protected get active(): boolean {
|
||||
return this.#active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-only: attach the host and run {@link onMount}. Idempotent — a second
|
||||
* call (e.g. a transcript rebuild that re-presents the same instance) is a
|
||||
* no-op.
|
||||
*/
|
||||
mount(host: ChatBlockHost): void {
|
||||
if (this.#host || this.#disposed) return;
|
||||
this.#host = host;
|
||||
this.#active = true;
|
||||
this.onMount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-complete: stop ongoing effects and freeze the block at its current
|
||||
* content, leaving it rendered in the transcript. Use when the operation the
|
||||
* block represents finishes (connection resolved, download done).
|
||||
*/
|
||||
finish(): void {
|
||||
if (!this.#active) return;
|
||||
this.#active = false;
|
||||
this.#runCleanups();
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-only teardown: release everything and propagate to children. Called
|
||||
* when the host permanently discards the block (transcript reset). Idempotent.
|
||||
*/
|
||||
override dispose(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
this.#active = false;
|
||||
this.#runCleanups();
|
||||
super.dispose();
|
||||
this.#host = undefined;
|
||||
}
|
||||
|
||||
/** Live blocks stay repaintable; finished/disposed ones may freeze. */
|
||||
isTranscriptBlockFinalized(): boolean {
|
||||
return !this.#active;
|
||||
}
|
||||
|
||||
#runCleanups(): void {
|
||||
const cleanups = this.#cleanups.splice(0);
|
||||
for (const cleanup of cleanups) cleanup();
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,5 @@ export function showCommandMessage(ctx: InteractiveModeContext, text: string): v
|
||||
block.addChild(new DynamicBorder());
|
||||
block.addChild(new Text(text, 1, 1));
|
||||
block.addChild(new DynamicBorder());
|
||||
ctx.chatContainer.addChild(block);
|
||||
ctx.ui.requestRender();
|
||||
ctx.present(block);
|
||||
}
|
||||
|
||||
@@ -53,8 +53,7 @@ function showMarkdownPanel(ctx: InteractiveModeContext, title: string, markdown:
|
||||
block.addChild(new Spacer(1));
|
||||
block.addChild(new Markdown(markdown.trim(), 1, 1, getMarkdownTheme()));
|
||||
block.addChild(new DynamicBorder());
|
||||
ctx.chatContainer.addChild(block);
|
||||
ctx.ui.requestRender();
|
||||
ctx.present(block);
|
||||
}
|
||||
|
||||
export class CommandController {
|
||||
@@ -336,9 +335,7 @@ export class CommandController {
|
||||
}
|
||||
}
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(new Text(info, 1, 0));
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present([new Spacer(1), new Text(info, 1, 0)]);
|
||||
}
|
||||
|
||||
async handleJobsCommand(): Promise<void> {
|
||||
@@ -355,9 +352,7 @@ export class CommandController {
|
||||
|
||||
if (snapshot.running.length === 0 && snapshot.recent.length === 0) {
|
||||
info += `\n${theme.fg("dim", "No async jobs yet.")}\n`;
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(new Text(info, 1, 0));
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present([new Spacer(1), new Text(info, 1, 0)]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -377,9 +372,7 @@ export class CommandController {
|
||||
}
|
||||
}
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(new Text(info.trimEnd(), 1, 0));
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present([new Spacer(1), new Text(info.trimEnd(), 1, 0)]);
|
||||
}
|
||||
|
||||
async handleUsageCommand(reports?: UsageReport[] | null): Promise<void> {
|
||||
@@ -405,9 +398,7 @@ export class CommandController {
|
||||
|
||||
const availableWidth = Math.max(40, (this.ctx.ui.terminal.columns ?? 100) - 2);
|
||||
const output = renderUsageReports(usageReports, theme, Date.now(), availableWidth);
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(new Text(output, 1, 0));
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present([new Spacer(1), new Text(output, 1, 0)]);
|
||||
}
|
||||
|
||||
async handleChangelogCommand(showFull = false): Promise<void> {
|
||||
@@ -434,8 +425,7 @@ export class CommandController {
|
||||
block.addChild(new Spacer(1));
|
||||
block.addChild(new Markdown(changelogMarkdown + hint, 1, 1, getMarkdownTheme()));
|
||||
block.addChild(new DynamicBorder());
|
||||
this.ctx.chatContainer.addChild(block);
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present(block);
|
||||
}
|
||||
|
||||
handleHotkeysCommand(): void {
|
||||
@@ -461,8 +451,7 @@ export class CommandController {
|
||||
block.addChild(new Spacer(1));
|
||||
block.addChild(new Text(output, 1, 0));
|
||||
block.addChild(new DynamicBorder());
|
||||
this.ctx.chatContainer.addChild(block);
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present(block);
|
||||
}
|
||||
|
||||
async handleMemoryCommand(text: string): Promise<void> {
|
||||
@@ -483,8 +472,7 @@ export class CommandController {
|
||||
block.addChild(new Spacer(1));
|
||||
block.addChild(new Markdown(payload, 1, 1, getMarkdownTheme()));
|
||||
block.addChild(new DynamicBorder());
|
||||
this.ctx.chatContainer.addChild(block);
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present(block);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -805,8 +793,7 @@ export class CommandController {
|
||||
this.ctx.streamingMessage = undefined;
|
||||
this.ctx.pendingTools.clear();
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(new Text(`${theme.fg("accent", `${theme.status.success} ${label}`)}`, 1, 1));
|
||||
this.ctx.present([new Spacer(1), new Text(`${theme.fg("accent", `${theme.status.success} ${label}`)}`, 1, 1)]);
|
||||
await this.ctx.reloadTodos();
|
||||
this.ctx.ui.requestRender(true, { clearScrollback: true });
|
||||
}
|
||||
@@ -845,11 +832,10 @@ export class CommandController {
|
||||
|
||||
const sessionFile = this.ctx.session.sessionFile;
|
||||
const shortPath = sessionFile ? sessionFile.split("/").pop() : "new session";
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
new Text(`${theme.fg("accent", `${theme.status.success} Session forked to ${shortPath}`)}`, 1, 1),
|
||||
);
|
||||
this.ctx.ui.requestRender();
|
||||
]);
|
||||
}
|
||||
|
||||
async handleMoveCommand(targetPath: string): Promise<void> {
|
||||
@@ -883,11 +869,10 @@ export class CommandController {
|
||||
await this.ctx.sessionManager.moveTo(resolvedPath);
|
||||
await this.ctx.applyCwdChange(resolvedPath);
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
new Text(`${theme.fg("accent", `${theme.status.success} Session moved to ${resolvedPath}`)}`, 1, 1),
|
||||
);
|
||||
this.ctx.ui.requestRender();
|
||||
]);
|
||||
} catch (err) {
|
||||
this.ctx.showError(`Move failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
@@ -918,7 +903,7 @@ export class CommandController {
|
||||
this.ctx.pendingMessagesContainer.addChild(this.ctx.bashComponent);
|
||||
this.ctx.pendingBashComponents.push(this.ctx.bashComponent);
|
||||
} else {
|
||||
this.ctx.chatContainer.addChild(this.ctx.bashComponent);
|
||||
this.ctx.present(this.ctx.bashComponent);
|
||||
}
|
||||
this.ctx.ui.requestRender();
|
||||
|
||||
@@ -959,7 +944,7 @@ export class CommandController {
|
||||
this.ctx.pendingMessagesContainer.addChild(this.ctx.pythonComponent);
|
||||
this.ctx.pendingPythonComponents.push(this.ctx.pythonComponent);
|
||||
} else {
|
||||
this.ctx.chatContainer.addChild(this.ctx.pythonComponent);
|
||||
this.ctx.present(this.ctx.pythonComponent);
|
||||
}
|
||||
this.ctx.ui.requestRender();
|
||||
|
||||
@@ -1148,10 +1133,10 @@ export class CommandController {
|
||||
this.ctx.updateEditorBorderColor();
|
||||
await this.ctx.reloadTodos();
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
new Text(`${theme.fg("accent", `${theme.status.success} New session started with handoff context`)}`, 1, 1),
|
||||
);
|
||||
]);
|
||||
if (result.savedPath) {
|
||||
this.ctx.showStatus(`Handoff document saved to: ${result.savedPath}`);
|
||||
}
|
||||
|
||||
@@ -830,14 +830,12 @@ export class EventController {
|
||||
async #handleTtsrTriggered(event: Extract<AgentSessionEvent, { type: "ttsr_triggered" }>): Promise<void> {
|
||||
const component = new TtsrNotificationComponent(event.rules);
|
||||
component.setExpanded(this.ctx.toolOutputExpanded);
|
||||
this.ctx.chatContainer.addChild(component);
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present(component);
|
||||
}
|
||||
|
||||
async #handleTodoReminder(event: Extract<AgentSessionEvent, { type: "todo_reminder" }>): Promise<void> {
|
||||
const component = new TodoReminderComponent(event.todos, event.attempt, event.maxAttempts);
|
||||
this.ctx.chatContainer.addChild(component);
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present(component);
|
||||
}
|
||||
|
||||
async #handleTodoAutoClear(_event: Extract<AgentSessionEvent, { type: "todo_auto_clear" }>): Promise<void> {
|
||||
|
||||
@@ -176,10 +176,10 @@ export class ExtensionUiController {
|
||||
this.ctx.streamingMessage = undefined;
|
||||
this.ctx.pendingTools.clear();
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
|
||||
);
|
||||
]);
|
||||
await this.ctx.reloadTodos();
|
||||
this.ctx.ui.requestRender(true, { clearScrollback: true });
|
||||
|
||||
@@ -415,10 +415,10 @@ export class ExtensionUiController {
|
||||
this.ctx.streamingMessage = undefined;
|
||||
this.ctx.pendingTools.clear();
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
|
||||
);
|
||||
]);
|
||||
await this.ctx.reloadTodos();
|
||||
this.ctx.ui.requestRender(true, { clearScrollback: true });
|
||||
|
||||
@@ -562,8 +562,7 @@ export class ExtensionUiController {
|
||||
return;
|
||||
}
|
||||
const errorText = new Text(theme.fg("error", `Tool "${toolName}" error: ${error}`), 1, 0);
|
||||
this.ctx.chatContainer.addChild(errorText);
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present(errorText);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -860,8 +859,7 @@ export class ExtensionUiController {
|
||||
|
||||
showExtensionError(extensionPath: string, error: string): void {
|
||||
const errorText = new Text(theme.fg("error", `Extension "${extensionPath}" error: ${error}`), 1, 0);
|
||||
this.ctx.chatContainer.addChild(errorText);
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present(errorText);
|
||||
}
|
||||
async #handleInteractiveCompact(instructionsOrOptions: string | CompactOptions | undefined): Promise<void> {
|
||||
if (this.ctx.isBackgrounded) {
|
||||
|
||||
@@ -37,6 +37,7 @@ import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../
|
||||
import type { OAuthCredential } from "../../session/auth-storage";
|
||||
import { shortenPath } from "../../tools/render-utils";
|
||||
import { openPath } from "../../utils/open";
|
||||
import { ChatBlock } from "../components/chat-block";
|
||||
import { MCPAddWizard } from "../components/mcp-add-wizard";
|
||||
import { TranscriptBlock } from "../components/transcript-container";
|
||||
import { parseCommandArgs } from "../shared";
|
||||
@@ -50,6 +51,42 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string)
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated "Connecting to …" transcript block. Owns its spinner interval: it
|
||||
* starts on mount and is cleared on {@link ChatBlock.finish}/dispose, so callers
|
||||
* never juggle `setInterval`/`clearInterval` or `requestRender` by hand.
|
||||
*/
|
||||
class McpConnectingBlock extends ChatBlock {
|
||||
readonly #text: Text;
|
||||
|
||||
constructor(private readonly serverName: string) {
|
||||
super();
|
||||
this.addChild(new Spacer(1));
|
||||
const frame = theme.spinnerFrames[0] ?? "|";
|
||||
this.#text = new Text(theme.fg("muted", `${frame} Connecting to "${serverName}"...`), 1, 0);
|
||||
this.addChild(this.#text);
|
||||
}
|
||||
|
||||
protected override onMount(): void {
|
||||
const frames = theme.spinnerFrames;
|
||||
let frame = 0;
|
||||
const interval = setInterval(() => {
|
||||
frame++;
|
||||
this.#text.setText(
|
||||
theme.fg("muted", `${frames[frame % frames.length] ?? "|"} Connecting to "${this.serverName}"...`),
|
||||
);
|
||||
this.requestRender();
|
||||
}, 80);
|
||||
this.onCleanup(() => clearInterval(interval));
|
||||
}
|
||||
|
||||
/** Replace the spinner line with a terminal status; pair with {@link finish}. */
|
||||
setStatus(text: string): void {
|
||||
this.#text.setText(text);
|
||||
this.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of {@link MCPCommandController}'s OAuth handler.
|
||||
*
|
||||
@@ -550,7 +587,7 @@ export class MCPCommandController {
|
||||
onAuth: (info: { url: string; instructions?: string }) => {
|
||||
// Show auth URL prominently in chat as one block
|
||||
const block = new TranscriptBlock();
|
||||
this.ctx.chatContainer.addChild(block);
|
||||
this.ctx.present(block);
|
||||
block.addChild(new Text(theme.fg("accent", "━━━ OAuth Authorization Required ━━━"), 1, 0));
|
||||
block.addChild(new Spacer(1));
|
||||
block.addChild(new Text(theme.fg("muted", "Preparing browser authorization..."), 1, 0));
|
||||
@@ -564,7 +601,6 @@ export class MCPCommandController {
|
||||
);
|
||||
block.addChild(new Spacer(1));
|
||||
block.addChild(new Text(theme.fg("accent", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"), 1, 0));
|
||||
this.ctx.ui.requestRender();
|
||||
// Try to open browser automatically
|
||||
try {
|
||||
openPath(info.url);
|
||||
@@ -587,9 +623,7 @@ export class MCPCommandController {
|
||||
}
|
||||
},
|
||||
onProgress: (message: string) => {
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(new Text(theme.fg("muted", message), 1, 0));
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present([new Spacer(1), new Text(theme.fg("muted", message), 1, 0)]);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -597,9 +631,10 @@ export class MCPCommandController {
|
||||
// Execute OAuth flow with 5 minute timeout
|
||||
const credentials = await withTimeout(flow.login(), 5 * 60 * 1000, "OAuth flow timed out after 5 minutes");
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(new Text(theme.fg("success", "✓ Authorization completed in browser."), 1, 0));
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
new Text(theme.fg("success", "✓ Authorization completed in browser."), 1, 0),
|
||||
]);
|
||||
|
||||
// Generate a unique credential ID
|
||||
const credentialId = `mcp_oauth_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
@@ -752,19 +787,8 @@ export class MCPCommandController {
|
||||
): Promise<"connected" | "connecting" | "disconnected"> {
|
||||
if (!this.ctx.mcpManager) return "disconnected";
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
const frames = theme.spinnerFrames;
|
||||
const initialFrame = frames[0] ?? "|";
|
||||
const statusText = new Text(theme.fg("muted", `${initialFrame} Connecting to "${name}"...`), 1, 0);
|
||||
this.ctx.chatContainer.addChild(statusText);
|
||||
this.ctx.ui.requestRender();
|
||||
|
||||
let frame = 0;
|
||||
const interval = setInterval(() => {
|
||||
statusText.setText(theme.fg("muted", `${frames[frame % frames.length]} Connecting to "${name}"...`));
|
||||
frame++;
|
||||
this.ctx.ui.requestRender();
|
||||
}, 80);
|
||||
const block = new McpConnectingBlock(name);
|
||||
this.ctx.present(block);
|
||||
|
||||
try {
|
||||
try {
|
||||
@@ -778,20 +802,19 @@ export class MCPCommandController {
|
||||
await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools());
|
||||
}
|
||||
if (state === "connected") {
|
||||
statusText.setText(theme.fg("success", `✓ Connected to "${name}"`));
|
||||
block.setStatus(theme.fg("success", `✓ Connected to "${name}"`));
|
||||
} else if (state === "connecting") {
|
||||
statusText.setText(theme.fg("muted", `◌ "${name}" is still connecting...`));
|
||||
block.setStatus(theme.fg("muted", `◌ "${name}" is still connecting...`));
|
||||
} else {
|
||||
statusText.setText(
|
||||
block.setStatus(
|
||||
options?.suppressDisconnectedWarning
|
||||
? theme.fg("muted", `◌ Connection check complete for "${name}"`)
|
||||
: theme.fg("warning", `⚠ Could not connect to "${name}" yet`),
|
||||
);
|
||||
}
|
||||
this.ctx.ui.requestRender();
|
||||
return state;
|
||||
} finally {
|
||||
clearInterval(interval);
|
||||
block.finish();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -933,7 +933,6 @@ export class SelectorController {
|
||||
await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
|
||||
onAuth: (info: { url: string; instructions?: string }) => {
|
||||
const block = new TranscriptBlock();
|
||||
this.ctx.chatContainer.addChild(block);
|
||||
block.addChild(new Text(theme.fg("dim", info.url), 1, 0));
|
||||
const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
|
||||
block.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
|
||||
@@ -945,17 +944,16 @@ export class SelectorController {
|
||||
block.addChild(new Spacer(1));
|
||||
block.addChild(new Text(theme.fg("dim", MANUAL_LOGIN_TIP), 1, 0));
|
||||
}
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present(block);
|
||||
this.ctx.openInBrowser(info.url);
|
||||
},
|
||||
onPrompt: async (prompt: { message: string; placeholder?: string }) => {
|
||||
const promptBlock = new TranscriptBlock();
|
||||
this.ctx.chatContainer.addChild(promptBlock);
|
||||
promptBlock.addChild(new Text(theme.fg("warning", prompt.message), 1, 0));
|
||||
if (prompt.placeholder) {
|
||||
promptBlock.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
|
||||
}
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present(promptBlock);
|
||||
const { promise, resolve } = Promise.withResolvers<string>();
|
||||
const codeInput = new Input();
|
||||
codeInput.onSubmit = () => {
|
||||
@@ -972,8 +970,7 @@ export class SelectorController {
|
||||
return promise;
|
||||
},
|
||||
onProgress: (message: string) => {
|
||||
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present(new Text(theme.fg("dim", message), 1, 0));
|
||||
},
|
||||
onManualCodeInput: useManualInput ? () => manualInput.waitForInput(providerId) : undefined,
|
||||
});
|
||||
@@ -983,8 +980,7 @@ export class SelectorController {
|
||||
new Text(theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}`), 1, 0),
|
||||
);
|
||||
block.addChild(new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0));
|
||||
this.ctx.chatContainer.addChild(block);
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present(block);
|
||||
} catch (error: unknown) {
|
||||
this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
@@ -1017,8 +1013,7 @@ export class SelectorController {
|
||||
new Text(theme.fg("warning", `${providerId} is still authenticated via ${remainingSource}`), 1, 0),
|
||||
);
|
||||
}
|
||||
this.ctx.chatContainer.addChild(block);
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present(block);
|
||||
} catch (error: unknown) {
|
||||
this.ctx.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-colo
|
||||
import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator";
|
||||
import type { AssistantMessageComponent } from "./components/assistant-message";
|
||||
import type { BashExecutionComponent } from "./components/bash-execution";
|
||||
import { ChatBlock, type ChatBlockHost } from "./components/chat-block";
|
||||
import { CustomEditor } from "./components/custom-editor";
|
||||
import { DynamicBorder } from "./components/dynamic-border";
|
||||
import { ErrorBannerComponent } from "./components/error-banner";
|
||||
@@ -367,6 +368,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
#eventBus?: EventBus;
|
||||
#eventBusUnsubscribers: Array<() => void> = [];
|
||||
#welcomeComponent?: WelcomeComponent;
|
||||
readonly #chatHost: ChatBlockHost = { requestRender: () => this.ui.requestRender() };
|
||||
|
||||
constructor(
|
||||
session: AgentSession,
|
||||
@@ -2529,6 +2531,25 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
}
|
||||
|
||||
// UI helpers
|
||||
present(content: Component | readonly Component[]): void {
|
||||
if (Array.isArray(content)) {
|
||||
for (const item of content) this.#mountChatChild(item);
|
||||
} else {
|
||||
this.#mountChatChild(content as Component);
|
||||
}
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
#mountChatChild(item: Component): void {
|
||||
this.chatContainer.addChild(item);
|
||||
if (item instanceof ChatBlock) item.mount(this.#chatHost);
|
||||
}
|
||||
|
||||
resetTranscript(): void {
|
||||
this.chatContainer.dispose();
|
||||
this.chatContainer.clear();
|
||||
}
|
||||
|
||||
showStatus(message: string, options?: { dim?: boolean }): void {
|
||||
this.#uiHelpers.showStatus(message, options);
|
||||
}
|
||||
|
||||
@@ -158,6 +158,20 @@ export interface InteractiveModeContext {
|
||||
handleBackgroundEvent(event: AgentSessionEvent): Promise<void>;
|
||||
|
||||
// UI helpers
|
||||
/**
|
||||
* Mount transcript content and repaint once. The single sink for "show this in
|
||||
* chat": producers build and return a `Component` (or a `ChatBlock` carrying
|
||||
* its own lifecycle) and hand it here instead of touching `chatContainer` /
|
||||
* `ui.requestRender()` directly. `ChatBlock`s are mounted (their `onMount`
|
||||
* runs) so their timers/subscriptions start.
|
||||
*/
|
||||
present(content: Component | readonly Component[]): void;
|
||||
/**
|
||||
* Dispose every live block in the transcript (stopping timers/subscriptions)
|
||||
* and clear it. Used before a full rebuild so animated/streaming blocks do not
|
||||
* leak.
|
||||
*/
|
||||
resetTranscript(): void;
|
||||
showStatus(message: string, options?: { dim?: boolean }): void;
|
||||
showError(message: string): void;
|
||||
showPinnedError(message: string): void;
|
||||
|
||||
@@ -91,11 +91,9 @@ export class UiHelpers {
|
||||
|
||||
const spacer = new Spacer(1);
|
||||
const text = new Text(rendered, 1, 0);
|
||||
this.ctx.chatContainer.addChild(spacer);
|
||||
this.ctx.chatContainer.addChild(text);
|
||||
this.ctx.present([spacer, text]);
|
||||
this.ctx.lastStatusSpacer = spacer;
|
||||
this.ctx.lastStatusText = text;
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
addMessageToChat(
|
||||
@@ -492,8 +490,15 @@ export class UiHelpers {
|
||||
renderInitialMessages(prebuiltContext?: SessionContext, options: RenderInitialMessagesOptions = {}): void {
|
||||
// This path is used to rebuild the visible chat transcript (e.g. after custom/debug UI).
|
||||
// Clear existing rendered chat first to avoid duplicating the full session in the container.
|
||||
// On a non-preserving rebuild the existing blocks are discarded for good, so
|
||||
// dispose them (stopping any live timers/subscriptions) before clearing. When
|
||||
// preserving, the same instances are re-added below, so detach without dispose.
|
||||
const preservedChatChildren = options.preserveExistingChat ? this.ctx.chatContainer.children : undefined;
|
||||
this.ctx.chatContainer.clear();
|
||||
if (preservedChatChildren) {
|
||||
this.ctx.chatContainer.clear();
|
||||
} else {
|
||||
this.ctx.resetTranscript();
|
||||
}
|
||||
this.ctx.pendingMessagesContainer.clear();
|
||||
this.ctx.pendingBashComponents = [];
|
||||
this.ctx.pendingPythonComponents = [];
|
||||
@@ -544,9 +549,7 @@ export class UiHelpers {
|
||||
process.stderr.write(`Error: ${errorMessage}\n`);
|
||||
return;
|
||||
}
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present([new Spacer(1), new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0)]);
|
||||
}
|
||||
|
||||
showWarning(warningMessage: string): void {
|
||||
@@ -554,9 +557,7 @@ export class UiHelpers {
|
||||
process.stderr.write(`Warning: ${warningMessage}\n`);
|
||||
return;
|
||||
}
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0));
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present([new Spacer(1), new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0)]);
|
||||
}
|
||||
|
||||
showNewVersionNotification(newVersion: string): void {
|
||||
@@ -573,8 +574,7 @@ export class UiHelpers {
|
||||
),
|
||||
);
|
||||
block.addChild(new DynamicBorder(text => theme.fg("warning", text)));
|
||||
this.ctx.chatContainer.addChild(block);
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present(block);
|
||||
}
|
||||
|
||||
updatePendingMessagesDisplay(): void {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
||||
import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers";
|
||||
import { buildSessionContext, type SessionContext } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { Container } from "@oh-my-pi/pi-tui";
|
||||
import { type Component, Container } from "@oh-my-pi/pi-tui";
|
||||
|
||||
function renderLastLine(container: Container, width = 120): string {
|
||||
const last = container.children[container.children.length - 1];
|
||||
@@ -25,6 +25,11 @@ function createInitialRenderHarness(): { ctx: InteractiveModeContext; helpers: U
|
||||
pendingPythonComponents: [],
|
||||
pendingTools: new Map(),
|
||||
ui: { requestRender: vi.fn() },
|
||||
present: (content: Component | readonly Component[]) => {
|
||||
const items = Array.isArray(content) ? content : [content];
|
||||
for (const item of items) ctx.chatContainer.addChild(item);
|
||||
ctx.ui.requestRender();
|
||||
},
|
||||
isBackgrounded: false,
|
||||
sessionManager: {
|
||||
buildSessionContext: () => buildSessionContext([]),
|
||||
@@ -59,6 +64,11 @@ describe("InteractiveMode.showStatus", () => {
|
||||
const ctx = {
|
||||
chatContainer: new Container(),
|
||||
ui: { requestRender: vi.fn() },
|
||||
present: (content: Component | readonly Component[]) => {
|
||||
const items = Array.isArray(content) ? content : [content];
|
||||
for (const item of items) ctx.chatContainer.addChild(item);
|
||||
ctx.ui.requestRender();
|
||||
},
|
||||
isBackgrounded: false,
|
||||
lastStatusSpacer: undefined,
|
||||
lastStatusText: undefined,
|
||||
@@ -80,6 +90,11 @@ describe("InteractiveMode.showStatus", () => {
|
||||
const ctx = {
|
||||
chatContainer: new Container(),
|
||||
ui: { requestRender: vi.fn() },
|
||||
present: (content: Component | readonly Component[]) => {
|
||||
const items = Array.isArray(content) ? content : [content];
|
||||
for (const item of items) ctx.chatContainer.addChild(item);
|
||||
ctx.ui.requestRender();
|
||||
},
|
||||
isBackgrounded: false,
|
||||
lastStatusSpacer: undefined,
|
||||
lastStatusText: undefined,
|
||||
|
||||
@@ -80,6 +80,10 @@ describe("issue #956: interactive /mcp test", () => {
|
||||
const disconnectServer = vi.spyOn(mcpClient, "disconnectServer").mockResolvedValue();
|
||||
const controller = new MCPCommandController({
|
||||
chatContainer: { addChild },
|
||||
present: (content: unknown) => {
|
||||
for (const item of Array.isArray(content) ? content : [content]) addChild(item);
|
||||
requestRender();
|
||||
},
|
||||
ui: { requestRender },
|
||||
editor: {},
|
||||
showError,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { ChatBlock, type ChatBlockHost } from "@oh-my-pi/pi-coding-agent/modes/components/chat-block";
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
|
||||
/** Concrete subclass exposing the protected lifecycle seams for assertions. */
|
||||
class TestBlock extends ChatBlock {
|
||||
mountCount = 0;
|
||||
cleanupCount = 0;
|
||||
|
||||
protected override onMount(): void {
|
||||
this.mountCount++;
|
||||
this.onCleanup(() => {
|
||||
this.cleanupCount++;
|
||||
});
|
||||
}
|
||||
|
||||
/** Public proxy for the protected requestRender. */
|
||||
ping(): void {
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
/** Public proxy for the protected onCleanup. */
|
||||
register(cleanup: () => void): void {
|
||||
this.onCleanup(cleanup);
|
||||
}
|
||||
}
|
||||
|
||||
describe("ChatBlock lifecycle", () => {
|
||||
let renders: number;
|
||||
let host: ChatBlockHost;
|
||||
|
||||
beforeEach(() => {
|
||||
renders = 0;
|
||||
host = {
|
||||
requestRender: () => {
|
||||
renders++;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it("runs onMount exactly once; a second mount is a no-op", () => {
|
||||
const block = new TestBlock();
|
||||
expect(block.mountCount).toBe(0);
|
||||
block.mount(host);
|
||||
block.mount(host);
|
||||
expect(block.mountCount).toBe(1);
|
||||
});
|
||||
|
||||
it("is finalized until mounted, live while active, finalized after finish", () => {
|
||||
const block = new TestBlock();
|
||||
expect(block.isTranscriptBlockFinalized()).toBe(true);
|
||||
block.mount(host);
|
||||
expect(block.isTranscriptBlockFinalized()).toBe(false);
|
||||
block.finish();
|
||||
expect(block.isTranscriptBlockFinalized()).toBe(true);
|
||||
});
|
||||
|
||||
it("finish runs cleanups once and requests one render", () => {
|
||||
const block = new TestBlock();
|
||||
block.mount(host);
|
||||
const before = renders;
|
||||
block.finish();
|
||||
expect(block.cleanupCount).toBe(1);
|
||||
expect(renders).toBe(before + 1);
|
||||
block.finish();
|
||||
expect(block.cleanupCount).toBe(1);
|
||||
});
|
||||
|
||||
it("dispose runs cleanups once, is idempotent, and finalizes", () => {
|
||||
const block = new TestBlock();
|
||||
block.mount(host);
|
||||
block.dispose();
|
||||
expect(block.cleanupCount).toBe(1);
|
||||
expect(block.isTranscriptBlockFinalized()).toBe(true);
|
||||
block.dispose();
|
||||
expect(block.cleanupCount).toBe(1);
|
||||
});
|
||||
|
||||
it("finish then dispose does not double-run cleanups", () => {
|
||||
const block = new TestBlock();
|
||||
block.mount(host);
|
||||
block.finish();
|
||||
block.dispose();
|
||||
expect(block.cleanupCount).toBe(1);
|
||||
});
|
||||
|
||||
it("requestRender routes to the host only between mount and dispose", () => {
|
||||
const block = new TestBlock();
|
||||
block.ping();
|
||||
expect(renders).toBe(0);
|
||||
block.mount(host);
|
||||
block.ping();
|
||||
expect(renders).toBe(1);
|
||||
block.dispose();
|
||||
const after = renders;
|
||||
block.ping();
|
||||
expect(renders).toBe(after);
|
||||
});
|
||||
|
||||
it("onCleanup registered after dispose runs immediately so callers never leak", () => {
|
||||
const block = new TestBlock();
|
||||
block.mount(host);
|
||||
block.dispose();
|
||||
let ran = false;
|
||||
block.register(() => {
|
||||
ran = true;
|
||||
});
|
||||
expect(ran).toBe(true);
|
||||
});
|
||||
|
||||
it("dispose propagates to child components", () => {
|
||||
const block = new TestBlock();
|
||||
let childDisposed = 0;
|
||||
const child: Component = {
|
||||
render: () => [],
|
||||
invalidate: () => {},
|
||||
dispose: () => {
|
||||
childDisposed++;
|
||||
},
|
||||
};
|
||||
block.addChild(child);
|
||||
block.mount(host);
|
||||
block.dispose();
|
||||
expect(childDisposed).toBe(1);
|
||||
});
|
||||
|
||||
it("tears down a timer effect started in onMount when finished", async () => {
|
||||
class TimerBlock extends ChatBlock {
|
||||
protected override onMount(): void {
|
||||
const id = setInterval(() => this.requestRender(), 5);
|
||||
this.onCleanup(() => clearInterval(id));
|
||||
}
|
||||
}
|
||||
const block = new TimerBlock();
|
||||
block.mount(host);
|
||||
await Bun.sleep(25);
|
||||
expect(renders).toBeGreaterThan(0); // timer fired while active
|
||||
block.finish();
|
||||
const settled = renders; // includes finish()'s own render
|
||||
await Bun.sleep(25);
|
||||
expect(renders).toBe(settled); // interval torn down — no further ticks
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,7 @@ function makeCtx(sessionManager?: Pick<SessionManager, "buildSessionContext" | "
|
||||
renderSessionContext: renderSessionContextSpy,
|
||||
showStatus: vi.fn(),
|
||||
ui: { requestRender: vi.fn() },
|
||||
resetTranscript: () => ctx.chatContainer.clear(),
|
||||
} as unknown as InteractiveModeContext;
|
||||
|
||||
return { ctx, buildSessionContextSpy, renderSessionContextSpy };
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings";
|
||||
import { getThemeByName, initTheme, theme, type Theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import { getThemeByName, initTheme, type Theme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import {
|
||||
dedupeParseErrors,
|
||||
expandKeyHint,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
formatScreenshot,
|
||||
truncateDiffByHunk,
|
||||
} from "@oh-my-pi/pi-coding-agent/tools/render-utils";
|
||||
import { getKeybindings, setKeybindings } from "@oh-my-pi/pi-tui";
|
||||
import { getKeybindings, setKeybindings, type KeybindingsManager as TuiKeybindingsManager } from "@oh-my-pi/pi-tui";
|
||||
|
||||
describe("parse error formatting", () => {
|
||||
it("deduplicates parse errors while preserving order", () => {
|
||||
@@ -295,7 +295,7 @@ describe("formatExpandHint / expandKeyHint", () => {
|
||||
format: { bracketLeft: "[", bracketRight: "]" },
|
||||
} as unknown as Theme;
|
||||
|
||||
let previous: ReturnType<typeof getKeybindings>;
|
||||
let previous: TuiKeybindingsManager;
|
||||
beforeEach(() => {
|
||||
previous = getKeybindings();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Added an optional `dispose()` lifecycle method to `Component` so components can release timers and subscriptions during permanent teardown
|
||||
- Added `Container.dispose()` to propagate teardown to child components when a component tree is permanently discarded
|
||||
- Added `Loader.dispose()` to stop the loader animation timer when the component is disposed
|
||||
|
||||
## [15.10.0] - 2026-06-06
|
||||
|
||||
|
||||
@@ -71,6 +71,11 @@ export class Loader extends Text {
|
||||
}
|
||||
}
|
||||
|
||||
/** Lifecycle teardown: stop the animation timer. Idempotent. */
|
||||
dispose() {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
setMessage(message: string) {
|
||||
this.message = message;
|
||||
this.#updateDisplay();
|
||||
|
||||
@@ -128,6 +128,14 @@ export interface Component {
|
||||
* Called when theme changes or when component needs to re-render from scratch.
|
||||
*/
|
||||
invalidate(): void;
|
||||
|
||||
/**
|
||||
* Optional teardown. Called when the component is permanently removed from
|
||||
* the live tree (e.g. a transcript reset). Release timers, intervals, and
|
||||
* subscriptions here. Must be idempotent. Containers propagate dispose to
|
||||
* their children; leaf components without resources may omit it.
|
||||
*/
|
||||
dispose?(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,6 +347,17 @@ export class Container implements Component {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagate teardown to children. Call when the container's children are
|
||||
* being permanently discarded (not when they are detached for reuse — use
|
||||
* {@link clear} for that). Idempotent per child via each child's own dispose.
|
||||
*/
|
||||
dispose(): void {
|
||||
for (const child of this.children) {
|
||||
child.dispose?.();
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
width = Math.max(1, width);
|
||||
const lines: string[] = [];
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { type Component, Container } from "@oh-my-pi/pi-tui";
|
||||
|
||||
function inert(dispose?: () => void): Component {
|
||||
return { render: () => [], invalidate: () => {}, dispose };
|
||||
}
|
||||
|
||||
describe("Container.dispose", () => {
|
||||
it("calls dispose on each child and tolerates children without dispose", () => {
|
||||
const order: string[] = [];
|
||||
const container = new Container();
|
||||
container.addChild(inert(() => order.push("a")));
|
||||
container.addChild(inert()); // no dispose — must not throw
|
||||
container.addChild(inert(() => order.push("c")));
|
||||
|
||||
expect(() => container.dispose()).not.toThrow();
|
||||
expect(order).toEqual(["a", "c"]);
|
||||
});
|
||||
|
||||
it("recurses through nested containers", () => {
|
||||
let leafDisposed = 0;
|
||||
const outer = new Container();
|
||||
const inner = new Container();
|
||||
inner.addChild(inert(() => leafDisposed++));
|
||||
outer.addChild(inner);
|
||||
|
||||
outer.dispose();
|
||||
expect(leafDisposed).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { describe, expect, it, spyOn } from "bun:test";
|
||||
import { TUI } from "@oh-my-pi/pi-tui";
|
||||
import { Loader } from "@oh-my-pi/pi-tui/components/loader";
|
||||
import { visibleWidth } from "@oh-my-pi/pi-tui/utils";
|
||||
@@ -28,4 +28,23 @@ describe("Loader component", () => {
|
||||
loader.stop();
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("dispose() stops the animation so no further renders are scheduled", async () => {
|
||||
const term = new VirtualTerminal(20, 4);
|
||||
const tui = new TUI(term);
|
||||
const loader = new Loader(
|
||||
tui,
|
||||
text => text,
|
||||
text => text,
|
||||
"Checking",
|
||||
["a", "b", "c"],
|
||||
);
|
||||
const spy = spyOn(tui, "requestRender");
|
||||
loader.dispose();
|
||||
const after = spy.mock.calls.length;
|
||||
await Bun.sleep(40); // longer than the spinner interval
|
||||
expect(spy.mock.calls.length).toBe(after);
|
||||
expect(() => loader.dispose()).not.toThrow(); // idempotent
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user