pi-odd-worktree 0.0.0-stage → 0.1.0

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.
Files changed (60) hide show
  1. package/README.md +223 -2
  2. package/extensions/odd-worktree-tools.ts +7593 -0
  3. package/lib/checkout-facts.js +598 -0
  4. package/lib/clipboard.js +226 -0
  5. package/lib/gh-issues.js +1555 -0
  6. package/lib/issue-preview.js +273 -0
  7. package/lib/issues-view.js +2274 -0
  8. package/lib/odd-docs.js +505 -0
  9. package/lib/odd-markdown.js +413 -0
  10. package/lib/odd-view.js +990 -0
  11. package/lib/open-external.js +106 -0
  12. package/lib/preview-scroll-keys.js +192 -0
  13. package/lib/session-auto-name.js +292 -0
  14. package/lib/session-worktree-register.js +62 -0
  15. package/lib/tui-paint.js +273 -0
  16. package/lib/view-layout.js +368 -0
  17. package/lib/warp-create-panel.js +722 -0
  18. package/lib/warp-create-picker.js +906 -0
  19. package/lib/warp-delete-confirm.js +465 -0
  20. package/lib/warp-selector-view.js +1265 -0
  21. package/lib/worktree-card.js +1117 -0
  22. package/lib/worktree-changes.js +1395 -0
  23. package/lib/worktree-close-herdr.js +73 -0
  24. package/lib/worktree-git.js +1048 -0
  25. package/lib/worktree-hooks.js +738 -0
  26. package/lib/worktree-model-result.js +291 -0
  27. package/lib/worktree-registry.js +886 -0
  28. package/lib/worktree-session-guard.js +595 -0
  29. package/lib/worktree-symlinks.js +336 -0
  30. package/lib/worktree-warp-sessions.js +566 -0
  31. package/package.json +36 -4
  32. package/tools/commands.test.js +176 -0
  33. package/tools/fixtures/gh-api-user.json +1 -0
  34. package/tools/fixtures/gh-issue-list.json +1 -0
  35. package/tools/fixtures/gh-issue-view.json +1 -0
  36. package/tools/fixtures/gh-label-list.json +1 -0
  37. package/tools/fixtures/gh-repo-view.json +1 -0
  38. package/tools/fixtures/load-extension.js +66 -0
  39. package/tools/gh-issues-feedback.test.js +359 -0
  40. package/tools/gh-issues.test.js +1502 -0
  41. package/tools/issue-preview.test.js +216 -0
  42. package/tools/issues-command.test.js +1217 -0
  43. package/tools/issues-view.test.js +4401 -0
  44. package/tools/odd-docs.test.js +803 -0
  45. package/tools/odd-markdown.test.js +236 -0
  46. package/tools/odd-view.test.js +2328 -0
  47. package/tools/package-surface.test.js +138 -0
  48. package/tools/t5-session-identity.test.js +306 -0
  49. package/tools/warp-create-panel.test.js +185 -0
  50. package/tools/warp-create-picker.test.js +1357 -0
  51. package/tools/warp-delete-confirm.test.js +616 -0
  52. package/tools/warp-selector-view.test.js +1463 -0
  53. package/tools/worktree-changes.test.js +951 -0
  54. package/tools/worktree-close-herdr-async.test.js +204 -0
  55. package/tools/worktree-odd-migration.test.js +224 -0
  56. package/tools/worktree-registry-cache.test.js +163 -0
  57. package/tools/worktree-registry-odd.test.js +231 -0
  58. package/tools/worktree-registry.test.js +244 -0
  59. package/tools/worktree-session-guard.test.js +507 -0
  60. package/tools/worktree-tools.test.js +434 -0
