@wierdbytes/pi-facelift 0.1.0 → 0.1.1

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.
@@ -166,4 +166,169 @@ describe("bash renderCall expansion", () => {
166
166
  }
167
167
  });
168
168
  });
169
+
170
+ it("keeps trailing dashes on the first line for multi-line commands", () => {
171
+ withStdoutColumns(120, () => {
172
+ const bashTool = loadBashTool();
173
+ // User-typed multi-line command (line continuation with `\`)
174
+ const command = `cd /tmp && \\\n echo "hello"`;
175
+
176
+ const rendered = bashTool.renderCall({ command }, mockTheme, {
177
+ lastComponent: new MockText(),
178
+ isError: false,
179
+ state: {},
180
+ expanded: true,
181
+ invalidate: () => {},
182
+ });
183
+
184
+ const lines = rendered.getText().split("\n");
185
+ expect(lines.length).toBeGreaterThanOrEqual(2);
186
+
187
+ // First line: top border with `╭── bash cd /tmp && \` and trailing dashes.
188
+ expect(lines[0]).toMatch(/^.*╭──.*bash.*cd \/tmp && \\.*─/);
189
+ expect(lines[0]).toContain("\\");
190
+ expect(lines[0]).toMatch(/─{2,}/); // trailing dashes survive on first line
191
+
192
+ // Continuation line: outer rail + sub-tree connector + content (no trailing dashes).
193
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
194
+ const plainCont = stripAnsi(lines[1]);
195
+ expect(plainCont).toMatch(/^│/);
196
+ expect(plainCont).toMatch(/[│╰] /); // sub-tree connector before content
197
+ expect(plainCont).toContain("echo");
198
+ expect(plainCont).not.toMatch(/─{2,}/);
199
+ });
200
+ });
201
+
202
+ it("renders multi-line commands as a sub-tree with `│`/`╰` connectors", () => {
203
+ withStdoutColumns(200, () => {
204
+ const bashTool = loadBashTool();
205
+ const command = `cd /tmp && \\\n echo "line 1" \\\n echo "line 2"`;
206
+
207
+ const rendered = bashTool.renderCall({ command }, mockTheme, {
208
+ lastComponent: new MockText(),
209
+ isError: false,
210
+ state: {},
211
+ expanded: true,
212
+ invalidate: () => {},
213
+ });
214
+
215
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
216
+ const plain = rendered.getText().split("\n").map(stripAnsi);
217
+
218
+ expect(plain.length).toBe(3);
219
+ // Non-last continuation row uses `│`.
220
+ expect(plain[1]).toMatch(/^│ +│ echo "line 1" \\$/);
221
+ // Last continuation row uses `╰`.
222
+ expect(plain[2]).toMatch(/^│ +╰ echo "line 2"$/);
223
+ });
224
+ });
225
+
226
+ it("aligns sub-tree continuations under the first arg, ignoring heredoc indent", () => {
227
+ withStdoutColumns(120, () => {
228
+ const bashTool = loadBashTool();
229
+ // Continuation has 4 leading spaces — those should be stripped so the
230
+ // sub-tree connector aligns the content under the first row's first arg.
231
+ const command = "cd /tmp && \\\n echo hi";
232
+
233
+ const rendered = bashTool.renderCall({ command }, mockTheme, {
234
+ lastComponent: new MockText(),
235
+ isError: false,
236
+ state: {},
237
+ expanded: true,
238
+ invalidate: () => {},
239
+ });
240
+
241
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
242
+ const [firstLine, secondLine] = rendered.getText().split("\n").map(stripAnsi);
243
+
244
+ // `╭── bash ` is 9 chars, so `c` of `cd` is at col 10 (0-indexed: 9).
245
+ const firstArgCol = firstLine.indexOf("cd /tmp");
246
+ expect(firstArgCol).toBe(9);
247
+
248
+ // Continuation: `│` (col 0) + 6 spaces + connector (col 7) + space (col 8) + content (col 9).
249
+ // Content col must equal the first row's first arg col, regardless of
250
+ // the user's heredoc indent (4 leading spaces above are stripped).
251
+ const echoCol = secondLine.indexOf("echo");
252
+ expect(echoCol).toBe(firstArgCol);
253
+ });
254
+ });
255
+
256
+ it("colors continuation lines with the same accent as the first arg", () => {
257
+ withStdoutColumns(120, () => {
258
+ const bashTool = loadBashTool();
259
+ const accentTheme = {
260
+ fg: (key: string, text: string) => (key === "accent" ? `\x1b[36m${text}\x1b[0m` : `\x1b[31m${text}\x1b[0m`),
261
+ bold: (text: string) => `\x1b[1m${text}\x1b[22m`,
262
+ };
263
+ const command = "cd /tmp && \\\n echo hi";
264
+
265
+ const rendered = bashTool.renderCall({ command }, accentTheme, {
266
+ lastComponent: new MockText(),
267
+ isError: false,
268
+ state: {},
269
+ expanded: true,
270
+ invalidate: () => {},
271
+ });
272
+
273
+ const lines = rendered.getText().split("\n");
274
+ // First-row args are wrapped in the accent open code (`\x1b[36m`); the
275
+ // continuation row must independently carry the same accent wrap so the
276
+ // rendered text doesn't fall back to the terminal's default fg.
277
+ expect(lines[0]).toContain("\x1b[36mcd /tmp");
278
+ expect(lines[1]).toContain("\x1b[36mecho hi");
279
+ });
280
+ });
281
+ });
282
+
283
+ describe("bash renderResult body rails", () => {
284
+ it("keeps the left rail on lines that contain a `\\r` (terminal-style overwrite)", () => {
285
+ withStdoutColumns(120, () => {
286
+ const bashTool = loadBashTool();
287
+
288
+ // Simulates `git rebase` output, which prints `Rebasing (1/1)\rSuccessfully …`
289
+ const text = [
290
+ "=== before ===",
291
+ "abc1234 some change",
292
+ "Rebasing (1/1)\rSuccessfully rebased and updated refs/heads/master.",
293
+ "=== after ===",
294
+ ].join("\n");
295
+
296
+ const result = {
297
+ content: [{ type: "text", text }],
298
+ details: { _type: "bashResult", text, exitCode: 0, command: "git pull --rebase" },
299
+ };
300
+
301
+ const rendered = bashTool.renderResult(
302
+ result as any,
303
+ { isPartial: false } as any,
304
+ mockTheme,
305
+ {
306
+ lastComponent: new MockText(),
307
+ isError: false,
308
+ state: {},
309
+ expanded: true,
310
+ invalidate: () => {},
311
+ } as any,
312
+ );
313
+
314
+ const out = rendered.getText() as string;
315
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
316
+ const lines = out.split("\n").map(stripAnsi);
317
+
318
+ // Find the line that ends with the success message — it must be rail-prefixed,
319
+ // not raw `Successfully…` (which would mean the rail got clobbered).
320
+ const successLine = lines.find((l) => l.includes("Successfully rebased"));
321
+ expect(successLine).toBeDefined();
322
+ expect(successLine!.startsWith("│")).toBe(true);
323
+ // `\r` and the discarded progress prefix are stripped from the rendered line.
324
+ expect(successLine!).not.toContain("\r");
325
+ expect(successLine!).not.toContain("Rebasing (1/1)");
326
+
327
+ // Sanity check: every body line keeps a rail prefix.
328
+ const bodyLines = lines.filter((l) => !l.startsWith("╭") && !l.startsWith("╰") && l.length > 0);
329
+ for (const line of bodyLines) {
330
+ expect(line.startsWith("│")).toBe(true);
331
+ }
332
+ });
333
+ });
169
334
  });
