@zenodinh/pi-render 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -67,5 +67,5 @@
67
67
  "typescript": "^7.0.2",
68
68
  "vitest": "^5.0.3"
69
69
  },
70
- "version": "0.1.7"
70
+ "version": "0.1.8"
71
71
  }
package/src/core/paint.ts CHANGED
@@ -60,7 +60,9 @@ export function createRowPaint(theme: HostTheme): RowPaint {
60
60
  accent: (text) => fgOrPlain(theme, "accent", text),
61
61
  error: (text) => fgOrPlain(theme, "error", text),
62
62
  warning: (text) => fgOrPlain(theme, "warning", text),
63
- success: (text) => fgOrPlain(theme, "success", text),
63
+ // The settled ink is `muted` by owner pick (#58): the theme's green read too bright. The role
64
+ // keeps its name, so the three state call sites stay one expression.
65
+ success: (text) => fgOrPlain(theme, "muted", text),
64
66
  gutter: (text) => fgOrPlain(theme, "muted", text),
65
67
  match: (text) => matchOrAccent(theme, text),
66
68
  diffAdded: (text) => fgOrPlain(theme, "toolDiffAdded", text),
@@ -26,7 +26,10 @@ export interface RowPaint {
26
26
  error(text: string): string;
27
27
  /** Warning text for a degraded or partial result. Required. */
28
28
  warning(text: string): string;
29
- /** The success ink: a settled call that did not fail. Required. */
29
+ /**
30
+ * The settled ink: a call that did not fail, drawn in the `muted` token (thinking's gray) by owner
31
+ * pick (#58). The role keeps its name so the state call sites stay one expression. Required.
32
+ */
30
33
  success(text: string): string;
31
34
  /** The row's left gutter marker. Required. */
32
35
  gutter(text: string): string;
@@ -9,9 +9,10 @@
9
9
  *
10
10
  * The header row is stamped through the header-band role — bold over the accent ink inside a
11
11
  * full-bleed band spanning each cell's whole interior plus its trailing separator, so the header
12
- * reads as one bar (FP-11) — or, when the header row is empty (`| | |`), that stamp moves onto the
13
- * first column so no blank band is spent. `**strong**` and `*em*` cells route through their own
14
- * roles.
12
+ * reads as one bar (FP-11); where the band abuts the outer frame, the frame column joins it too
13
+ * (#57), so the field reaches the border. When the header row is empty (`| | |`), that stamp moves
14
+ * onto the first column instead of spending a blank band. `**strong**` and `*em*` cells route
15
+ * through their own roles.
15
16
  *
16
17
  * Boundary: lane-local. The transformer passes markdown in and splices the returned markdown back
17
18
  * out, so every escape in the output originates in the injected ContentPaint (T-08) — the #22 fix.
@@ -165,7 +166,13 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
165
166
  const unit = index < cells.length - 1 ? ` ${padded} ${bar}` : ` ${padded} `;
166
167
  return banded(index) ? paint.headerBand(unit) : unit;
167
168
  });
168
- physical.push(`${bar}${parts.join("")}${bar}`);
169
+ // #57 (owner pick): the band reaches the outer frame where it abuts it — a banded first cell
170
+ // takes the left frame column into the band, a banded last cell the right one. The frame glyph
171
+ // joins the band's own style (dark-on-green), not the rule ink, so no unbanded sliver remains
172
+ // between the field and the border.
173
+ const left = banded(0) ? paint.headerBand("│") : bar;
174
+ const right = banded(cells.length - 1) ? paint.headerBand("│") : bar;
175
+ physical.push(`${left}${parts.join("")}${right}`);
169
176
  }
170
177
  };
171
178
 
@@ -15,6 +15,7 @@
15
15
  * renderCall/renderResult methods, with no subclassing.
16
16
  */
17
17
 
