pi-odd-worktree 0.0.0-stage → 1.0.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.
- package/README.md +234 -2
- package/extensions/odd-worktree-tools.ts +7593 -0
- package/lib/checkout-facts.js +598 -0
- package/lib/clipboard.js +226 -0
- package/lib/gh-issues.js +1555 -0
- package/lib/issue-preview.js +273 -0
- package/lib/issues-view.js +2274 -0
- package/lib/odd-docs.js +505 -0
- package/lib/odd-markdown.js +413 -0
- package/lib/odd-view.js +990 -0
- package/lib/open-external.js +106 -0
- package/lib/preview-scroll-keys.js +192 -0
- package/lib/session-auto-name.js +292 -0
- package/lib/session-worktree-register.js +62 -0
- package/lib/tui-paint.js +273 -0
- package/lib/view-layout.js +368 -0
- package/lib/warp-create-panel.js +722 -0
- package/lib/warp-create-picker.js +906 -0
- package/lib/warp-delete-confirm.js +465 -0
- package/lib/warp-selector-view.js +1265 -0
- package/lib/worktree-card.js +1117 -0
- package/lib/worktree-changes.js +1395 -0
- package/lib/worktree-close-herdr.js +73 -0
- package/lib/worktree-git.js +1048 -0
- package/lib/worktree-hooks.js +738 -0
- package/lib/worktree-model-result.js +291 -0
- package/lib/worktree-registry.js +886 -0
- package/lib/worktree-session-guard.js +595 -0
- package/lib/worktree-symlinks.js +336 -0
- package/lib/worktree-warp-sessions.js +566 -0
- package/package.json +36 -4
- package/tools/commands.test.js +176 -0
- package/tools/fixtures/gh-api-user.json +1 -0
- package/tools/fixtures/gh-issue-list.json +1 -0
- package/tools/fixtures/gh-issue-view.json +1 -0
- package/tools/fixtures/gh-label-list.json +1 -0
- package/tools/fixtures/gh-repo-view.json +1 -0
- package/tools/fixtures/load-extension.js +66 -0
- package/tools/gh-issues-feedback.test.js +359 -0
- package/tools/gh-issues.test.js +1502 -0
- package/tools/issue-preview.test.js +216 -0
- package/tools/issues-command.test.js +1217 -0
- package/tools/issues-view.test.js +4401 -0
- package/tools/odd-docs.test.js +803 -0
- package/tools/odd-markdown.test.js +236 -0
- package/tools/odd-view.test.js +2328 -0
- package/tools/package-surface.test.js +138 -0
- package/tools/t5-session-identity.test.js +306 -0
- package/tools/warp-create-panel.test.js +185 -0
- package/tools/warp-create-picker.test.js +1357 -0
- package/tools/warp-delete-confirm.test.js +616 -0
- package/tools/warp-selector-view.test.js +1463 -0
- package/tools/worktree-changes.test.js +951 -0
- package/tools/worktree-close-herdr-async.test.js +204 -0
- package/tools/worktree-odd-migration.test.js +224 -0
- package/tools/worktree-registry-cache.test.js +163 -0
- package/tools/worktree-registry-odd.test.js +231 -0
- package/tools/worktree-registry.test.js +244 -0
- package/tools/worktree-session-guard.test.js +507 -0
- 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
|
+
};
|