package/index.ts CHANGED
@@ -250,27 +250,112 @@ function bodyW(): number {
250
250
  return Math.max(1, termW() - 1);
251
251
  }
252
252
 
253
+ /**
254
+ * Find the visible column of the first space character in an ANSI-formatted
255
+ * string, ignoring ANSI escape sequences. Returns -1 if no space is found
256
+ * before a newline (or end of string).
257
+ *
258
+ * Used by `frameTop` to align continuation rows under the first argument of
259
+ * the tool title (e.g., the column where `cd` starts in `bash cd …`).
260
+ */
261
+ function firstVisibleSpace(s: string): number {
262
+ let visiblePos = 0;
263
+ let i = 0;
264
+ while (i < s.length) {
265
+ const ch = s[i];
266
+ if (ch === "\x1b") {
267
+ const next = s[i + 1];
268
+ if (next === "[") {
269
+ const end = s.indexOf("m", i + 2);
270
+ if (end < 0) return -1;
271
+ i = end + 1;
272
+ continue;
273
+ }
274
+ // Unknown escape — bail out
275
+ return -1;
276
+ }
277
+ if (ch === "\n") return -1;
278
+ if (ch === " ") return visiblePos;
279
+ visiblePos += 1;
280
+ i += 1;
281
+ }
282
+ return -1;
283
+ }
284
+
253
285
  /**
254
286
  * Render the top border of the frame: `╭── <title> ─────`.
255
287
  * The title may contain ANSI codes; visible width is measured with pi-tui.
256
288
  * Truncated with `…` if it would overflow the terminal width.
289
+ *
290
+ * Multi-line titles (e.g., a multi-line bash command) are rendered with the
291
+ * first line as the top border and remaining lines as rail-prefixed
292
+ * continuation rows in a sub-tree:
293
+ *
294
+ * ╭── bash cd /…/pi-wierd-stuff && \ ──────────────
295
+ * │ │ echo "line 1" \
296
+ * │ ╚ echo "line 2"
297
+ *
298
+ * Each continuation row is composed of:
299
+ * • the outer frame rail `│` (frame status color)
300
+ * • padding sized so the sub-tree connector lines up with the first arg
301
+ * character of the first row
302
+ * • a sub-tree connector (`│` for non-last rows, `╰` for the last)
303
+ * • a single separator space
304
+ * • the row's content (callers are expected to wrap each line in the
305
+ * same color as the first-row args so the sub-tree text matches)
306
+ *
307
+ * Trailing dashes are only emitted on the first row so continuation rows
308
+ * stay clean.
257
309
  */