18
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
18
19
  import type {
19
20
  HostTheme,
20
21
  RenderContext,
@@ -166,6 +167,11 @@ interface PaddedText extends UiComponent {
166
167
  setText(body: string): void;
167
168
  /** Re-reads the row's indent, so a host padding change lands on the next frame. Required. */
168
169
  setPad(pad: number): void;
170
+ /**
171
+ * Sets how an over-wide line is spent: false cuts it to the budget with an ellipsis, true reflows it.
172
+ * Required: one component serves both row states, so the mode must follow the frame.
173
+ */
174
+ setWrap(wrap: boolean): void;
169
175
  }
170
176
 
171
177
  /** `in`-narrowed, never cast: a recorded component is the host's until it answers this protocol. */
@@ -180,7 +186,8 @@ function textComponent(body: string): UiComponent {
180
186
  render(_width: number): string[] {
181
187
  // One entry per line; an empty body still yields one line, so a degraded row never vanishes.
182
188
  // Width is the component's own: renderResult receives none, so nothing here is width-keyed.
183
- // The offer minus the indent is handed down by the pad wrapper around this component.
189
+ // The offer minus the indent is handed down by the pad wrapper, which also spends what comes back —
190
+ // cut when collapsed, reflowed when expanded.
184
191
  return current.split("\n");
185
192
  },
186
193
  invalidate(): void {
@@ -193,22 +200,39 @@ function textComponent(body: string): UiComponent {
193
200
  }
194
201
 
195
202
  /**
196
- * The pad wrapper: every line the inner component draws takes the indent, and the inner component gets
197
- * the offer minus it.
203
+ * The pad wrapper: the inner component gets the offer minus the indent, every line it draws takes the
204
+ * indent, and every line is fitted to the budget — an inner is told the budget, not obliged to spend it.
198
205
  *
199
206
  * Both halves are ours because a row asks the host for the "self" shell (#51), and that shell skips the
200
207
  * Box(1,1) which used to indent each line and render its child at `width - paddingX * 2` (pi-tui
201
208
  * box.js:83 → :91 → :115). The budget half is what a reused host `Text` needs: it wraps at the width it
202
209
  * is given (pi-tui text.js:53-56), so a full-width title would wrap at the whole offer and then be
203
- * pushed one column past it by the indent.
210
+ * pushed one column past it by the indent. Spending is the other half of the same duty (#56): our own
211
+ * text component ignores the width it is handed, so an over-wide title used to run past the offer.
204
212
  *
205
- * shape: closure returning an object literal — trigger #4: one stored pad behind render/setPad.
213
+ * How an over-wide line is spent follows the row's state — the owner's review on #59: an expansion must
214
+ * show the FULL request and result. Collapsed cuts to the budget, ellipsis-marked: one logical line is the
215
+ * collapse contract. Expanded reflows with pi-tui's `wrapTextWithAnsi` — the call the table and code-panel
216
+ * surfaces already spend their cells with — while truncation stays only as the backstop for a slice that
217
+ * no width can hold (table.ts:187's rule). A line within budget passes through untouched either way.
218
+ *
219
+ * shape: closure returning an object literal — trigger #4: one stored pad and one spending mode behind
220
+ * render/setPad/setWrap.
206
221
  */
207
- function padComponent(inner: UiComponent, pad: number): PaddedText {
222
+ function padComponent(inner: UiComponent, pad: number, wrap: boolean): PaddedText {
208
223
  let indent = pad;
224
+ let wrapping = wrap;
209
225
  return {
210
226
  render(width: number): string[] {
211
- const lines = inner.render(Math.max(0, width - indent));
227
+ const budget = Math.max(0, width - indent);
228
+ const lines = inner.render(budget).flatMap((line) => {
229
+ if (visibleWidth(line) <= budget) return [line];
230
+ // Collapsed cuts, expanded reflows (see the block comment): only an over-wide line is spent.
231
+ const spent = wrapping && budget > 0 ? wrapTextWithAnsi(line, budget) : [truncateToWidth(line, budget, "…")];
232
+ // Backstop: a wrapped slice can still exceed the budget (a wide grapheme), and a line no
233
+ // width can hold is cut rather than left to run past the edge (#56).
234
+ return spent.map((piece) => (visibleWidth(piece) > budget ? truncateToWidth(piece, budget, "…") : piece));
235
+ });
212
236
  if (indent === 0) return lines;
213
237
  const spaces = " ".repeat(indent);
214
238
  return lines.map((line) => spaces + line);
@@ -222,10 +246,13 @@ function padComponent(inner: UiComponent, pad: number): PaddedText {
222
246
  setPad(value: number): void {
223
247
  indent = value;
224
248
  },
249
+ setWrap(value: boolean): void {
250
+ wrapping = value;
251
+ },
225
252
  };
226
253
  }
227
254
 
228
- function textFor(ctx: RenderContext, body: string): UiComponent {
255
+ function textFor(ctx: RenderContext, body: string, wrap: boolean): UiComponent {
229
256
  const pad = padOf(ctx);
230
257
  const reuse = ctx.lastComponent;
231
258
  // Only a component this runtime wrapped can be told a new pad, so it is the only one it reuses. A
@@ -234,6 +261,7 @@ function textFor(ctx: RenderContext, body: string): UiComponent {
234
261
  if (reuse !== undefined && isPaddedText(reuse)) {
235
262
  reuse.setText(body);
236
263
  reuse.setPad(pad);
264
+ reuse.setWrap(wrap);
237
265
  return reuse;
238
266
  }
239
267
  // A host text component in the slot is still worth mutating in place: it caches its own frame, and
@@ -241,9 +269,9 @@ function textFor(ctx: RenderContext, body: string): UiComponent {
241
269
  const setText = reuse?.setText;
242
270
  if (reuse !== undefined && setText !== undefined) {
243
271
  setText.call(reuse, body);
244
- return padComponent(reuse, pad);
272
+ return padComponent(reuse, pad, wrap);
245
273
  }
246
- return padComponent(textComponent(body), pad);
274
+ return padComponent(textComponent(body), pad, wrap);
247
275
  }
248
276
 
249
277
  /**
@@ -374,11 +402,13 @@ export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory
374
402
  const ink = ctx.isError === true ? paint.error : ctx.isPartial === true ? paint.dim : paint.success;
375
403
  const title = ink(model.title);
376
404
  // Collapsed rows are one line: the summary the result slot settled rides this title line.
377
- const summary = ctx.expanded === true ? undefined : readSummary(ctx);
378
- return textFor(ctx, summary === undefined ? title : `${title} · ${summary}`);
405
+ // Expanded reflows, so a long title stays readable in full; collapsed cuts to its one line.
406
+ const expanded = ctx.expanded === true;
407
+ const summary = expanded ? undefined : readSummary(ctx);
408
+ return textFor(ctx, summary === undefined ? title : `${title} · ${summary}`, expanded);
379
409
  } catch (err) {
380
410
  logFailure(deps.log, ctx, err);
381
- return textFor(ctx, "");
411
+ return textFor(ctx, "", ctx.expanded === true);
382
412
  }
383
413
  };
384
414
 
@@ -391,10 +421,11 @@ export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory
391
421
  try {
392
422
  const paint = deps.paint(theme);
393
423
  const model = modelFor(result, ctx);
394
- // A degraded row keeps today's one raw line and settles no summary for the call slot to adopt.
395
- if (model === UNKNOWN) return textFor(ctx, rawBody(result, paint));
424
+ // A degraded row keeps the raw one-line body and settles no summary for the call slot to adopt;
425
+ // an expanded frame spends that line by the same reflow rule as any other.
426
+ if (model === UNKNOWN) return textFor(ctx, rawBody(result, paint), options.expanded === true);
396
427
  const body = spec.project(model, viewOf(options, ctx), paint);
397
- if (options.expanded) return textFor(ctx, body);
428
+ if (options.expanded) return textFor(ctx, body, true);
398
429
  // Collapsed: hand the one-line summary to the call slot through the shared state bag and draw no
399
430
  // line here. Redraw only when it changed — an unchanged summary is not a new frame.
400
431
  if (readSummary(ctx) !== body) {
@@ -405,7 +436,7 @@ export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory
405
436
  } catch (err) {
406
437
  // The paint may itself be the thrower, so the degraded row is built without it.
407
438
  logFailure(deps.log, ctx, err);
408
- return textFor(ctx, rawBody(result, undefined));
439
+ return textFor(ctx, rawBody(result, undefined), options.expanded === true);
409
440
  }
410
441
  };
411
442