Files
oh-my-pi/packages/coding-agent/src/tui/output-block.ts
T
can1357 c0edd45496 fix(coding-agent): corrected fetch result rendering to wrap long lines
- Fixed fetch result rendering to properly wrap long lines instead of truncating them.
- Replaced Bun.wrapAnsi with unified wrapTextWithAnsi utility across debug and output modules.
- Corrected content preview calculation to use actual wrapped line count instead of truncated count.
2026-03-04 01:42:38 +01:00

151 lines
5.5 KiB
TypeScript

/**
* Bordered output container with optional header and sections.
*/
import { ImageProtocol, padding, TERMINAL, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
import type { Theme } from "../modes/theme/theme";
import { getSixelLineMask } from "../utils/sixel";
import type { State } from "./types";
import type { RenderCache } from "./utils";
import { getStateBgColor, Hasher, padToWidth, truncateToWidth } from "./utils";
export interface OutputBlockOptions {
header?: string;
headerMeta?: string;
state?: State;
sections?: Array<{ label?: string; lines: string[] }>;
width: number;
applyBg?: boolean;
}
export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] {
const { header, headerMeta, state, sections = [], width, applyBg = true } = options;
const h = theme.boxSharp.horizontal;
const v = theme.boxSharp.vertical;
const cap = h.repeat(3);
const lineWidth = Math.max(0, width);
// Border colors: running/pending use accent, success uses dim (gray), error/warning keep their colors
const borderColor: "error" | "warning" | "accent" | "dim" =
state === "error"
? "error"
: state === "warning"
? "warning"
: state === "running" || state === "pending"
? "accent"
: "dim";
const border = (text: string) => theme.fg(borderColor, text);
const bgFn = (() => {
if (!state || !applyBg) return undefined;
const bgAnsi = theme.getBgAnsi(getStateBgColor(state));
// Keep block background stable even if inner content contains SGR resets (e.g. "\x1b[0m"),
// which would otherwise clear the outer background mid-line.
return (text: string) => {
const stabilized = text
.replace(/\x1b\[(?:0)?m/g, m => `${m}${bgAnsi}`)
.replace(/\x1b\[49m/g, m => `${m}${bgAnsi}`);
return `${bgAnsi}${stabilized}\x1b[49m`;
};
})();
const buildBarLine = (leftChar: string, rightChar: string, label?: string, meta?: string): string => {
const left = border(`${leftChar}${cap}`);
const right = border(rightChar);
if (lineWidth <= 0) return left + right;
const labelText = [label, meta].filter(Boolean).join(theme.sep.dot);
const rawLabel = labelText ? ` ${labelText} ` : " ";
const leftWidth = visibleWidth(left);
const rightWidth = visibleWidth(right);
const maxLabelWidth = Math.max(0, lineWidth - leftWidth - rightWidth);
const trimmedLabel = truncateToWidth(rawLabel, maxLabelWidth);
const labelWidth = visibleWidth(trimmedLabel);
const fillCount = Math.max(0, lineWidth - leftWidth - labelWidth - rightWidth);
return `${left}${trimmedLabel}${border(h.repeat(fillCount))}${right}`;
};
const contentPrefix = border(`${v} `);
const contentSuffix = border(v);
const contentWidth = Math.max(0, lineWidth - visibleWidth(contentPrefix) - visibleWidth(contentSuffix));
const lines: string[] = [];
lines.push(
padToWidth(buildBarLine(theme.boxSharp.topLeft, theme.boxSharp.topRight, header, headerMeta), lineWidth, bgFn),
);
const hasSections = sections.length > 0;
const normalizedSections = hasSections ? sections : [{ lines: [] }];
for (let i = 0; i < normalizedSections.length; i++) {
const section = normalizedSections[i];
if (section.label) {
lines.push(
padToWidth(buildBarLine(theme.boxSharp.teeRight, theme.boxSharp.teeLeft, section.label), lineWidth, bgFn),
);
}
const allLines = section.lines.flatMap(l => l.split("\n"));
const sixelLineMask = TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(allLines) : undefined;
for (let lineIndex = 0; lineIndex < allLines.length; lineIndex++) {
const line = allLines[lineIndex]!;
if (sixelLineMask?.[lineIndex]) {
lines.push(line);
continue;
}
const wrappedLines = wrapTextWithAnsi(line.trimEnd(), contentWidth);
for (const wrappedLine of wrappedLines) {
const innerPadding = padding(Math.max(0, contentWidth - visibleWidth(wrappedLine)));
const fullLine = `${contentPrefix}${wrappedLine}${innerPadding}${contentSuffix}`;
lines.push(padToWidth(fullLine, lineWidth, bgFn));
}
}
}
const bottomLeft = border(`${theme.boxSharp.bottomLeft}${cap}`);
const bottomRight = border(theme.boxSharp.bottomRight);
const bottomFillCount = Math.max(0, lineWidth - visibleWidth(bottomLeft) - visibleWidth(bottomRight));
const bottomLine = `${bottomLeft}${border(h.repeat(bottomFillCount))}${bottomRight}`;
lines.push(padToWidth(bottomLine, lineWidth, bgFn));
return lines;
}
/**
* Cached wrapper around `renderOutputBlock`.
*
* Since output blocks are re-rendered on every frame (via `render(width)` closures),
* but their content rarely changes, this cache avoids redundant `visibleWidth()` and
* `padding()` computations on ~99% of render calls.
*/
export class CachedOutputBlock {
#cache?: RenderCache;
/** Render with caching. Returns cached result if options haven't changed. */
render(options: OutputBlockOptions, theme: Theme): string[] {
const key = this.#buildKey(options);
if (this.#cache?.key === key) return this.#cache.lines;
const lines = renderOutputBlock(options, theme);
this.#cache = { key, lines };
return lines;
}
/** Invalidate the cache, forcing a rebuild on next render. */
invalidate(): void {
this.#cache = undefined;
}
#buildKey(options: OutputBlockOptions): bigint {
const h = new Hasher();
h.u32(options.width);
h.optional(options.header);
h.optional(options.headerMeta);
h.optional(options.state);
h.bool(options.applyBg ?? true);
if (options.sections) {
for (const s of options.sections) {
h.optional(s.label);
for (const line of s.lines) {
h.str(line);
}
}
}
return h.digest();
}
}