258
310
  function frameTop(titleAnsi: string, status: FrameStatus): string {
259
311
  const color = getFrameColor(status);
260
312
  const w = termW();
313
+ const titleLines = titleAnsi.split("\n");
314
+ const firstTitle = titleLines[0];
315
+ const continuations = titleLines.slice(1);
316
+
261
317
  // Layout: `╭` (1) + `──` (2) + ` ` (1) + title + ` ` (1) + trailing dashes (≥1)
262
318
  const minTrailing = 1;
263
319
  const fixed = 1 + 2 + 1 + 1 + minTrailing;
264
320
  const maxTitleW = Math.max(0, w - fixed);
265
321
 
266
- let title = titleAnsi;
267
- let tw = visibleWidth(titleAnsi);
322
+ let title = firstTitle;
323
+ let tw = visibleWidth(firstTitle);
268
324
  if (tw > maxTitleW) {
269
- title = truncateToWidth(titleAnsi, maxTitleW, "…");
325
+ title = truncateToWidth(firstTitle, maxTitleW, "…");
270
326
  tw = visibleWidth(title);
271
327
  }
272
328
  const trailing = Math.max(minTrailing, w - 1 - 2 - 1 - tw - 1);
273
- return `${color}╭──${RST} ${title} ${color}${"─".repeat(trailing)}${RST}`;
329
+ const firstLine = `${color}╭──${RST} ${title} ${color}${"─".repeat(trailing)}${RST}`;
330
+
331
+ if (continuations.length === 0) return firstLine;
332
+
333
+ // Continuation rows: rail + padding + sub-tree connector + space + content.
334
+ // First line layout:
335
+ // col 1: `╭`
336
+ // cols 2-3: `──`
337
+ // col 4: literal space
338
+ // cols 5..: title (toolName + space + args…)
339
+ // If the toolName ends at visible-offset N (i.e., the space is at offset N),
340
+ // the first arg char sits at column N + 6 (1-indexed). We align the sub-tree
341
+ // connector two columns before it (at contentCol - 2) and put the content
342
+ // itself at the same column as the first row's first arg char, so the rows
343
+ // read as a tree hanging off the title.
344
+ const toolNameEnd = firstVisibleSpace(firstTitle);
345
+ const contentCol = toolNameEnd >= 0 ? toolNameEnd + 6 : 6;
346
+ const padBetween = " ".repeat(Math.max(0, contentCol - 4));
347
+ const rail = `${color}│${RST}`;
348
+ const innerW = Math.max(1, w - contentCol + 1);
349
+ const lastIdx = continuations.length - 1;
350
+
351
+ const contLines = continuations.map((line, idx) => {
352
+ const connectorChar = idx === lastIdx ? "╰" : "│";
353
+ const connector = `${color}${connectorChar}${RST}`;
354
+ const fitted = visibleWidth(line) > innerW ? truncateToWidth(line, innerW, "…") : line;
355
+ return `${rail}${padBetween}${connector} ${fitted}`;
356
+ });
357
+
358
+ return [firstLine, ...contLines].join("\n");
274
359
  }