@@ -0,0 +1,1117 @@
1
+ 'use strict';
2
+ // Reusable native Pi worktree tool card renderer (T6/S13).
3
+ //
4
+ // One factory per tool — `createWorktreeCardRenderer(toolName, deps)` —
5
+ // returning the three render hooks the parent extension spreads over its
6
+ // tool definitions (`renderShell: "self"`, `renderCall`, `renderResult`),
7
+ // so execute/schema/content/details stay exactly where they are. Compact
8
+ // collapsed cards match the gentle-shell reference: an accent left border,
9
+ // a readable operation header, a ctrl+o expand hint on that header, a
10
+ // short result, and a duration only when lifecycle timing evidence is
11
+ // supplied — that duration is the only thing the closing rule carries.
12
+ //
13
+ // S12 split of the two states: the collapsed body carries only the action
14
+ // and the identity it acted on (no full path and no thread id), while the
15
+ // expanded body formats the SAME details into ordered `label: value` rows —
16
+ // status, path, branch, warnings — instead of echoing the raw content dump.
17
+ // S1 removed issue and ODD associations from every worktree surface, so no
18
+ // card row mentions them: the registry still stores those legacy fields and
19
+ // the details may still carry them, but they are data preservation, never
20
+ // display. The content text is only the fallback for a result the details
21
+ // cannot describe. Failures always keep their honest prose.
22
+ //
23
+ // S6 worktree list: the expanded listing is a LIST of entry blocks (one
24
+ // blank row between them, one identity row per checkout) and never prints a
25
+ // `live`/`exists` status — being listed is the existence proof — while the
26
+ // statuses that carry information (`missing`, `unregistered`) ride the
27
+ // trailing cell of the entry's own identity row instead of costing a row.
28
+ //
29
+ // S19 worktree identity: one git-branch glyph for every tool and the
30
+ // git-orange tone on the opening rule. The installed Pi theme schema
31
+ // exposes no orange role, so the closest documented role — `warning`
32
+ // (yellow in the built-in themes) — carries the tone; failures keep the
33
+ // `error` role and the running marker keeps `muted`. No other role is
34
+ // ever passed to theme.fg.
35
+ //
36
+ // No new dependencies and no imports of package-private reference
37
+ // modules: width and key-hint helpers arrive via injected `deps`
38
+ // ({visibleWidth, truncateToWidth, keyHint}) with small local fallbacks,
39
+ // consistent with the libRequire architecture. Every hook returns a plain
40
+ // Pi component ({render(width), invalidate}) and never throws: untrusted
41
+ // strings (branch names, paths, result text) are sanitized so no raw ANSI
42
+ // or control character can leak into or reshape the transcript.
43
+
44
+ const WORKTREE_TOOLS = new Set([
45
+ 'worktree_create',
46
+ 'worktree_list',
47
+ 'worktree_close',
48
+ 'worktree_session_open',
49
+ 'worktree_warp',
50
+ ]);
51
+
52
+ // One branch glyph for all five tools (S19): the worktree tools share a
53
+ // single git identity instead of four unrelated marks. U+2387 renders as a
54
+ // single column through both the local fallback and pi-tui measurement.
55
+ const GLYPH = {
56
+ worktree_create: '⎇',
57
+ worktree_list: '⎇',
58
+ worktree_close: '⎇',
59
+ worktree_session_open: '⎇',
60
+ worktree_warp: '⎇',
61
+ };
62
+
63
+ const TITLE = {
64
+ worktree_create: 'worktree create',
65
+ worktree_list: 'worktree list',
66
+ worktree_close: 'worktree close',
67
+ worktree_session_open: 'worktree session open',
68
+ worktree_warp: 'worktree warp',
69
+ };
70
+
71
+ // Collapsed budget: header + preview rows + closing rule. Operate-mode
72
+ // scanability: the transcript stays readable with five tools chattering.
73
+ const COLLAPSED_PREVIEW_ROWS = 3;
74
+ const ACCENT_BAR = '▎';
75
+ const RUNNING_MARK = '…';
76
+ const EMPTY_TEXT = '(no output)';
77
+
78
+ // Matches ANSI SGR escapes and OSC 8 hyperlinks; control characters and
79
+ // DEL are stripped separately so multi-line structure (\n, \t) survives.
80
+ const ANSI_PATTERN = /\x1b\[[0-9;]*m|\x1b\]8;;.*?\x07/g;
81
+ // eslint-disable-next-line no-control-regex
82
+ const CONTROL_PATTERN = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
83
+
84
+ function stripAnsi(value) {
85
+ return String(value).replace(ANSI_PATTERN, '');
86
+ }
87
+
88
+ function safeText(value) {
89
+ if (typeof value !== 'string') return '';
90
+ return stripAnsi(value).replace(CONTROL_PATTERN, '');
91
+ }
92
+
93
+ function asRecord(value) {
94
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) return value;
95
+ return {};
96
+ }
97
+
98
+ function oneLine(value, fallback) {
99
+ const line = safeText(value).split('\n')[0].trim().replace(/\s+/g, ' ');
100
+ return line.length > 0 ? line : fallback;
101
+ }
102
+
103
+ function baseName(value) {
104
+ const text = safeText(value).replace(/\/+$/, '');
105
+ if (text.length === 0) return '';
106
+ const parts = text.split('/');
107
+ return parts[parts.length - 1];
108
+ }
109
+
110
+ // Minimal column-width measurement for the local fallback (used only
111
+ // when the host injects no helpers): wide East-Asian/emoji code points
112
+ // occupy two columns, combining marks none. Runtime rendering goes
113
+ // through the real pi-tui visibleWidth; this keeps the fallback honest
114
+ // at narrow widths instead of counting code points.
115
+ function charWidth(code) {
116
+ if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return 0;
117
+ if (
118
+ (code >= 0x300 && code <= 0x36f) || (code >= 0x1ab0 && code <= 0x1aff) ||
119
+ (code >= 0x1dc0 && code <= 0x1dff) || (code >= 0x20d0 && code <= 0x20ff) ||
120
+ (code >= 0xfe20 && code <= 0xfe2f)
121
+ ) return 0;
122
+ if (
123
+ (code >= 0x1100 && code <= 0x115f) || code === 0x2329 || code === 0x232a ||
124
+ (code >= 0x2e80 && code <= 0x303e) || (code >= 0x3041 && code <= 0xa4cf) ||
125
+ (code >= 0xac00 && code <= 0xd7a3) || (code >= 0xf900 && code <= 0xfaff) ||
126
+ (code >= 0xfe30 && code <= 0xfe4f) || (code >= 0xff00 && code <= 0xff60) ||
127
+ (code >= 0xffe0 && code <= 0xffe6) || (code >= 0x1f300 && code <= 0x1faff) ||
128
+ (code >= 0x20000 && code <= 0x3fffd)
129
+ ) return 2;
130
+ return 1;
131
+ }
132
+
133
+ function localVisibleWidth(line) {
134
+ let width = 0;
135
+ for (const char of stripAnsi(line)) width += charWidth(char.codePointAt(0));
136
+ return width;
137
+ }
138
+
139
+ function localTruncate(line, width, ellipsis = '…') {
140
+ if (localVisibleWidth(line) <= width) return line;
141
+ if (width <= 0) return '';
142
+ const mark = localVisibleWidth(ellipsis) >= width ? '' : ellipsis;
143
+ const budget = width - localVisibleWidth(mark);
144
+ let used = 0;
145
+ let out = '';
146
+ for (const char of line) {
147
+ const next = used + charWidth(char.codePointAt(0));
148
+ if (next > budget) break;
149
+ out += char;
150
+ used = next;
151
+ }
152
+ return out + mark;
153
+ }
154
+
155
+ function resolveHelpers(deps) {
156
+ const d = asRecord(deps);
157
+ return {
158
+ visibleWidth: typeof d.visibleWidth === 'function' ? d.visibleWidth : localVisibleWidth,
159
+ truncateToWidth: typeof d.truncateToWidth === 'function' ? d.truncateToWidth : localTruncate,
160
+ keyHint: typeof d.keyHint === 'function' ? d.keyHint : undefined,
161
+ // Click-to-expand seam: the extension injects the callback that flips the
162
+ // host's GLOBAL tool-expansion state (ctx.ui.setToolsExpanded(!ctx.ui
163
+ // .getToolsExpanded())). It is optional — with no callback the cards stay
164
+ // keyboard-only and expose no mouse handler at all.
165
+ toggleExpanded: typeof d.toggleExpanded === 'function' ? d.toggleExpanded : undefined,
166
+ };
167
+ }
168
+
169
+ function themeFg(theme, role, text) {
170
+ try {
171
+ if (theme && typeof theme.fg === 'function') return theme.fg(role, text);
172
+ } catch {
173
+ // A hostile theme never breaks the card; fall through to plain text.
174
+ }
175
+ return text;
176
+ }
177
+
178
+ function expandHint(helpers, expanded) {
179
+ const suffix = expanded ? 'to collapse' : 'to expand';
180
+ if (helpers.keyHint) {
181
+ try {
182
+ const hinted = helpers.keyHint('app.tools.expand', suffix);
183
+ const clean = oneLine(hinted, '');
184
+ if (clean.length > 0) return clean;
185
+ } catch {
186
+ // Fall through to the static hint below.
187
+ }
188
+ }
189
+ return `ctrl+o ${suffix}`;
190
+ }
191
+
192
+ // Click-to-expand seam (opt-in, host-driven).
193
+ //
194
+ // The installed pi-tui delivers a click to the component that CLAIMED the
195
+ // press: tui-alt-screen.js records `mousePressTarget` from the press dispatch
196
+ // result and, on release at the same cell, synthesizes a `click` retargeted to
197
+ // that exact component. So the press must return { handled: true } or the click
198
+ // never arrives. Both host shells reach a card component: tool cards through
199
+ // tool-execution.js's MouseRegion (which dispatches to its child first) and
200
+ // message cards through CustomMessageComponent — a Container — forwarding to
201
+ // the renderer component.
202
+ //
203
+ // The card therefore claims the HINT's own cells and nothing else, and only a
204
+ // left press/click: everywhere else it returns undefined, so the host's own
205
+ // click behavior (the MouseRegion toggle, text selection, links) is exactly
206
+ // what it was. The callback flips the host's global expansion state — the
207
+ // renderer keeps no expansion state of its own, so the keyboard path
208
+ // (app.tools.expand / ctrl+o) stays the single source of truth.
209
+
210
+ // The hint's cell rectangle inside the rendered card: the row it was drawn on
211
+ // and its half-open column range. Read back from the rendered lines, so it
212
+ // follows the real geometry of both card styles (float heading band, outputPad
213
+ // margin, wrapped heading) instead of a second, drifting calculation. Only the
214
+ // card's top rule ever carries the hint text, and it is searched first, so the
215
+ // body can never masquerade as a click region.
216
+ function locateHint(lines, hint, helpers) {
217
+ const text = typeof hint === 'string' ? oneLine(hint, '') : '';
218
+ if (text.length === 0 || !Array.isArray(lines)) return undefined;
219
+ for (let row = 0; row < lines.length; row += 1) {
220
+ const plain = stripAnsi(String(lines[row]));
221
+ const index = plain.indexOf(text);
222
+ if (index < 0) continue;
223
+ const start = floatMeasure(plain.slice(0, index), helpers);
224
+ return { row, start, end: start + floatMeasure(text, helpers) };
225
+ }
226
+ return undefined;
227
+ }
228
+
229
+ function hintMouseEvent(event, rect, helpers) {
230
+ if (!rect) return undefined;
231
+ const e = asRecord(event);
232
+ if (e.button !== 'left') return undefined;
233
+ if (e.type !== 'press' && e.type !== 'click') return undefined;
234
+ if (typeof e.x !== 'number' || typeof e.y !== 'number') return undefined;
235
+ if (e.y !== rect.row) return undefined;
236
+ if (!(e.x >= rect.start && e.x < rect.end)) return undefined;
237
+ if (e.type === 'click') {
238
+ try {
239
+ helpers.toggleExpanded();
240
+ } catch {
241
+ // A hostile host callback never breaks the card or the transcript.
242
+ }
243
+ return { handled: true };
244
+ }
245
+ // The press CLAIMS the gesture so the host retargets the synthesized click
246
+ // here, and asks for no repaint so the row it just measured stays valid.
247
+ return { handled: true, render: false };
248
+ }
249
+
250
+ // The component shell every card returns: render once per width, remember where
251
+ // the hint landed, and — only when the toggle callback was injected — claim the
252
+ // mouse on exactly those cells.
253
+ function cardComponent({ helpers, hint, renderLines }) {
254
+ let hintRect;
255
+ const component = {
256
+ render(width) {
257
+ const lines = renderLines(width);
258
+ hintRect = locateHint(lines, hint, helpers);
259
+ return lines;
260
+ },
261
+ invalidate() {},
262
+ };
263
+ if (typeof helpers.toggleExpanded === 'function') {
264
+ component.handleMouse = (event) => hintMouseEvent(event, hintRect, helpers);
265
+ }
266
+ return component;
267
+ }
268
+
269
+ function formatDuration(durationMs) {
270
+ if (typeof durationMs !== 'number' || !Number.isFinite(durationMs) || durationMs < 0) return undefined;
271
+ if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
272
+ return `${(durationMs / 1000).toFixed(1)}s`;
273
+ }
274
+
275
+ // Duration reads ONLY from the host's ToolRenderContext.durationMs — the
276
+ // one timing field the installed Pi contract provides (milliseconds the
277
+ // execution took, undefined while it runs). ToolRenderResultOptions
278
+ // carries no timing, so options timing is never read. Nothing here starts
279
+ // a timer, so a card can never fabricate or leak one.
280
+ function durationOf(context) {
281
+ return formatDuration(asRecord(context).durationMs);
282
+ }
283
+
284
+ // Shared row mark (the reference's RESULT_MARK pattern): renderResult tags
285
+ // the host-owned context.state object, which Pi shares across the call
286
+ // and result renders of one execution. While unmarked, the call closes
287
+ // its own frame; once marked, the call drops to the top rule and the
288
+ // result body closes the single shared frame — never two full cards.
289
+ const RESULT_MARK = Symbol.for('herdr-gentle.worktree-card.result');
290
+
291
+ function markCardResult(state) {
292
+ if (state !== null && typeof state === 'object') {
293
+ try {
294
+ state[RESULT_MARK] = true;
295
+ } catch {
296
+ // A frozen state object never breaks the result render below.
297
+ }
298
+ }
299
+ }
300
+
301
+ function cardAwaitingResult(context) {
302
+ const state = asRecord(context).state;
303
+ if (state !== null && typeof state === 'object') {
304
+ try {
305
+ if (state[RESULT_MARK] === true) return false;
306
+ } catch {
307
+ // An unreadable state reads as awaiting: closed mini-card.
308
+ }
309
+ }
310
+ return true;
311
+ }
312
+
313
+ function resultTextOf(result) {
314
+ try {
315
+ const content = result && Array.isArray(result.content) ? result.content : [];
316
+ const texts = content
317
+ .filter((entry) => entry && entry.type === 'text' && typeof entry.text === 'string')
318
+ .map((entry) => safeText(entry.text));
319
+ return texts.join('\n');
320
+ } catch {
321
+ return '';
322
+ }
323
+ }
324
+
325
+ function isErrorResult(result, options, context) {
326
+ if (result && result.isError === true) return true;
327
+ if (asRecord(options).isError === true) return true;
328
+ if (asRecord(context).isError === true) return true;
329
+ return false;
330
+ }
331
+
332
+ // Tool-specific one-line summaries read from result.details — the same
333
+ // shapes execute() already returns. Unknown or malformed details fall back
334
+ // to the result's first line, never to a throw.
335
+ function summarizeWorktreeResult(toolName, result) {
336
+ const details = asRecord(result ? result.details : undefined);
337
+ const text = resultTextOf(result);
338
+ const firstLine = oneLine(text, EMPTY_TEXT);
339
+ const failed = result && result.isError === true;
340
+
341
+ switch (toolName) {
342
+ case 'worktree_create': {
343
+ const branch = oneLine(details.branch, '');
344
+ const target = baseName(details.path) || branch || firstLine;
345
+ if (failed) return branch.length > 0 ? `error · ${branch}` : `error · ${firstLine}`;
346
+ if (details.alreadyExists === true) return `already at ${target}`;
347
+ return branch.length > 0 ? branch : target;
348
+ }
349
+ case 'worktree_list': {
350
+ const count = typeof details.count === 'number' ? details.count : undefined;
351
+ if (failed) return `error · ${firstLine}`;
352
+ if (count === 0) return 'no worktrees';
353
+ if (count !== undefined) return `${count} checkout${count === 1 ? '' : 's'}`;
354
+ return firstLine;
355
+ }
356
+ case 'worktree_close': {
357
+ const target = baseName(details.path) || oneLine(details.branch, '') || firstLine;
358
+ if (failed) return `error · ${target}`;
359
+ return details.branchKept === false ? `removed ${target}` : `removed ${target} · branch kept`;
360
+ }
361
+ case 'worktree_session_open': {
362
+ const worktree = oneLine(details.worktree, '');
363
+ const session = oneLine(details.sessionId, '');
364
+ const name = [worktree, session].filter((part) => part.length > 0).join(' · ') || firstLine;
365
+ if (failed) return `error · ${name}`;
366
+ return details.resumed === true ? `${name} · resumed` : name;
367
+ }
368
+ case 'worktree_warp': {
369
+ const name = oneLine(details.name, '');
370
+ const branch = oneLine(details.branch, '');
371
+ const label = [name, branch].filter((part) => part.length > 0).join(' · ') || firstLine;
372
+ if (failed) return `error · ${label}`;
373
+ return details.resumed === true ? `${label} · resumed` : label;
374
+ }
375
+ default:
376
+ return firstLine;
377
+ }
378
+ }
379
+
380
+ // The S19 slug: mirrors lib/worktree-git.js slugifyBranch (lowercase,
381
+ // non-alphanumeric runs to one dash, trimmed, `worktree` fallback) and
382
+ // the extension's slugPart, so the card's compact rows name the same
383
+ // identifier the agent text and the listing print, without an import.
384
+ function slugify(value) {
385
+ try {
386
+ const slug = safeText(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
387
+ return slug === '' ? 'worktree' : slug;
388
+ } catch {
389
+ return 'worktree';
390
+ }
391
+ }
392
+
393
+ // A thread is worth a row only when it is real: the registry's `unknown`
394
+ // placeholder carries no information.
395
+ function threadValue(value) {
396
+ const thread = oneLine(value, '');
397
+ return thread.length > 0 && thread !== 'unknown' ? thread : '';
398
+ }
399
+
400
+ // The create outcome in the words the agent text uses: a call that found the
401
+ // thread's checkout already there must never be reported as a fresh create
402
+ // (S12: the card never hides what really happened).
403
+ function createStatus(details) {
404
+ return details.alreadyExists === true ? 'already existed' : 'created';
405
+ }
406
+
407
+ // S8: the collapsed create card is the STATUS ROW ONLY. The name is implied by
408
+ // the path and the branch is the tool's own input, so a collapsed
409
+ // `created: <slug> · <branch>` row repeated what the caller already knew. The
410
+ // identity, the path, the registry write and the CodeGraph state all live in
411
+ // the expanded detail.
412
+ function createHead(details) {
413
+ if (oneLine(details.branch, '').length === 0 && oneLine(details.path, '').length === 0) return '';
414
+ return `status: ${createStatus(details)}`;
415
+ }
416
+
417
+ // S6: the ONE CodeGraph fact of a create, read from `details.codegraph` (the
418
+ // extension publishes the creation hook's own `codegraph-init` outcome). The
419
+ // wording matches the agent text line exactly — the same status reads the same
420
+ // in both places, including when the card renderer itself is unavailable and
421
+ // the extension has to format it alone.
422
+ function codegraphValue(codegraph) {
423
+ const record = asRecord(codegraph);
424
+ const status = oneLine(record.status, '');
425
+ const detail = oneLine(record.detail, '');
426
+ if (status === 'done' || status === 'present') return 'initialized';
427
+ if (status === 'absent') return 'not initialized';
428
+ if (status === 'skipped' || status === 'failed') {
429
+ return detail.length > 0 ? `not initialized (${detail})` : 'not initialized';
430
+ }
431
+ if (status === 'not-run') return detail.length > 0 ? `not run (${detail})` : 'not run';
432
+ return '';
433
+ }
434
+
435
+ // S12 expanded detail: the collapsed card says only what happened, so the
436
+ // path, the status and the outcome nuances live here as ordered
437
+ // `label: value` rows read from the SAME details execute() returns. This
438
+ // is what replaces the raw content dump the transcript used to print
439
+ // (`worktree list: … thread=t issues=1 odd=a,b,c`) — no repeated prefix,
440
+ // no key=value pairs, no giant list on one line. A result the details
441
+ // cannot describe returns [] so the caller falls back to the content
442
+ // text, never to an invented or blank body.
443
+ function formattedDetailRows(toolName, details) {
444
+ switch (toolName) {
445
+ case 'worktree_create': {
446
+ // S8: the branch is the tool's input and the name is implied by the
447
+ // path, so neither gets a row. What remains is what the caller cannot
448
+ // derive: the status, where the checkout landed, what the registry write
449
+ // actually did (S7) and the real CodeGraph state.
450
+ const branch = oneLine(details.branch, '');
451
+ const target = oneLine(details.path, '');
452
+ if (branch.length === 0 && target.length === 0) return [];
453
+ const rows = [`status: ${createStatus(details)}`];
454
+ if (target.length > 0) rows.push(`path: ${target}`);
455
+ const registered = oneLine(details.registered, '');
456
+ if (registered.length > 0) rows.push(`registered: ${registered}`);
457
+ // S6: what CodeGraph actually did — never an issue or ODD association,
458
+ // which a create neither accepts nor records.
459
+ const codegraph = codegraphValue(details.codegraph);
460
+ if (codegraph.length > 0) rows.push(`codegraph: ${codegraph}`);
461
+ return rows;
462
+ }
463
+ case 'worktree_list': {
464
+ // S6: the expanded listing is a LIST — one block per checkout, one blank
465
+ // row between blocks — not one undifferentiated column of label rows,
466
+ // where an entry's `path:`/`thread:` ran straight into the next entry's
467
+ // identity.
468
+ if (!Array.isArray(details.worktrees)) return [];
469
+ const entries = details.worktrees;
470
+ const count = typeof details.count === 'number' ? details.count : entries.length;
471
+ const rows = [`checkouts: ${count}`];
472
+ const blocks = [];
473
+ for (const entry of entries) {
474
+ const row = asRecord(entry);
475
+ const name = oneLine(row.name, '') || baseName(row.path);
476
+ const branch = oneLine(row.branch, '');
477
+ // S6: being listed IS the existence proof — a checkout whose path is
478
+ // gone is pruned before it can be listed — so `live`/`exists` is a row
479
+ // that only repeats itself. The statuses that carry information
480
+ // (`missing`: the path exists but git no longer lists the checkout;
481
+ // `unregistered`: a live checkout the registry does not own) stay, as
482
+ // one trailing cell of the entry's identity row instead of a row of
483
+ // their own.
484
+ const status = oneLine(row.status, '');
485
+ const notable = status === 'live' || status === 'exists' ? '' : status;
486
+ const identity = [name, branch, notable].filter((part) => part.length > 0).join(' \u00B7 ');
487
+ const block = [];
488
+ if (identity.length > 0) block.push(identity);
489
+ const target = oneLine(row.path, '');
490
+ if (target.length > 0) block.push(`path: ${target}`);
491
+ const thread = threadValue(row.thread);
492
+ if (thread.length > 0) block.push(`thread: ${thread}`);
493
+ // S1: a worktree carries no issue list and no ODD document, so the
494
+ // entry is its identity — never an `issues:`/`odd:` association row.
495
+ // The extension publishes no association field at all, and legacy
496
+ // fields a producer might still supply are deliberately not rendered.
497
+ if (block.length > 0) blocks.push(block);
498
+ }
499
+ blocks.forEach((block, index) => {
500
+ if (index > 0) rows.push('');
501
+ rows.push(...block);
502
+ });
503
+ return rows;
504
+ }
505
+ case 'worktree_close': {
506
+ const branch = oneLine(details.branch, '');
507
+ const target = oneLine(details.path, '');
508
+ if (branch.length === 0 && target.length === 0) return [];
509
+ const rows = [];
510
+ const name = branch.length > 0 ? slugify(branch) : baseName(target);
511
+ if (name.length > 0) rows.push(`name: ${name}`);
512
+ if (branch.length > 0) rows.push(`branch: ${branch}`);
513
+ if (target.length > 0) rows.push(`path: ${target}`);
514
+ rows.push(`branch state: ${details.branchKept === false ? 'deleted' : 'kept'}`);
515
+ const thread = threadValue(details.thread);
516
+ if (thread.length > 0) rows.push(`thread: ${thread}`);
517
+ return rows;
518
+ }
519
+ case 'worktree_session_open': {
520
+ const worktree = oneLine(details.worktree, '');
521
+ const session = oneLine(details.sessionId, '');
522
+ const target = oneLine(details.path, '');
523
+ if (worktree.length === 0 && session.length === 0 && target.length === 0) return [];
524
+ const rows = [];
525
+ if (worktree.length > 0) rows.push(`worktree: ${worktree}`);
526
+ if (session.length > 0) rows.push(`session: ${session}`);
527
+ const branch = oneLine(details.branch, '');
528
+ if (branch.length > 0) rows.push(`branch: ${branch}`);
529
+ if (target.length > 0) rows.push(`path: ${target}`);
530
+ rows.push(`status: ${details.resumed === true ? 'resumed' : 'opened'}`);
531
+ const agent = oneLine(details.agentName, '');
532
+ if (agent.length > 0) rows.push(`agent: ${agent}`);
533
+ return rows;
534
+ }
535
+ case 'worktree_warp': {
536
+ const rows = [];
537
+ const from = oneLine(details.fromName, '')
538
+ || oneLine(details.fromDesc, '').split('\u00B7')[0].trim().replace(/\s+/g, ' ');
539
+ const name = oneLine(details.name, '');
540
+ const target = oneLine(details.path, '');
541
+ const branch = oneLine(details.branch, '');
542
+ if (from.length > 0) rows.push(`moved: ${from}`);
543
+ if (name.length > 0) rows.push(`to: ${name}`);
544
+ if (details.alreadyThere === true) rows.push('status: already there');
545
+ if (target.length > 0) rows.push(`path: now in "${target}"`);
546
+ if (branch.length > 0) rows.push(`branch: now in "${branch}"`);
547
+ return rows;
548
+ }
549
+ default:
550
+ return [];
551
+ }
552
+ }
553
+
554
+ // Collapsed success bodies (S12): the precise action and the identity it
555
+ // acted on — nothing else. Paths, thread ids, issue numbers and odd
556
+ // documents belong to the expanded detail above, so a transcript of five
557
+ // calls stays scannable. Malformed details fall back to the result's first
558
+ // line, never to a throw; failures keep the summary plus preview shape
559
+ // summarizeWorktreeResult pins.
560
+ function compactBodyRows(toolName, result) {
561
+ const details = asRecord(result ? result.details : undefined);
562
+ const text = resultTextOf(result);
563
+ const firstLine = oneLine(text, EMPTY_TEXT);
564
+ switch (toolName) {
565
+ case 'worktree_create': {
566
+ const head = createHead(details);
567
+ return head.length > 0 ? [head] : [firstLine];
568
+ }
569
+ case 'worktree_list': {
570
+ // The listing contract puts the PRINCIPAL first and the session's own
571
+ // checkout second when the two differ, and the result carries no field
572
+ // naming which entry is current. So the compact card names the action
573
+ // and the count only: a `current:` identity inferred from array order
574
+ // would read `current: principal` for a session in a linked worktree.
575
+ // The per-entry rows live in the expanded detail.
576
+ const count = typeof details.count === 'number' ? details.count : undefined;
577
+ // An empty listing compacts to one line; a result with no text at
578
+ // all still says so, exactly as the placeholder contract pins.
579
+ if (count === 0) return text.length > 0 ? ['no worktrees'] : ['no worktrees', EMPTY_TEXT];
580
+ // No count in the details means no honest action line: the first line
581
+ // of the result stands in rather than a guessed total.
582
+ if (count === undefined) return [firstLine];
583
+ return [`listed: ${count} checkout${count === 1 ? '' : 's'}`];
584
+ }
585
+ case 'worktree_close': {
586
+ const branch = oneLine(details.branch, '');
587
+ const target = baseName(details.path) || branch;
588
+ if (target.length === 0) return [firstLine];
589
+ const name = branch.length > 0 ? branch : target;
590
+ return [`removed: ${slugify(name)} \u00B7 ${name}`, details.branchKept === false ? 'branch deleted' : 'branch kept'];
591
+ }
592
+ case 'worktree_session_open': {
593
+ const worktree = oneLine(details.worktree, '');
594
+ const session = oneLine(details.sessionId, '');
595
+ if (worktree.length === 0 && session.length === 0) return [firstLine];
596
+ const head = `opened: ${worktree.length > 0 ? worktree : 'worktree'} \u00B7 ${session.length > 0 ? session : '(unknown session)'}`;
597
+ // A resume is an outcome; the checkout path is expanded detail.
598
+ return details.resumed === true ? [head, 'resumed'] : [head];
599
+ }
600
+ case 'worktree_warp': {
601
+ // S42 compact: EXACTLY the two name-only rows, mirroring the warp
602
+ // message renderer below — `moved: <source name>` then
603
+ // `to: <target name>`, no branch and no path on either row. The
604
+ // expanded form adds the S42 path/branch rows, so the collapsed pair
605
+ // is the only special case here.
606
+ const from = oneLine(details.fromName, '')
607
+ || oneLine(details.fromDesc, '').split('\u00B7')[0].trim().replace(/\s+/g, ' ');
608
+ const name = oneLine(details.name, '');
609
+ if (from.length > 0 && name.length > 0) return [`moved: ${from}`, `to: ${name}`];
610
+ return [firstLine];
611
+ }
612
+ default:
613
+ return [firstLine];
614
+ }
615
+ }
616
+
617
+ function callSubject(toolName, args) {
618
+ const params = asRecord(args);
619
+ switch (toolName) {
620
+ case 'worktree_create':
621
+ return oneLine(params.branch, '');
622
+ case 'worktree_list':
623
+ return params.all === true ? 'all' : '';
624
+ case 'worktree_close':
625
+ return oneLine(params.name ?? params.branch ?? params.path, '');
626
+ case 'worktree_session_open':
627
+ return oneLine(params.worktree, '');
628
+ case 'worktree_warp':
629
+ return oneLine(params.name, '');
630
+ default:
631
+ return '';
632
+ }
633
+ }
634
+
635
+ function warningLines(details) {
636
+ const warnings = Array.isArray(details.warnings) ? details.warnings : [];
637
+ const extra = [];
638
+ if (typeof details.pruneError === 'string' && details.pruneError.length > 0) extra.push(details.pruneError);
639
+ return [...warnings, ...extra]
640
+ .map((warning) => oneLine(warning, ''))
641
+ .filter((warning) => warning.length > 0);
642
+ }
643
+
644
+ // Host outputPad contract (installed Pi 1.3.0 ToolRenderContext):
645
+ // renderShell:self cards apply the pad themselves — the host wraps them
646
+ // in a plain Container with no padding, while default-shell content goes
647
+ // through setPaddingX(outputPad) on both sides (pi-tui Text: content in
648
+ // width − 2×pad, one margin each side). Non-finite or negative pads read
649
+ // as zero, so contexts without the field keep the unpadded shape.
650
+ function padCount(context) {
651
+ const pad = asRecord(context).outputPad;
652
+ if (typeof pad !== 'number' || !Number.isFinite(pad) || pad <= 0) return 0;
653
+ return Math.floor(pad);
654
+ }
655
+
656
+ // Card affordance geometry (hint on top): the ctrl+o expand/collapse hint
657
+ // belongs on the card's TOP rule and the closing rule belongs to the
658
+ // evidence-backed duration alone (`NNNms`/`N.Ns`) — one corner, one fact. `tailParts` splits a row into its
659
+ // lead, the fill and the right-aligned tail, and drops the tail entirely when
660
+ // it would leave no fill column, so a card identity is never truncated to
661
+ // make room for an affordance. Both card styles build their top row from it.
662
+ function tailParts(lead, tail, inner, helpers) {
663
+ const head = typeof lead === 'string' ? lead : '';
664
+ const tailText = typeof tail === 'string' ? oneLine(tail, '') : '';
665
+ if (tailText.length === 0) return { head, fill: '', tail: '' };
666
+ const fill = Math.floor(inner) - floatMeasure(head, helpers) - floatMeasure(tailText, helpers);
667
+ if (fill < 1) return { head, fill: '', tail: '' };
668
+ return { head, fill: ' '.repeat(fill), tail: tailText };
669
+ }
670
+
671
+ // Every row opens with the accent bar (the float-card left border from the
672
+ // reference). The header row is the call card's top rule and carries the
673
+ // expand hint right-aligned; a null header means a body-only result under
674
+ // that rule (the hint lives on the call card's header above it, never
675
+ // repeated down here). The closing rule carries the duration only;
676
+ // close:false omits it so a paired call leaves the frame to its result.
677
+ // Widths too narrow for the bar-plus-space prefix degrade to the bar alone
678
+ // rather than overflowing; only width 0 — or padding that leaves no
679
+ // column — draws nothing.
680
+ function buildCard({ helpers, theme, toneRole, header, rows, hint, duration, width, close = true, context, bgRole, wrap = false }) {
681
+ void bgRole;
682
+ const target = Math.max(0, Math.floor(width));
683
+ if (target === 0) return [];
684
+ const wrapRows = (list, budget) => wrap === true
685
+ ? list.flatMap((row) => wrapTextToWidth(row, budget, helpers))
686
+ : list;
687
+ // S24: the float card when the theme carries the tone background at
688
+ // this width — full-width padded rows in one-column margins — else the
689
+ // outlined card below, byte-identical to before.
690
+ const open = floatOpener(theme, toneRole, target);
691
+ if (open.length > 0) {
692
+ return paintFloatRows(floatCardRows({
693
+ helpers, theme, toneRole, header, rows, hint, duration, close,
694
+ inner: target - FLOAT_MARGIN * 2, wrap,
695
+ }), open);
696
+ }
697
+ const pad = padCount(context);
698
+ const body = target - 2 * pad;
699
+ if (body < 1) return [];
700
+ const bar = themeFg(theme, toneRole, ACCENT_BAR);
701
+ const roomy = body >= 2;
702
+ const prefix = roomy ? `${bar} ` : bar;
703
+ const inner = Math.max(0, body - (roomy ? 2 : 1));
704
+ const fit = (text) => {
705
+ const clean = oneLine(text, '');
706
+ let measured = 0;
707
+ try {
708
+ measured = helpers.visibleWidth(stripAnsi(clean));
709
+ } catch {
710
+ measured = localVisibleWidth(clean);
711
+ }
712
+ if (measured <= inner) return clean;
713
+ try {
714
+ return helpers.truncateToWidth(clean, inner, '…');
715
+ } catch {
716
+ return localTruncate(clean, inner);
717
+ }
718
+ };
719
+ const margin = ' '.repeat(pad);
720
+ const lines = [];
721
+ // S39: expanded wraps every row to the inner width; compact clips via fit().
722
+ const bodyRows = wrap === true ? wrapRows(Array.isArray(rows) ? rows : [], inner) : rows;
723
+ const headRows = header !== null && header !== undefined
724
+ ? (wrap === true ? wrapRows([header], inner) : [fit(header)])
725
+ : [];
726
+ headRows.forEach((line, index) => {
727
+ // the hint rides the last top-rule row, right-aligned; a wrapped
728
+ // header keeps its continuation rows plain.
729
+ const parts = index === headRows.length - 1
730
+ ? tailParts(line, hint, inner, helpers)
731
+ : { head: line, fill: '', tail: '' };
732
+ lines.push(`${margin}${prefix}${themeFg(theme, toneRole, parts.head)}${parts.fill}${parts.tail}${margin}`);
733
+ });
734
+ for (const row of bodyRows) lines.push(`${margin}${prefix}${wrap === true ? row : fit(row)}${margin}`);
735
+ if (close) {
736
+ // the closing rule belongs to the duration alone — right-aligned
737
+ // when the host supplied timing evidence, bare otherwise. Nothing is
738
+ // invented and no placeholder dash ever stands in for a duration.
739
+ const rule = tailParts(fit('─'), duration, inner, helpers);
740
+ lines.push(`${margin}${prefix}${rule.head}${rule.fill}${rule.tail}${margin}`);
741
+ }
742
+ return lines;
743
+ }
744
+
745
+ // S24 float recipe (gentle-shell lib/shell-card.ts, CARD_STYLE.FLOAT),
746
+ // ported mechanics, not an approximation: a one-column transparent margin
747
+ // on both sides; float chrome { topLeft:"\u258E", topLead:" ",
748
+ // topRight:" ", rule:" ", side:"\u258E", rail:" ", bottomLeft:"\u258E",
749
+ // bottomRight:" " }; every row padded to the full target width so the
750
+ // painted band is a full-width rectangle; a blank padding row above the
751
+ // heading and between heading and body; the opener is theme.bg(role,"")
752
+ // minus its trailing \x1b[49m, re-armed after every inner reset and
753
+ // closed with \x1b[49m; roles by tone (info/success toolSuccessBg,
754
+ // warning toolPendingBg, error toolErrorBg — customMessageBg is used
755
+ // nowhere in gentle-shell); outlined fallback when the theme lacks the
756
+ // role or the width is under 10; the duration rides the closing rule
757
+ // only when it fits and is omitted entirely otherwise, never as a
758
+ // placeholder dash. No invented hex ever reaches the transcript — only
759
+ // these documented role names.
760
+ const FLOAT_CHROME = { topLeft: '\u258E', topLead: ' ', topRight: ' ', rule: ' ', side: '\u258E', rail: ' ', bottomLeft: '\u258E', bottomRight: ' ' };
761
+ const FLOAT_MARGIN = 1;
762
+ const FLOAT_MIN_WIDTH = 10;
763
+ const FLOAT_BG_ROLE = { info: 'toolSuccessBg', success: 'toolSuccessBg', warning: 'toolPendingBg', error: 'toolErrorBg' };
764
+ const BG_RESET = '\x1b[49m';
765
+ // Full resets (pi-tui truncation inserts one) and background resets both
766
+ // clear the background.
767
+ const BG_CLEARING = /\x1b\[(?:0|49)?m/g;
768
+
769
+ function floatBgRoleForTone(tone) {
770
+ try {
771
+ if (typeof tone === 'string' && Object.prototype.hasOwnProperty.call(FLOAT_BG_ROLE, tone)) {
772
+ return FLOAT_BG_ROLE[tone];
773
+ }
774
+ } catch {
775
+ // An unusable tone reads as no role: the outlined card below.
776
+ }
777
+ return '';
778
+ }
779
+
780
+ // The tone background opener when the float style applies at this width,
781
+ // or "" for the outlined card: width under 10, an unknown tone, a theme
782
+ // without theme.bg, or a bg that carries no role (no trailing reset).
783
+ function floatOpener(theme, tone, width) {
784
+ try {
785
+ if (Math.floor(width) < FLOAT_MIN_WIDTH) return '';
786
+ const role = floatBgRoleForTone(tone);
787
+ if (!role || !theme || typeof theme.bg !== 'function') return '';
788
+ const painted = theme.bg(role, '');
789
+ if (typeof painted !== 'string' || painted.length === 0) return '';
790
+ return painted.endsWith(BG_RESET) ? painted.slice(0, -BG_RESET.length) : '';
791
+ } catch {
792
+ return '';
793
+ }
794
+ }
795
+
796
+ // Paints every row behind its tone background, re-armed after any reset
797
+ // the content carries, inside a transparent one-column margin on both
798
+ // sides. Padding rows keep their tone-coloured accent just like content.
799
+ function paintFloatRows(rows, open) {
800
+ const margin = ' '.repeat(FLOAT_MARGIN);
801
+ return rows.map((row) => `${margin}${open}${String(row).replace(BG_CLEARING, (reset) => reset + open)}${BG_RESET}${margin}`);
802
+ }
803
+
804
+ function floatMeasure(text, helpers) {
805
+ try {
806
+ return helpers.visibleWidth(stripAnsi(text));
807
+ } catch {
808
+ return localVisibleWidth(stripAnsi(text));
809
+ }
810
+ }
811
+
812
+ function floatFit(text, width, helpers) {
813
+ const clean = oneLine(text, '');
814
+ if (floatMeasure(clean, helpers) <= width) return clean;
815
+ try {
816
+ return helpers.truncateToWidth(clean, width, '\u2026');
817
+ } catch {
818
+ return localTruncate(clean, width);
819
+ }
820
+ }
821
+
822
+ // S39 expanded wrapping: in the expanded form NOTHING may be cut — long
823
+ // lines wrap to the inner width instead of truncating with an ellipsis.
824
+ // Only the compact (collapsed) row may clip. Wrapping is character based
825
+ // on visible columns, so every emitted line fits its budget.
826
+ function wrapTextToWidth(text, width, helpers) {
827
+ const clean = oneLine(text, '');
828
+ const budget = Math.max(0, Math.floor(width));
829
+ if (budget <= 0) return [clean];
830
+ const measure = (line) => floatMeasure(line, helpers);
831
+ if (measure(clean) <= budget) return [clean];
832
+ const lines = [];
833
+ let current = '';
834
+ for (const char of clean) {
835
+ const next = current + char;
836
+ if (measure(next) <= budget || current.length === 0) {
837
+ current = next;
838
+ } else {
839
+ lines.push(current);
840
+ current = char;
841
+ }
842
+ }
843
+ lines.push(current);
844
+ return lines;
845
+ }
846
+
847
+ // One float content row at exactly `inner` visible columns: the accent
848
+ // bar, one space, the text padded out, one space, the right rail. An
849
+ // optional `tail` (the expand hint on the heading row) right-aligns
850
+ // inside the content columns and is dropped when it leaves no fill column.
851
+ function floatContentRow({ helpers, theme, toneRole, text, inner, paint, noClip = false, tail }) {
852
+ const bar = themeFg(theme, toneRole, FLOAT_CHROME.side);
853
+ const contentCols = Math.max(0, inner - 4);
854
+ // With noClip (S39 expanded) the text was already wrapped to fit, so it
855
+ // is never truncated here.
856
+ const fit = noClip === true ? oneLine(text, '') : floatFit(text, contentCols, helpers);
857
+ const parts = tailParts(fit, tail, contentCols, helpers);
858
+ const styled = typeof paint === 'function' ? paint(parts.head) : parts.head;
859
+ const used = floatMeasure(styled, helpers) + parts.fill.length + floatMeasure(parts.tail, helpers);
860
+ const padded = `${styled}${parts.fill}${parts.tail}${' '.repeat(Math.max(0, contentCols - used))}`;
861
+ return `${bar} ${padded} ${FLOAT_CHROME.rail}`;
862
+ }
863
+
864
+ function floatBlankRow({ theme, toneRole, inner }) {
865
+ const bar = themeFg(theme, toneRole, FLOAT_CHROME.side);
866
+ return `${bar}${' '.repeat(Math.max(0, inner - 1))}`;
867
+ }
868
+
869
+ // The closing rule (hint on top): the evidence-backed duration alone right-aligns
870
+ // on it, and only when it fits with at least one fill column — otherwise
871
+ // it is omitted entirely, never a placeholder dash. With the float chrome
872
+ // the rule itself is spaces, so an omitted duration leaves a blank painted
873
+ // band, never a bare dash. The expand hint lives on the heading row above.
874
+ function floatBottomRow({ helpers, theme, toneRole, duration, inner }) {
875
+ const bar = themeFg(theme, toneRole, FLOAT_CHROME.bottomLeft);
876
+ const tail = typeof duration === 'string' ? oneLine(duration, '') : '';
877
+ if (tail.length > 0) {
878
+ const tailWidth = floatMeasure(tail, helpers);
879
+ const fill = inner - tailWidth - 3;
880
+ if (fill >= 1) {
881
+ return `${bar}${' '.repeat(fill)} ${floatFit(tail, tailWidth, helpers)} `;
882
+ }
883
+ }
884
+ return `${bar}${' '.repeat(Math.max(0, inner - 1))}`;
885
+ }
886
+
887
+ function floatCardRows({ helpers, theme, toneRole, header, rows, hint, duration, close, inner, wrap = false }) {
888
+ const blank = floatBlankRow({ theme, toneRole, inner });
889
+ const out = [];
890
+ const contentCols = Math.max(0, inner - 4);
891
+ // S39: expanded wraps every row to the content width (noClip below, so
892
+ // nothing is cut); compact keeps the single clipped row.
893
+ const wrapList = (list) => wrap === true
894
+ ? list.flatMap((row) => wrapTextToWidth(row, contentCols, helpers))
895
+ : list;
896
+ const headed = header !== null && header !== undefined;
897
+ if (headed) {
898
+ out.push(blank);
899
+ const headLines = wrapList([header]);
900
+ headLines.forEach((line, index) => {
901
+ // the hint rides the last heading row, right-aligned inside the
902
+ // painted band; a wrapped heading keeps its continuation rows plain.
903
+ out.push(floatContentRow({
904
+ helpers, theme, toneRole, text: line, inner, paint: (fit) => themeFg(theme, toneRole, fit),
905
+ noClip: wrap === true, tail: index === headLines.length - 1 ? hint : undefined,
906
+ }));
907
+ });
908
+ }
909
+ const body = wrapList(Array.isArray(rows) ? rows : []);
910
+ if (body.length > 0) {
911
+ // The separator between heading and body — and the leading padding
912
+ // of a header-less result, so a paired call plus its result still
913
+ // reads as one card with the heading set off from its body.
914
+ out.push(blank);
915
+ for (const row of body) out.push(floatContentRow({ helpers, theme, toneRole, text: row, inner, noClip: wrap === true }));
916
+ }
917
+ if (close) out.push(floatBottomRow({ helpers, theme, toneRole, duration, inner }));
918
+ return out;
919
+ }
920
+
921
+ function createWorktreeCardRenderer(toolName, deps) {
922
+ if (!WORKTREE_TOOLS.has(toolName)) {
923
+ throw new Error(`unknown worktree tool: ${safeText(toolName) || typeof toolName}`);
924
+ }
925
+ const helpers = resolveHelpers(deps);
926
+ const glyph = GLYPH[toolName];
927
+ const title = TITLE[toolName];
928
+
929
+ function renderCall(args, theme, context) {
930
+ const ctx = asRecord(context);
931
+ const pending = ctx.isPartial !== false;
932
+ const failed = ctx.isError === true;
933
+ // Git-orange (warning) while running and settled; error only on failure.
934
+ const toneRole = failed ? 'error' : 'warning';
935
+ const subject = callSubject(toolName, args);
936
+ const header = `${glyph} ${title}${subject.length > 0 ? ` · ${subject}` : ''}`;
937
+ // Paired with a result: the top rule only, so the result body below
938
+ // closes the one shared frame. Awaiting: a closed mini-card.
939
+ const awaiting = cardAwaitingResult(ctx);
940
+ const finished = ctx.isPartial === false || failed;
941
+ // the card's top rule is the expand affordance's home, so the hint is
942
+ // computed HERE and right-aligned on the header row — it is never repeated
943
+ // on the closing rule below, which belongs to the duration alone. The gate
944
+ // is `finished`, NOT `!awaiting`: the host renders the call before the
945
+ // result that marks the shared row state, so gating on the mark would
946
+ // delay the hint to a later re-render (and hide it entirely if none came).
947
+ const hint = finished ? expandHint(helpers, ctx.expanded === true) : undefined;
948
+ const rows = awaiting && pending && !failed ? [themeFg(theme, 'muted', `running${RUNNING_MARK}`)] : [];
949
+ const close = awaiting;
950
+ return cardComponent({
951
+ helpers,
952
+ hint,
953
+ renderLines: (width) => buildCard({ helpers, theme, toneRole, header, rows, hint, duration: undefined, width, close, context: ctx }),
954
+ });
955
+ }
956
+
957
+ function renderResult(result, options, theme, context) {
958
+ const opts = asRecord(options);
959
+ const ctx = asRecord(context);
960
+ const expanded = opts.expanded === true || ctx.expanded === true;
961
+ const partial = opts.isPartial === true;
962
+ const failed = isErrorResult(result, opts, ctx);
963
+ // Git-orange (warning) settled; error only on failure.
964
+ const toneRole = failed ? 'error' : 'warning';
965
+ markCardResult(ctx.state);
966
+ // No repeated title: the call card above owns the header. Failures
967
+ // keep their honest prose (the message IS the error detail); a settled
968
+ // success formats the details into ordered label rows (S12); a result
969
+ // the details cannot describe falls back to the content text; and the
970
+ // collapsed card carries only the action and its identity.
971
+ const header = null;
972
+ const summary = summarizeWorktreeResult(toolName, result);
973
+ const text = resultTextOf(result);
974
+ const bodyLines = text.length > 0 ? text.split('\n') : [EMPTY_TEXT];
975
+ const warnings = warningLines(asRecord(result ? result.details : undefined));
976
+ // this body-only half has no top rule, so it renders no hint — the
977
+ // card's expand affordance rides the call card's header row above it
978
+ // (renderCall), and this card's closing rule carries the duration alone.
979
+ const duration = durationOf(ctx);
980
+ let rows;
981
+ if (failed) {
982
+ // The prose is the error detail: expanded keeps all of it, the
983
+ // collapsed card keeps the bounded preview.
984
+ const prose = bodyLines.filter((line) => line.trim().length > 0);
985
+ const shown = expanded ? prose : prose.slice(0, COLLAPSED_PREVIEW_ROWS);
986
+ rows = [summary, ...(shown.length > 0 ? shown : [EMPTY_TEXT])];
987
+ } else if (expanded) {
988
+ const detail = formattedDetailRows(toolName, asRecord(result ? result.details : undefined));
989
+ rows = detail.length > 0 ? detail : [summary, ...bodyLines];
990
+ } else {
991
+ rows = compactBodyRows(toolName, result);
992
+ }
993
+ if (partial && !failed) {
994
+ rows.push(`receiving${RUNNING_MARK}`);
995
+ } else if ((expanded || failed) && !partial) {
996
+ for (const warning of warnings) rows.push(`! ${warning}`);
997
+ }
998
+ return cardComponent({
999
+ helpers,
1000
+ // No hint on this half (header is null): the seam's rect lookup finds
1001
+ // nothing, so the result body never claims a mouse cell.
1002
+ hint: undefined,
1003
+ // S39: expanded wraps long lines to the inner width (nothing cut);
1004
+ // only the compact row may clip.
1005
+ renderLines: (width) => buildCard({ helpers, theme, toneRole, header, rows, duration, width, close: true, context: ctx, wrap: expanded === true }),
1006
+ });
1007
+ }
1008
+
1009
+ return { renderShell: 'self', renderCall, renderResult };
1010
+ }
1011
+
1012
+ // Warp confirmation message renderer (customize-command T13/S20).
1013
+ //
1014
+ // The warp is a COMMAND, so it never passes through the tool cards above:
1015
+ // its one visible confirmation is this custom-message renderer, which the
1016
+ // extension registers for the `worktree-warp` customType via
1017
+ // `pi.registerMessageRenderer`. Compact shows the capitalised label plus
1018
+ // the two ordered lines; expanded adds the checkout path on ctrl+o (S41:
1019
+ // never repeats in the card body what the notice already says) — the same
1020
+ // geometry as the tool cards
1021
+ // (accent bar, right-aligned hint, outputPad on the outlined fallback;
1022
+ // one-column margins and full-width painted rows on the float card). The
1023
+ // tone is the documented `warning` role: the installed Pi theme schema
1024
+ // exposes no orange role, so warning (yellow in the built-in themes) is
1025
+ // the closest documented match for git-orange, and the float card paints
1026
+ // it behind `toolPendingBg` per the tone table. Compact shows the
1027
+ // capitalised label plus the two S42 name-only rows; expanded appends
1028
+ // exactly `path: now in "<path>"` and `branch: now in "<branch>"` in
1029
+ // that order (S41/S42: the long prose notice never enters the card body).
1030
+ // Warp failures never send a message (they notify instead), so `error`
1031
+ // never appears here. The compact rows read from
1032
+ // message.details — never by parsing text — with a content fallback when
1033
+ // details are malformed; a foreign customType returns undefined so the
1034
+ // host default renderer takes over. Untrusted strings are sanitized
1035
+ // exactly like the tool cards, and the hook never throws.
1036
+ const WARP_CUSTOM_TYPE = 'worktree-warp';
1037
+ const WARP_GLYPH = '\u2387';
1038
+ const WARP_TITLE = 'Warp \u00B7 moved';
1039
+
1040
+ function warpMessageText(message) {
1041
+ const content = message ? message.content : undefined;
1042
+ if (typeof content === 'string') return safeText(content);
1043
+ if (Array.isArray(content)) {
1044
+ try {
1045
+ return content
1046
+ .filter((entry) => entry && entry.type === 'text' && typeof entry.text === 'string')
1047
+ .map((entry) => safeText(entry.text))
1048
+ .join('\n');
1049
+ } catch {
1050
+ return '';
1051
+ }
1052
+ }
1053
+ return '';
1054
+ }
1055
+
1056
+ // S42 warp compact: EXACTLY two name-only rows — `moved: <source name>`
1057
+ // then `to: <target name>` — with no branch and no path on either row.
1058
+ // The source name travels explicitly as details.fromName; details written
1059
+ // before that field fall back to the name segment of fromDesc (the text
1060
+ // before "·"), and the target name is details.name. A foreign or
1061
+ // malformed payload falls back to the message content, never to a throw.
1062
+ function warpSourceName(details) {
1063
+ const named = oneLine(details.fromName, '');
1064
+ if (named.length > 0) return named;
1065
+ const cut = oneLine(details.fromDesc, '').split('\u00B7')[0].trim().replace(/\s+/g, ' ');
1066
+ return cut;
1067
+ }
1068
+
1069
+ function warpCompactRows(message) {
1070
+ const details = asRecord(message ? message.details : undefined);
1071
+ const from = warpSourceName(details);
1072
+ const name = oneLine(details.name, '');
1073
+ if (from.length > 0 && name.length > 0) {
1074
+ return [`moved: ${from}`, `to: ${name}`];
1075
+ }
1076
+ return [oneLine(warpMessageText(message), EMPTY_TEXT)];
1077
+ }
1078
+
1079
+ function createWarpMessageRenderer(deps) {
1080
+ const helpers = resolveHelpers(deps);
1081
+ return function renderWarpMessage(message, options, theme) {
1082
+ if (!message || message.customType !== WARP_CUSTOM_TYPE) return undefined;
1083
+ const opts = asRecord(options);
1084
+ const expanded = opts.expanded === true;
1085
+ const context = { outputPad: opts.outputPad };
1086
+ const header = `${WARP_GLYPH} ${WARP_TITLE}`;
1087
+ let rows = warpCompactRows(message);
1088
+ if (expanded) {
1089
+ // S42: expanded adds exactly these two rows after the compact pair,
1090
+ // in this order — and nothing else (never the prose notice).
1091
+ const detail = asRecord(message.details);
1092
+ const to = oneLine(detail.to, '');
1093
+ const branch = oneLine(detail.branch, '');
1094
+ if (to.length > 0) rows = [...rows, `path: now in "${to}"`];
1095
+ if (branch.length > 0) rows = [...rows, `branch: now in "${branch}"`];
1096
+ }
1097
+ const hint = expandHint(helpers, expanded);
1098
+ return cardComponent({
1099
+ helpers,
1100
+ hint,
1101
+ // S39: expanded wraps long lines to the inner width (nothing cut);
1102
+ // only the compact row may clip.
1103
+ renderLines: (width) => buildCard({ helpers, theme, toneRole: 'warning', header, rows, hint, duration: undefined, width, close: true, context, wrap: expanded === true }),
1104
+ });
1105
+ };
1106
+ }
1107
+
1108
+ module.exports = {
1109
+ createWorktreeCardRenderer,
1110
+ createWarpMessageRenderer,
1111
+ wrapTextToWidth,
1112
+ summarizeWorktreeResult,
1113
+ floatBgRoleForTone,
1114
+ safeText,
1115
+ stripAnsi,
1116
+ WORKTREE_TOOLS: [...WORKTREE_TOOLS],
1117
+ };