Skip to content

Commit fbcbf78

Browse files
roboompBrit
authored andcommitted
fix(tui): preserved hanging prefixes in tool cards
- Wrapped numbered code previews, tree rows, diffs, option descriptions, and hover rails under their content columns. - Covered streaming and completed cards, CJK lines, URLs, and framed previews with renderer regressions. Fixes #15677
1 parent ae179ca commit fbcbf78

11 files changed

Lines changed: 254 additions & 21 deletions

File tree

‎packages/tui/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
### Fixed
1111

1212
- SVG fences in CRLF or CR-only assistant Markdown now render as figures instead of source code ([#15663](https://github.com/can1357/oh-my-pi/issues/15663)).
13+
- Tool-card continuations stay aligned beneath line numbers, tree connectors, and option or code rails in Write, Read, Edit, Todo, Ask, and LSP previews ([#15677](https://github.com/can1357/oh-my-pi/issues/15677)).
1314
- Fixed clipboard-image and image-path pastes being dropped while the composer draft is edited beneath an ask or approval dialog ([#15642](https://github.com/can1357/oh-my-pi/pull/15642) by [@H4vC](https://github.com/H4vC))
1415
- Fixed inline images inside tmux assuming Kitty graphics from the pane's `TERM`; the attached client is identified from tmux's terminal-type reply or its `TERM` (e.g. `xterm-kitty`), and unrecognized clients go to the SIXEL probe ([#15524](https://github.com/can1357/oh-my-pi/issues/15524)).
1516
- Fixed ```svg fences rendering as their source code instead of a drawing when no theme was installed yet, e.g. in an extension rendering a message that opens with the fence ([#15660](https://github.com/can1357/oh-my-pi/issues/15660))

‎packages/tui/src/chat/read-tool-group.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
} from "../tools/read";
1717
import { PREVIEW_LIMITS, shortenPath } from "../render/render-utils";
1818
import { fileHyperlink, renderCodeCell, renderMarkdownCell } from "../render";
19+
import { wrapTreeLine } from "../render/hanging-wrap";
20+
import { wrapTextWithAnsi } from "../utils";
1921
import { canonicalizeMessage } from "./thinking-display";
2022
import { internalUrlSchemeSpec, splitUrlScheme } from "../tools/url-scheme-host";
2123
import type { ToolExecutionHandle } from "./tool-execution";
@@ -891,7 +893,9 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
891893

892894
// Clear previous children and rebuild the summary and preview blocks.
893895
this.clear();
894-
this.#text = new Text("", 0, 0);
896+
this.#text = new Text("", 0, 0).setWrapLine(
897+
(line, width) => wrapTreeLine(line, width, theme) ?? wrapTextWithAnsi(line, width),
898+
);
895899

896900
if (displayRows.length === 0) {
897901
this.#text.setText(` ${theme.format.bullet} ${theme.fg("toolTitle", theme.bold("Read"))}`);

‎packages/tui/src/components/text.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,20 @@ export class Text implements Component {
3030
#customBgFn?: (text: string) => string;
3131
#styleFn?: (text: string) => string;
3232
#ignoreTight = false;
33+
#wrapLine?: (line: string, width: number) => string[];
3334

3435
setIgnoreTight(ignore: boolean): this {
3536
if (this.#ignoreTight === ignore) return this;
3637
this.#ignoreTight = ignore;
3738
this.invalidate();
3839
return this;
3940
}
41+
/** Override row wrapping when the source carries structural prefixes. */
42+
setWrapLine(wrapLine: (line: string, width: number) => string[]): this {
43+
this.#wrapLine = wrapLine;
44+
this.invalidate();
45+
return this;
46+
}
4047

4148
// Cache for rendered output
4249
#cachedText?: string;
@@ -162,7 +169,10 @@ export class Text implements Component {
162169
const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
163170
const contentWidth = Math.max(1, width - paddingX * 2);
164171
// Wrap text (this preserves ANSI codes but does NOT pad)
165-
const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
172+
const wrapLine = this.#wrapLine;
173+
const wrappedLines = wrapLine
174+
? normalizedText.split("\n").flatMap(line => wrapLine(line, contentWidth))
175+
: wrapTextWithAnsi(normalizedText, contentWidth);
166176

167177
// Add margins and background to each line
168178
const leftMargin = padding(paddingX);

‎packages/tui/src/render/code-cell.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { code, md, span } from "../native/describe";
88
import type { NativeNode } from "../native/node";
99
import { getMarkdownTheme, highlightCode, type Theme } from "../theme/theme";
1010
import { formatDuration, formatExpandHint, formatMoreItems, formatStatusIcon, replaceTabs } from "./render-utils";
11+
import { wrapPrefixedLine } from "./hanging-wrap";
1112
import {
1213
describeOutputBlock,
1314
type NativeOutputBlockSection,
@@ -167,34 +168,39 @@ export function renderCodeCell(options: CodeCellOptions, theme: Theme): string[]
167168
}
168169
}
169170

171+
const numberedCodeLines: string[] = [];
170172
if (lineNumberWidth > 0 && visibleLineNumbers) {
171173
for (let i = 0; i < codeLines.length; i++) {
172174
const lineNum = visibleLineNumbers[i];
173175
const gutter =
174176
lineNum !== null && lineNum !== undefined
175177
? String(lineNum).padStart(lineNumberWidth, " ")
176178
: " ".repeat(lineNumberWidth);
177-
codeLines[i] = theme.fg("dim", `${gutter} `) + codeLines[i];
179+
const prefix = theme.fg("dim", `${gutter} `);
180+
numberedCodeLines.push(
181+
...wrapPrefixedLine(prefix, " ".repeat(lineNumberWidth + 1), codeLines[i]!, outputBlockContentWidth(width)),
182+
);
178183
}
179184
}
180185

186+
const displayCodeLines = lineNumberWidth > 0 ? numberedCodeLines : codeLines;
181187
if (hiddenCodeLines > 0) {
182188
const hint = formatExpandHint(theme, expanded, hiddenCodeLines > 0);
183189
const gutterPad = lineNumberWidth > 0 ? " ".repeat(lineNumberWidth + 1) : "";
184190
if (tail) {
185191
// Earlier rows scrolled above the live tail window — mark them on top so
186192
// the newest streamed line stays pinned to the bottom of the box.
187193
const earlier = `… ${hiddenCodeLines} earlier line${hiddenCodeLines === 1 ? "" : "s"}${hint ? ` ${hint}` : ""}`;
188-
codeLines.unshift(theme.fg("dim", gutterPad + earlier));
194+
displayCodeLines.unshift(theme.fg("dim", gutterPad + earlier));
189195
} else {
190196
const moreLine = `${formatMoreItems(hiddenCodeLines, "line")}${hint ? ` ${hint}` : ""}`;
191-
codeLines.push(theme.fg("dim", gutterPad + moreLine));
197+
displayCodeLines.push(theme.fg("dim", gutterPad + moreLine));
192198
}
193199
}
194200

195201
const outputLines = renderCellOutput(output, expanded, outputMaxLines, theme);
196202

197-
const sections: Array<{ label?: string; lines: readonly string[] }> = [{ lines: codeLines }];
203+
const sections: Array<{ label?: string; lines: readonly string[] }> = [{ lines: displayCodeLines }];
198204
if (outputLines.length > 0) {
199205
sections.push({ label: theme.fg("toolTitle", "Output"), lines: outputLines });
200206
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { visibleWidth, wrapTextWithAnsi } from "../utils";
2+
import type { Theme } from "../theme/theme";
3+
4+
/** Wrap content under a distinct first-row prefix, preserving its column on continuation rows. */
5+
export function wrapPrefixedLine(prefix: string, continuationPrefix: string, content: string, width: number): string[] {
6+
const prefixWidth = visibleWidth(prefix);
7+
const continuationWidth = visibleWidth(continuationPrefix);
8+
if (width <= Math.max(prefixWidth, continuationWidth)) return wrapTextWithAnsi(prefix + content, width);
9+
const rows = wrapTextWithAnsi(content, width - Math.max(prefixWidth, continuationWidth));
10+
return rows.map((row, index) => `${index === 0 ? prefix : continuationPrefix}${row}`);
11+
}
12+
13+
/** Preserve a tree connector's ancestor guides while hanging a long node beneath its label. */
14+
export function wrapTreeLine(line: string, width: number, theme: Theme): string[] | undefined {
15+
for (const connector of [theme.tree.branch, theme.tree.last]) {
16+
const marker = theme.fg("dim", connector);
17+
const index = line.indexOf(marker);
18+
if (index < 0) continue;
19+
const before = line.slice(0, index);
20+
if (!/^[ │]*$/.test(Bun.stripANSI(before))) continue;
21+
const end = index + marker.length + 1;
22+
if (line[end - 1] !== " ") continue;
23+
const connectorWidth = visibleWidth(connector);
24+
const guide =
25+
connector === theme.tree.last
26+
? " ".repeat(connectorWidth)
27+
: `${theme.fg("dim", theme.tree.vertical)}${" ".repeat(Math.max(0, connectorWidth - visibleWidth(theme.tree.vertical)))}`;
28+
return wrapPrefixedLine(line.slice(0, end), `${before}${guide} `, line.slice(end), width);
29+
}
30+
return undefined;
31+
}

‎packages/tui/src/tools/ask.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { getMarkdownTheme, type Theme } from "../theme/theme";
2323

2424
import { outputBlockContentWidth, renderStatusLine } from "../render";
2525
import { framedToolCard } from "../render/tool-card";
26+
import { wrapPrefixedLine } from "../render/hanging-wrap";
2627

2728
import { formatErrorMessage, formatMeta, formatTitle, sanitizeCarriageReturns } from "../render/render-utils";
2829

@@ -208,14 +209,17 @@ function renderQuestionOptionLines(
208209
mdTheme: MarkdownTheme,
209210
options: AskRenderOption[],
210211
multi: boolean | undefined,
212+
width: number,
211213
): string[] {
212214
const out: string[] = [];
213215
for (const opt of options) {
214216
const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
215-
out.push(` ${uiTheme.fg("dim", optionMarker(uiTheme, multi, false))} ${optLabel}`);
217+
const marker = ` ${uiTheme.fg("dim", optionMarker(uiTheme, multi, false))} `;
218+
out.push(...wrapPrefixedLine(marker, " ".repeat(visibleWidth(marker)), optLabel, width));
216219
if (opt.description?.trim()) {
217220
const description = renderInlineMarkdown(opt.description.trim(), mdTheme, t => uiTheme.fg("dim", t));
218-
out.push(` ${uiTheme.fg("dim", "↳")} ${description}`);
221+
const prefix = ` ${uiTheme.fg("dim", "↳")} `;
222+
out.push(...wrapPrefixedLine(prefix, " ".repeat(visibleWidth(prefix)), description, width));
219223
}
220224
}
221225
return out;
@@ -453,7 +457,7 @@ export const askToolRenderer = {
453457
const questions = normalizeRenderQuestions(args.questions);
454458
if (questions && questions.length > 0) {
455459
const header = `${label} ${uiTheme.fg("muted", `${questions.length} questions`)}`;
456-
return framedToolCard(uiTheme, ({ width }) => {
460+
return framedToolCard(uiTheme, ({ width, contentWidth }) => {
457461
const sections = questions.map(q => {
458462
const meta: string[] = [];
459463
if (q.multi) meta.push("multi");
@@ -462,7 +466,7 @@ export const askToolRenderer = {
462466
// md() returns a shared cached array (module-level Markdown LRU) — copy before appending.
463467
const mdLines = md(q.question, width);
464468
const lines = q.options?.length
465-
? [...mdLines, ...renderQuestionOptionLines(uiTheme, mdTheme, q.options, q.multi)]
469+
? [...mdLines, ...renderQuestionOptionLines(uiTheme, mdTheme, q.options, q.multi, contentWidth)]
466470
: mdLines;
467471
return { label: `${uiTheme.fg("dim", `[${q.id}]`)}${metaStr}`, content: lines };
468472
});
@@ -488,11 +492,11 @@ export const askToolRenderer = {
488492
if (questionOptions?.length) meta.push(`options:${questionOptions.length}`);
489493
const header = `${label}${formatMeta(meta, uiTheme)}`;
490494
const multi = args.multi;
491-
return framedToolCard(uiTheme, ({ width }) => {
495+
return framedToolCard(uiTheme, ({ width, contentWidth }) => {
492496
// md() returns a shared cached array (module-level Markdown LRU) — copy before appending.
493497
const mdLines = md(question, width);
494498
const bodyLines = questionOptions?.length
495-
? [...mdLines, ...renderQuestionOptionLines(uiTheme, mdTheme, questionOptions, multi)]
499+
? [...mdLines, ...renderQuestionOptionLines(uiTheme, mdTheme, questionOptions, multi, contentWidth)]
496500
: mdLines;
497501
return {
498502
header,

‎packages/tui/src/tools/edit.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1142,7 +1142,10 @@ export const editToolRenderer = {
11421142
while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
11431143
return {
11441144
header,
1145-
sections: bodyLines.length > 0 ? [{ content: bodyLines }] : [],
1145+
sections:
1146+
bodyLines.length > 0
1147+
? [{ content: bodyLines.flatMap(line => wrapEditRendererLine(line, Math.max(1, width - 2))) }]
1148+
: [],
11461149
phase: applyPatchError ? "error" : "pending",
11471150
borderColor: applyPatchError ? "error" : "borderMuted",
11481151
contentPaddingLeft: 0,

‎packages/tui/src/tools/lsp.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from "../render/render-utils";
2929
import { renderStatusLine } from "../render";
3030
import { framedToolCard } from "../render/tool-card";
31+
import { wrapPrefixedLine } from "../render/hanging-wrap";
3132
import { ansi, code, col, compact, keyed, md, node, row, span, text } from "../native/describe";
3233
import type { NativeNode } from "../native/node";
3334
import { OwnerMemo } from "../native/memo";
@@ -245,7 +246,7 @@ export function renderResult(
245246
return { label: "Response", state: "success", bodyLines: renderGeneric(text, lines, expanded, theme) };
246247
};
247248

248-
return framedToolCard(theme, () => {
249+
return framedToolCard(theme, ({ contentWidth }) => {
249250
// Read mutable state at render time
250251
const { expanded, isPartial, spinnerFrame } = options;
251252
const slot = expanded ? 1 : 0;
@@ -263,12 +264,19 @@ export function renderResult(
263264
: formatStatusIcon(isPartial ? "running" : "error", theme, spinnerFrame);
264265
const header = `${icon} LSP ${actionLabel}`;
265266

267+
const rail = ` ${theme.fg("mdCodeBlockBorder", theme.boxRound.vertical)} `;
268+
const contentLines =
269+
codeBlockMatch && expanded
270+
? bodyLines.flatMap(line =>
271+
line.startsWith(rail) ? wrapPrefixedLine(rail, rail, line.slice(rail.length), contentWidth) : [line],
272+
)
273+
: bodyLines;
266274
return {
267275
header,
268276
phase: isPartial ? "partial" : state,
269277
sections: [
270278
...(requestLines.length > 0 ? [{ content: requestLines }] : []),
271-
{ label: theme.fg("toolTitle", "Response"), content: bodyLines },
279+
{ label: theme.fg("toolTitle", "Response"), content: contentLines },
272280
],
273281
applyBg: false,
274282
};

‎packages/tui/src/tools/todo.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { RenderResultOptions } from "./renderer";
88
import type { Theme } from "../theme/theme";
99

1010
import { renderStatusLine, renderTreeList } from "../render";
11+
import { wrapTreeLine } from "../render/hanging-wrap";
1112
import { framedToolCard } from "../render/tool-card";
1213

1314
import { formatErrorDetail, formatMoreItems, PREVIEW_LIMITS, pluralize, replaceTabs } from "../render/render-utils";
@@ -644,7 +645,7 @@ export const todoToolRenderer = {
644645
matchByContent.clear();
645646
};
646647

647-
return framedToolCard(uiTheme, () => {
648+
return framedToolCard(uiTheme, ({ contentWidth }) => {
648649
const { expanded, spinnerFrame } = options;
649650
const multiPhase = phases.length > 1;
650651
const indent = multiPhase ? " " : "";
@@ -725,7 +726,7 @@ export const todoToolRenderer = {
725726
);
726727
})();
727728
for (const line of treeLines) {
728-
bodyLines.push(`${indent}${line}`);
729+
bodyLines.push(...(wrapTreeLine(`${indent}${line}`, contentWidth, uiTheme) ?? [`${indent}${line}`]));
729730
}
730731
}
731732
while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();

‎packages/tui/src/tools/write.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getLanguageFromPath } from "../lang-from-path";
77
import { createHighlightStream, highlightCode, type Theme } from "../theme/theme";
88
import { fileHyperlink, renderStatusLine } from "../render";
99
import { framedToolCard } from "../render/tool-card";
10+
import { wrapPrefixedLine } from "../render/hanging-wrap";
1011
import {
1112
cachedRenderedString,
1213
createRenderedStringCache,
@@ -388,6 +389,15 @@ function formatStreamingContent(
388389
return `${bodyText}${spinner}${uiTheme.fg("dim", `… (streaming)`)}`;
389390
}
390391

392+
function wrapWritePreviewRows(lines: string[], width: number): string[] {
393+
return lines.flatMap(line => {
394+
const gutter = /^(\x1b\[[0-9;]*m)( *\d+ )(\x1b\[[0-9;]*m)(.*)$/.exec(line);
395+
if (!gutter) return line;
396+
const prefix = `${gutter[1]}${gutter[2]}${gutter[3]}`;
397+
return wrapPrefixedLine(prefix, " ".repeat(Bun.stringWidth(gutter[2]!)), gutter[4] ?? "", width);
398+
});
399+
}
400+
391401
function renderContentPreview(
392402
content: string,
393403
expanded: boolean,
@@ -639,7 +649,7 @@ export const writeToolRenderer = {
639649
const streamingCache = createRenderedStringCache();
640650
return framedToolCard(
641651
uiTheme,
642-
() => {
652+
({ contentWidth }) => {
643653
const body = content
644654
? formatStreamingContent(
645655
content,
@@ -660,7 +670,7 @@ export const writeToolRenderer = {
660670
while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
661671
return {
662672
header,
663-
sections: bodyLines.length > 0 ? [{ content: bodyLines }] : [],
673+
sections: bodyLines.length > 0 ? [{ content: wrapWritePreviewRows(bodyLines, contentWidth) }] : [],
664674
phase: "pending",
665675
borderColor: "borderMuted",
666676
};
@@ -743,7 +753,7 @@ export const writeToolRenderer = {
743753
const previewCache = createRenderedStringCache();
744754
return framedToolCard(
745755
uiTheme,
746-
() => {
756+
({ contentWidth }) => {
747757
const { expanded } = options;
748758
let body = renderContentPreview(fileContent, expanded, lang, uiTheme, previewCache);
749759
if (isPartial && progressText) {
@@ -768,7 +778,7 @@ export const writeToolRenderer = {
768778
while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
769779
return {
770780
header,
771-
sections: bodyLines.length > 0 ? [{ content: bodyLines }] : [],
781+
sections: bodyLines.length > 0 ? [{ content: wrapWritePreviewRows(bodyLines, contentWidth) }] : [],
772782
phase: isPartial ? "partial" : "success",
773783
borderColor: "borderMuted",
774784
};

0 commit comments

Comments
 (0)