275
360
 
276
361
  /** Render the bottom border of the frame: `╰─────────────`. */
@@ -306,6 +391,13 @@ function frameBottomWithLabel(labelAnsi: string, status: FrameStatus): string {
306
391
  * Prefix every line in `text` with the frame's left rail (`│`).
307
392
  * Each logical line is right-truncated to `bodyW()` so the visible width
308
393
  * including the rail never exceeds `termW()`.
394
+ *
395
+ * Embedded carriage returns (`\r`) are collapsed using terminal-style
396
+ * overwrite semantics: only the content after the last `\r` on a line is
397
+ * kept. Without this, sequences like `git rebase`'s
398
+ * `Rebasing (1/1)\rSuccessfully rebased…` would clobber the rail when the
399
+ * terminal honors `\r` as cursor-reset, leaving the line visibly
400
+ * unprefixed.
309
401
  */
310
402
  function frameBodyLines(text: string, status: FrameStatus): string {
311
403
  const color = getFrameColor(status);
@@ -314,7 +406,11 @@ function frameBodyLines(text: string, status: FrameStatus): string {
314
406
  return text
315
407
  .split("\n")
316
408
  .map((line) => {
317
- const fitted = line && visibleWidth(line) > w ? truncateToWidth(line, w, "") : line;
409
+ // Terminal-style \r handling: keep only what survives after the last
410
+ // carriage return so the rail isn't overwritten by progress messages.
411
+ const lastCR = line.lastIndexOf("\r");
412
+ const safeLine = lastCR >= 0 ? line.slice(lastCR + 1) : line;
413
+ const fitted = safeLine && visibleWidth(safeLine) > w ? truncateToWidth(safeLine, w, "") : safeLine;
318
414
  return `${rail}${fitted}`;
319
415
  })
320
416
  .join("\n");
@@ -1226,7 +1322,18 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1226
1322
  }
1227
1323
  const timeout = args.timeout ? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}` : "";
1228
1324
  const displayCmd = ctx.expanded || cmd.length <= 80 ? cmd : `${cmd.slice(0, 77)}…`;
1229
- const title = `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", displayCmd)}${timeout}`;
1325
+
1326
+ // Multi-line bash commands (shell line continuations with `\`, here-docs,
1327
+ // or embedded newlines) need each line wrapped in the accent color
1328
+ // separately so the color survives line splitting in `frameTop`. Leading
1329
+ // whitespace on continuation lines is dropped — the sub-tree connector
1330
+ // already provides visual indentation, so the user's heredoc indent
1331
+ // would just push content right and misalign the tree.
1332
+ const cmdLines = displayCmd.split("\n");
1333
+ const firstCmd = cmdLines[0];
1334
+ const restCmd = cmdLines.slice(1).map((line) => line.replace(/^\s+/, ""));
1335
+ const firstTitle = `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", firstCmd)}${timeout}`;
1336
+ const title = [firstTitle, ...restCmd.map((line) => theme.fg("accent", line))].join("\n");
1230
1337
  t.setText(frameTop(title, getFrameStatus(ctx)));
1231
1338
  return t;
1232
1339
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wierdbytes/pi-facelift",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Cosmetic facelift for built-in pi tool output — open-right rounded frames, syntax-highlighted reads, status-aware bash summaries, find/grep.",
5
5
  "type": "module",
6
6
  "files": [
package/scripts/demo.ts CHANGED
@@ -260,6 +260,56 @@ async function demoBashTimeout(): Promise<void> {
260
260
  );
261
261
  }
262
262
 
263
+ async function demoBashMultiline(): Promise<void> {
264
+ header("bash multi-line command — sub-tree title + \\r-safe body rails");
265
+ // Multi-line shell command (line continuations with `\`). The title
266
+ // renders as a sub-tree with `│`/`╰` connectors aligned under the first
267
+ // arg of the first row; continuation lines stay in the same accent
268
+ // color as the first row.
269
+ const command = [
270
+ "cd /Users/mentor/me/dev/pi-wierd-stuff && \\",
271
+ ' echo "=== before ===" && \\',
272
+ " git log --oneline -3 origin/master && \\",
273
+ " git pull --rebase && \\",
274
+ ' echo "=== after ==="',
275
+ ].join("\n");
276
+
277
+ // Body intentionally contains `Rebasing (1/1)\rSuccessfully rebased…`,
278
+ // the exact pattern `git rebase` writes to stderr. Without terminal-style
279
+ // `\r` handling, the embedded carriage return would clobber the `│` rail
280
+ // when the terminal honors `\r` as cursor-reset.
281
+ const bodyText = [
282
+ "=== before ===",
283
+ "981c12d facelift: add @wierdbytes/pi-facelift v0.1.0",
284
+ "4517d9c README: fix stale npm package names",
285
+ "046a5c2 web: rename slash command from /wierd-web to /web, bump to 0.3.1",
286
+ "Rebasing (1/1)\rSuccessfully rebased and updated refs/heads/master.",
287
+ "=== after ===",
288
+ "8770a5e facelift: add @wierdbytes/pi-facelift v0.1.0",
289
+ "046a5c2 web: rename slash command from /wierd-web to /web, bump to 0.3.1",
290
+ "f87cbcf voice: rename slash command from /wierd-voice to /voice, bump to 0.4.1",
291
+ ].join("\n");
292
+
293
+ const tools = loadTools({
294
+ bash: async () => ({
295
+ content: [{ type: "text", text: bodyText }],
296
+ details: { _type: "bashResult", text: bodyText, exitCode: 0, command },
297
+ }),
298
+ });
299
+ const bash = tools.get("bash");
300
+ const state: { startedAt?: number; endedAt?: number } = { startedAt: Date.now() - 1200 };
301
+
302
+ const callCtx = makeCtx({ state, expanded: true });
303
+ console.log(bash.renderCall({ command }, theme, callCtx).getText());
304
+
305
+ const result = await bash.execute("t1", { command }, undefined, undefined, {});
306
+ state.endedAt = Date.now();
307
+ const resCtx = makeCtx({ state, expanded: true });
308
+ console.log(
309
+ bash.renderResult(result, { isPartial: false, expanded: true }, theme, resCtx).getText(),
310
+ );
311
+ }
312
+
263
313
  async function demoLs(): Promise<void> {
264
314
  header("ls — tree view with Nerd Font icons");
265
315
  const tools = loadTools({
@@ -344,6 +394,7 @@ async function main(): Promise<void> {
344
394
  await demoBashError();
345
395
  await demoBashStreaming();
346
396
  await demoBashTimeout();
397
+ await demoBashMultiline();
347
398
  await demoLs();
348
399
  await demoFind();
349
400
  await demoGrep();