@pellux/goodvibes-tui 1.13.0 → 1.14.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 (157) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +6 -6
  3. package/bin/goodvibes +6 -0
  4. package/bin/goodvibes-daemon +6 -0
  5. package/bin/launcher-support.js +34 -1
  6. package/docs/foundation-artifacts/operator-contract.json +8076 -2047
  7. package/package.json +15 -11
  8. package/scripts/postinstall.js +73 -48
  9. package/src/cli/bundle-command.ts +5 -5
  10. package/src/cli/completion.ts +1 -1
  11. package/src/cli/completions/generate.ts +15 -3
  12. package/src/cli/doctor.ts +385 -0
  13. package/src/cli/entrypoint.ts +72 -1
  14. package/src/cli/help.ts +29 -9
  15. package/src/cli/hooks-command.ts +74 -0
  16. package/src/cli/hooks-report.ts +167 -0
  17. package/src/cli/index.ts +3 -0
  18. package/src/cli/management.ts +13 -1
  19. package/src/cli/parser.ts +10 -2
  20. package/src/cli/plugin-bundles-command.ts +228 -0
  21. package/src/cli/plugin-command.ts +179 -0
  22. package/src/cli/service-posture.ts +10 -0
  23. package/src/cli/status.ts +275 -2
  24. package/src/cli/tui-startup.ts +119 -1
  25. package/src/cli/types.ts +3 -1
  26. package/src/config/index.ts +16 -0
  27. package/src/config/tui-extension-settings.ts +169 -0
  28. package/src/core/approval-alert.ts +15 -2
  29. package/src/core/compaction-receipt.ts +127 -0
  30. package/src/core/model-routing-chip.ts +82 -0
  31. package/src/core/permission-mode.ts +89 -0
  32. package/src/core/rewind-receipt.ts +83 -0
  33. package/src/core/rewind-turn-anchors.ts +160 -0
  34. package/src/core/scriptable-statusline.ts +141 -0
  35. package/src/core/session-auto-titler.ts +110 -0
  36. package/src/core/stream-event-wiring.ts +20 -0
  37. package/src/core/system-message-router.ts +1 -1
  38. package/src/core/terminal-notifier.ts +102 -0
  39. package/src/core/turn-event-wiring.ts +80 -1
  40. package/src/input/command-reference.ts +96 -0
  41. package/src/input/command-registry.ts +25 -0
  42. package/src/input/commands/channel-pairing.ts +245 -0
  43. package/src/input/commands/channel-runtime.ts +152 -6
  44. package/src/input/commands/checkin-runtime.ts +89 -0
  45. package/src/input/commands/checkpoint-runtime.ts +7 -179
  46. package/src/input/commands/ci-runtime.ts +207 -0
  47. package/src/input/commands/cost-attribution-format.ts +52 -0
  48. package/src/input/commands/cost-runtime.ts +44 -4
  49. package/src/input/commands/editor-runtime.ts +22 -0
  50. package/src/input/commands/flags-runtime.ts +328 -0
  51. package/src/input/commands/health-metrics-format.ts +67 -0
  52. package/src/input/commands/health-runtime.ts +55 -1
  53. package/src/input/commands/memory.ts +23 -0
  54. package/src/input/commands/operator-rpc.ts +95 -0
  55. package/src/input/commands/palette-runtime.ts +22 -0
  56. package/src/input/commands/permissions-provenance.ts +264 -0
  57. package/src/input/commands/permissions-runtime.ts +21 -0
  58. package/src/input/commands/planning-runtime.ts +28 -1
  59. package/src/input/commands/platform-access-runtime.ts +2 -97
  60. package/src/input/commands/principals-runtime.ts +212 -0
  61. package/src/input/commands/product-runtime.ts +43 -3
  62. package/src/input/commands/recall-files-config.ts +54 -0
  63. package/src/input/commands/recall-files-git.ts +57 -0
  64. package/src/input/commands/recall-files.ts +185 -0
  65. package/src/input/commands/recall-query.ts +8 -3
  66. package/src/input/commands/recall-review.ts +2 -2
  67. package/src/input/commands/recall-shared.ts +13 -0
  68. package/src/input/commands/relay-runtime.ts +86 -0
  69. package/src/input/commands/review-runtime.ts +160 -0
  70. package/src/input/commands/rewind-runtime.ts +362 -0
  71. package/src/input/commands/schedule-nl.ts +147 -0
  72. package/src/input/commands/schedule-runtime.ts +29 -10
  73. package/src/input/commands/secret-runtime.ts +55 -0
  74. package/src/input/commands/session-content.ts +19 -8
  75. package/src/input/commands/session-workflow.ts +10 -0
  76. package/src/input/commands/update-runtime.ts +363 -0
  77. package/src/input/commands/workstream-attempt-dependents.ts +103 -0
  78. package/src/input/commands/workstream-attempts.ts +244 -0
  79. package/src/input/commands/workstream-runtime.ts +164 -26
  80. package/src/input/commands/worktree-runtime.ts +56 -2
  81. package/src/input/commands.ts +135 -80
  82. package/src/input/composer-capture.ts +67 -0
  83. package/src/input/composer-editor.ts +134 -0
  84. package/src/input/concealed-input.ts +94 -0
  85. package/src/input/exec-env-scrub-config.ts +84 -0
  86. package/src/input/feature-unit-layout.ts +142 -0
  87. package/src/input/feed-context-factory.ts +2 -0
  88. package/src/input/handler-feed-routes.ts +17 -0
  89. package/src/input/handler-feed.ts +3 -0
  90. package/src/input/handler-modal-routes.ts +5 -2
  91. package/src/input/handler-shortcuts.ts +37 -0
  92. package/src/input/handler.ts +24 -2
  93. package/src/input/keybindings.ts +11 -2
  94. package/src/input/onboarding/onboarding-feature-units.ts +536 -0
  95. package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
  96. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
  97. package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
  98. package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
  99. package/src/input/relay-settings-descriptions.ts +35 -0
  100. package/src/input/sandbox-exec-config.ts +107 -0
  101. package/src/input/settings-modal-adjustment.ts +4 -7
  102. package/src/input/settings-modal-data.ts +178 -8
  103. package/src/input/settings-modal-mutations.ts +41 -13
  104. package/src/input/settings-modal-types.ts +42 -5
  105. package/src/input/settings-modal.ts +67 -15
  106. package/src/input/submission-router.ts +4 -1
  107. package/src/input/worktree-setup-config.ts +93 -0
  108. package/src/main.ts +43 -43
  109. package/src/panels/builtin/development.ts +14 -0
  110. package/src/panels/diff-review-model.ts +189 -0
  111. package/src/panels/diff-review-panel.ts +395 -0
  112. package/src/panels/fleet-panel-format.ts +114 -2
  113. package/src/panels/fleet-panel.ts +65 -78
  114. package/src/panels/fleet-read-model.ts +91 -11
  115. package/src/panels/fleet-stop.ts +19 -4
  116. package/src/panels/modals/memory-modal.ts +11 -2
  117. package/src/permissions/hunk-selection.ts +86 -5
  118. package/src/permissions/prompt.ts +108 -7
  119. package/src/permissions/sandbox-exec-gate.ts +162 -0
  120. package/src/renderer/settings-modal-helpers.ts +21 -1
  121. package/src/renderer/settings-modal.ts +70 -44
  122. package/src/renderer/shell-surface.ts +23 -1
  123. package/src/renderer/system-message.ts +4 -0
  124. package/src/renderer/tool-result-summary.ts +95 -5
  125. package/src/renderer/ui-factory.ts +22 -0
  126. package/src/runtime/bootstrap-command-context.ts +15 -1
  127. package/src/runtime/bootstrap-command-parts.ts +14 -2
  128. package/src/runtime/bootstrap-core.ts +38 -38
  129. package/src/runtime/bootstrap-notifier-sync.ts +41 -0
  130. package/src/runtime/bootstrap-shell.ts +8 -0
  131. package/src/runtime/bootstrap.ts +21 -23
  132. package/src/runtime/context-accounting-source.ts +103 -0
  133. package/src/runtime/conversation-rewind-port.ts +130 -0
  134. package/src/runtime/fleet-needs-input-push.ts +70 -0
  135. package/src/runtime/install-self-check-startup.ts +36 -0
  136. package/src/runtime/install-self-check.ts +149 -0
  137. package/src/runtime/onboarding/fast-path.ts +93 -0
  138. package/src/runtime/process-lifecycle.ts +32 -24
  139. package/src/runtime/relay-reachability-bridge.ts +53 -0
  140. package/src/runtime/release-artifacts.ts +79 -0
  141. package/src/runtime/services.ts +38 -37
  142. package/src/runtime/trust/workspace-registration.ts +184 -0
  143. package/src/runtime/trust/workspace-trust.ts +202 -0
  144. package/src/runtime/ui-services.ts +2 -1
  145. package/src/runtime/update-check.ts +113 -0
  146. package/src/runtime/workspace-checkpointing.ts +32 -0
  147. package/src/runtime/workstream-attempts-validation.ts +77 -0
  148. package/src/runtime/workstream-draft-edits.ts +95 -0
  149. package/src/runtime/workstream-draft-store.ts +101 -0
  150. package/src/runtime/workstream-draft-types.ts +42 -0
  151. package/src/runtime/workstream-services.ts +70 -39
  152. package/src/shell/blocking-input.ts +29 -0
  153. package/src/shell/ui-openers.ts +65 -0
  154. package/src/verification/verification-ledger.ts +56 -2
  155. package/src/version.ts +1 -1
  156. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
  157. package/src/runtime/render-scheduler.ts +0 -90
@@ -0,0 +1,395 @@
1
+ // ---------------------------------------------------------------------------
2
+ // DiffReviewPanel — comment-on-hunk review loop.
3
+ //
4
+ // Shows the current session's file changes per-file and hunk-boundaried, lets
5
+ // the user select a hunk, attach a comment, and submit that comment to the
6
+ // session as a steering message carrying structured context (file path, new-file
7
+ // line range, and a short patch excerpt) so the model knows exactly what the
8
+ // comment targets.
9
+ //
10
+ // Data source (labelled honestly in the panel): `git diff <base> -- <files>`,
11
+ // where <files> is the set the SDK SessionChangeTracker recorded this session.
12
+ // That is CURRENT working-tree content vs the base ref — not a turn-start
13
+ // snapshot. The panel never presents a stale diff as current.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ import type { Line } from '../types/grid.ts';
17
+ import { createEmptyLine } from '../types/grid.ts';
18
+ import { truncateDisplay } from '../utils/terminal-width.ts';
19
+ import { GitService } from '@pellux/goodvibes-sdk/platform/git';
20
+ import { BasePanel } from './base-panel.ts';
21
+ import { DIFF_TONES, UI_TONES } from '../renderer/ui-primitives.ts';
22
+ import {
23
+ buildEmptyState,
24
+ buildPanelWorkspace,
25
+ buildStyledPanelLine,
26
+ resolveScrollablePanelSection,
27
+ DEFAULT_PANEL_PALETTE,
28
+ extendPalette,
29
+ } from './polish.ts';
30
+ import { isPanelSearchBackspace, isPanelSearchCancel, isPanelSearchCommit, isPanelSearchPrintable } from './search-focus.ts';
31
+ import { appendSteerText } from './fleet-tabs.ts';
32
+ import {
33
+ parseReviewDiff,
34
+ flattenHunks,
35
+ hunkLineRange,
36
+ buildSteerMessage,
37
+ type ReviewFile,
38
+ type ReviewHunk,
39
+ type HunkComment,
40
+ } from './diff-review-model.ts';
41
+
42
+ const COLOR = extendPalette(DEFAULT_PANEL_PALETTE, {
43
+ addition: DIFF_TONES.add,
44
+ deletion: DIFF_TONES.del,
45
+ hunk: DIFF_TONES.hunk,
46
+ } as const);
47
+
48
+ /** A comment attached to a hunk plus whether it has already been steered to the session. */
49
+ interface CommentState {
50
+ comment: string;
51
+ submitted: boolean;
52
+ }
53
+
54
+ function basename(p: string): string {
55
+ const parts = p.replace(/\\/g, '/').split('/');
56
+ return parts[parts.length - 1] ?? p;
57
+ }
58
+
59
+ export class DiffReviewPanel extends BasePanel {
60
+ public override isTransient = true;
61
+
62
+ private readonly workingDirectory: string;
63
+ private readonly getChangedFiles: (() => string[]) | undefined;
64
+ private files: ReviewFile[] = [];
65
+ private hunks: ReviewHunk[] = [];
66
+ private cursor = 0;
67
+ private scrollOffset = 0;
68
+ /** Comments keyed by `${fileIndex}:${hunkIndex}`. */
69
+ private comments = new Map<string, CommentState>();
70
+ private composing = false;
71
+ private draft = '';
72
+ private submitSteer: ((text: string) => void) | null = null;
73
+ /** Injected by the /review command: reverse-apply (reject) the given hunk via checkpoints.revertHunk. */
74
+ private revertHunk: ((hunk: ReviewHunk) => void) | null = null;
75
+ private sourceLabel = 'working tree vs HEAD (files edited this session)';
76
+ private status: string | null = null;
77
+ /** True once a load has completed (so the empty state can distinguish "no changes" from "not loaded"). */
78
+ private loaded = false;
79
+
80
+ constructor(
81
+ workingDirectory: string,
82
+ private readonly requestRender: () => void = () => {},
83
+ getChangedFiles?: () => string[],
84
+ ) {
85
+ super('review', 'Review', 'R', 'development');
86
+ this.workingDirectory = workingDirectory;
87
+ this.getChangedFiles = getChangedFiles;
88
+ }
89
+
90
+ // -------------------------------------------------------------------------
91
+ // Public API — driven by the /review command
92
+ // -------------------------------------------------------------------------
93
+
94
+ /** Wire the steering submit path (the /review command passes ctx.submitInput). */
95
+ setSubmit(fn: (text: string) => void): void {
96
+ this.submitSteer = fn;
97
+ }
98
+
99
+ /** Wire the hunk-revert path (the /review command drives checkpoints.revertHunk). */
100
+ setRevertHandler(fn: (hunk: ReviewHunk) => void): void {
101
+ this.revertHunk = fn;
102
+ }
103
+
104
+ /** Surface a status line (the revert flow reports conflicts/outcomes here). */
105
+ note(message: string): void {
106
+ this.status = message;
107
+ this.markDirty();
108
+ this.requestRender();
109
+ }
110
+
111
+ /** Re-load the session diff — used after a revert mutates the working tree. */
112
+ async refresh(): Promise<void> {
113
+ await this.loadSessionReview();
114
+ this.requestRender();
115
+ }
116
+
117
+ private keyFor(hunk: ReviewHunk): string {
118
+ return `${hunk.fileIndex}:${hunk.hunkIndex}`;
119
+ }
120
+
121
+ private currentHunk(): ReviewHunk | null {
122
+ return this.hunks[this.cursor] ?? null;
123
+ }
124
+
125
+ /** Adopt a parsed review set with an explicit provenance label. */
126
+ loadReview(files: ReviewFile[], sourceLabel: string): void {
127
+ this.files = files;
128
+ this.hunks = flattenHunks(files);
129
+ this.cursor = 0;
130
+ this.scrollOffset = 0;
131
+ this.comments.clear();
132
+ this.composing = false;
133
+ this.draft = '';
134
+ this.sourceLabel = sourceLabel;
135
+ this.loaded = true;
136
+ this.markDirty();
137
+ }
138
+
139
+ /**
140
+ * Load the session's changed files as a git diff against `ref` (default HEAD)
141
+ * and parse it into per-file hunks. Honest source: current working-tree
142
+ * content vs the base ref, restricted to files this session edited.
143
+ */
144
+ async loadSessionReview(ref = 'HEAD'): Promise<void> {
145
+ if (!GitService.isGitRepo(this.workingDirectory)) {
146
+ this.status = 'Not a git repository — no working-tree diff to review.';
147
+ this.loaded = true;
148
+ this.markDirty();
149
+ return;
150
+ }
151
+ const files = (this.getChangedFiles?.() ?? []).filter(Boolean);
152
+ const args = ['diff', ref, '--', ...files];
153
+ const wholeRepo = files.length === 0;
154
+ const proc = Bun.spawn(['git', ...(wholeRepo ? ['diff', ref] : args)], { stdout: 'pipe', stderr: 'pipe', cwd: this.workingDirectory });
155
+ const [raw, errText] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
156
+ const exitCode = await proc.exited;
157
+ if (exitCode !== 0) {
158
+ this.status = `git diff failed: ${errText.trim() || 'unknown error'}`;
159
+ this.loaded = true;
160
+ this.markDirty();
161
+ return;
162
+ }
163
+ const label = wholeRepo
164
+ ? `working tree vs ${ref} (no session-tracked files; showing whole working tree)`
165
+ : `working tree vs ${ref} (${files.length} file${files.length === 1 ? '' : 's'} edited this session)`;
166
+ this.loadReview(parseReviewDiff(raw), label);
167
+ }
168
+
169
+ // -------------------------------------------------------------------------
170
+ // Input
171
+ // -------------------------------------------------------------------------
172
+
173
+ handleInput(key: string): boolean {
174
+ if (this.composing) return this.handleComposerInput(key);
175
+ switch (key) {
176
+ case 'up': case 'k': this.moveCursor(-1); return true;
177
+ case 'down': case 'j': this.moveCursor(1); return true;
178
+ case 'tab': this.jumpFile(1); return true;
179
+ case '\x1b[Z': case 'shift-tab': case 'backtab': this.jumpFile(-1); return true;
180
+ case 'pageup': this.moveCursor(-5); return true;
181
+ case 'pagedown': this.moveCursor(5); return true;
182
+ case 'c': this.openComposer(); return true;
183
+ case 'x': this.clearComment(); return true;
184
+ case 'a': this.submitAll(); return true;
185
+ case 'r': this.rejectCurrent(); return true;
186
+ case 'enter': case 'return': this.submitCurrent(); return true;
187
+ default: return false;
188
+ }
189
+ }
190
+
191
+ private handleComposerInput(key: string): boolean {
192
+ if (isPanelSearchCancel(key)) {
193
+ this.composing = false;
194
+ this.draft = '';
195
+ this.status = 'Comment cancelled.';
196
+ this.markDirty();
197
+ return true;
198
+ }
199
+ if (isPanelSearchCommit(key)) {
200
+ const hunk = this.currentHunk();
201
+ const text = this.draft.trim();
202
+ if (hunk && text.length > 0) {
203
+ this.comments.set(this.keyFor(hunk), { comment: text, submitted: false });
204
+ this.status = 'Comment attached. Press Enter to send it, or a to send all.';
205
+ } else {
206
+ this.status = 'Empty comment discarded.';
207
+ }
208
+ this.composing = false;
209
+ this.draft = '';
210
+ this.markDirty();
211
+ return true;
212
+ }
213
+ if (isPanelSearchBackspace(key)) {
214
+ this.draft = this.draft.slice(0, -1);
215
+ this.markDirty();
216
+ return true;
217
+ }
218
+ if (key.length === 1 && (isPanelSearchPrintable(key) || key === '\r' || key === '\n')) {
219
+ this.draft = appendSteerText(this.draft, key);
220
+ this.markDirty();
221
+ return true;
222
+ }
223
+ return true; // swallow other keys while composing
224
+ }
225
+
226
+ private moveCursor(delta: number): void {
227
+ if (this.hunks.length === 0) return;
228
+ this.cursor = Math.max(0, Math.min(this.hunks.length - 1, this.cursor + delta));
229
+ this.markDirty();
230
+ }
231
+
232
+ private jumpFile(direction: number): void {
233
+ const cur = this.currentHunk();
234
+ if (!cur) return;
235
+ const targetFile = cur.fileIndex + direction;
236
+ const idx = this.hunks.findIndex((h) => h.fileIndex === targetFile);
237
+ if (idx >= 0) {
238
+ this.cursor = idx;
239
+ this.markDirty();
240
+ }
241
+ }
242
+
243
+ private openComposer(): void {
244
+ const hunk = this.currentHunk();
245
+ if (!hunk) return;
246
+ this.composing = true;
247
+ this.draft = this.comments.get(this.keyFor(hunk))?.comment ?? '';
248
+ this.status = null;
249
+ this.markDirty();
250
+ }
251
+
252
+ private clearComment(): void {
253
+ const hunk = this.currentHunk();
254
+ if (!hunk) return;
255
+ if (this.comments.delete(this.keyFor(hunk))) {
256
+ this.status = 'Comment removed.';
257
+ this.markDirty();
258
+ }
259
+ }
260
+
261
+ private steer(items: HunkComment[]): boolean {
262
+ if (!this.submitSteer) {
263
+ this.status = 'No session is wired to receive steering here.';
264
+ this.markDirty();
265
+ return false;
266
+ }
267
+ if (items.length === 0) {
268
+ this.status = 'No comment to send. Press c to write one.';
269
+ this.markDirty();
270
+ return false;
271
+ }
272
+ this.submitSteer(buildSteerMessage(items, this.sourceLabel));
273
+ for (const { hunk } of items) {
274
+ const state = this.comments.get(this.keyFor(hunk));
275
+ if (state) state.submitted = true;
276
+ }
277
+ this.status = items.length === 1
278
+ ? 'Comment sent to the session as a steering message.'
279
+ : `${items.length} comments sent to the session as one steering message.`;
280
+ this.markDirty();
281
+ this.requestRender();
282
+ return true;
283
+ }
284
+
285
+ private submitCurrent(): void {
286
+ const hunk = this.currentHunk();
287
+ if (!hunk) return;
288
+ const state = this.comments.get(this.keyFor(hunk));
289
+ if (!state || state.comment.length === 0) {
290
+ this.status = 'This hunk has no comment yet. Press c to write one.';
291
+ this.markDirty();
292
+ return;
293
+ }
294
+ this.steer([{ hunk, comment: state.comment }]);
295
+ }
296
+
297
+ /** Reject the selected hunk: hand it to the revert flow (preview → confirm → revertHunk). */
298
+ private rejectCurrent(): void {
299
+ const hunk = this.currentHunk();
300
+ if (!hunk) return;
301
+ if (!this.revertHunk) {
302
+ this.note('Hunk revert is not wired in this session.');
303
+ return;
304
+ }
305
+ this.revertHunk(hunk);
306
+ }
307
+
308
+ private submitAll(): void {
309
+ const items: HunkComment[] = [];
310
+ for (const hunk of this.hunks) {
311
+ const state = this.comments.get(this.keyFor(hunk));
312
+ if (state && !state.submitted && state.comment.length > 0) items.push({ hunk, comment: state.comment });
313
+ }
314
+ this.steer(items);
315
+ }
316
+
317
+ // -------------------------------------------------------------------------
318
+ // Render
319
+ // -------------------------------------------------------------------------
320
+
321
+ private hunkRow(hunk: ReviewHunk, isCursor: boolean, width: number): Line {
322
+ const state = this.comments.get(this.keyFor(hunk));
323
+ const marker = !state ? ' ' : state.submitted ? '[✓]' : '[c]';
324
+ const range = hunkLineRange(hunk);
325
+ const label = `${marker} ${basename(hunk.filePath)}:${range.start}-${range.end} +${hunk.added} -${hunk.removed}`;
326
+ return buildStyledPanelLine(width, [{
327
+ text: truncateDisplay(`${isCursor ? '▸ ' : ' '}${label}`, width),
328
+ fg: state?.submitted ? COLOR.addition : isCursor ? COLOR.value : COLOR.dim,
329
+ bg: isCursor ? UI_TONES.bg.selected : undefined,
330
+ bold: isCursor,
331
+ }]);
332
+ }
333
+
334
+ private detailLines(hunk: ReviewHunk, width: number): Line[] {
335
+ const lines: Line[] = [];
336
+ lines.push(buildStyledPanelLine(width, [{ text: truncateDisplay(` ${hunk.filePath}`, width), fg: COLOR.value, bold: true }]));
337
+ lines.push(buildStyledPanelLine(width, [{ text: truncateDisplay(` ${hunk.header}`, width), fg: COLOR.hunk }]));
338
+ for (const raw of hunk.bodyLines.slice(0, 8)) {
339
+ const fg = raw.startsWith('+') ? COLOR.addition : raw.startsWith('-') ? COLOR.deletion : COLOR.dim;
340
+ lines.push(buildStyledPanelLine(width, [{ text: truncateDisplay(` ${raw}`, width), fg }]));
341
+ }
342
+ const state = this.comments.get(this.keyFor(hunk));
343
+ if (this.composing) {
344
+ lines.push(buildStyledPanelLine(width, [{ text: truncateDisplay(` comment> ${this.draft}_`, width), fg: COLOR.value, bold: true }]));
345
+ } else if (state) {
346
+ const tag = state.submitted ? '(sent)' : '(unsent)';
347
+ lines.push(buildStyledPanelLine(width, [{ text: truncateDisplay(` comment ${tag}: ${state.comment}`, width), fg: state.submitted ? COLOR.addition : COLOR.value }]));
348
+ }
349
+ return lines;
350
+ }
351
+
352
+ private footer(width: number): Line {
353
+ const hints = this.composing
354
+ ? ' Enter attach Esc cancel'
355
+ : ' ↑/↓ hunk Tab file c comment Enter send a send all r revert x clear';
356
+ const text = this.status ? `${hints} • ${this.status}` : hints;
357
+ return buildStyledPanelLine(width, [{ text: truncateDisplay(text, width), fg: COLOR.dim, bg: COLOR.sectionBg }], { fillBg: COLOR.sectionBg });
358
+ }
359
+
360
+ render(width: number, height: number): Line[] {
361
+ return this.trackedRender(() => {
362
+ if (height <= 0 || width <= 0) return [];
363
+ if (this.hunks.length === 0) {
364
+ const detail = this.loaded
365
+ ? (this.status ?? 'No changes to review. Files edited this session appear here as hunks.')
366
+ : 'Loading session changes…';
367
+ return buildPanelWorkspace(width, height, {
368
+ title: 'Review',
369
+ palette: COLOR,
370
+ sections: [{ title: 'Review', lines: buildEmptyState(width, ' Nothing to review yet.', detail, [{ command: '/review', summary: 'load this session\'s file changes as commentable hunks' }], COLOR) }],
371
+ });
372
+ }
373
+
374
+ const hunk = this.currentHunk();
375
+ const listLines = this.hunks.map((h, i) => this.hunkRow(h, i === this.cursor, width));
376
+ const section = resolveScrollablePanelSection(width, height, {
377
+ palette: COLOR,
378
+ footerLines: [this.footer(width)],
379
+ beforeSections: [{ title: `Selected hunk (${this.sourceLabel})`, lines: hunk ? this.detailLines(hunk, width) : [] }],
380
+ section: { title: `Hunks (${this.cursor + 1}/${this.hunks.length})`, scrollableLines: listLines, scrollOffset: this.scrollOffset, selectedIndex: this.cursor, minRows: 1 },
381
+ });
382
+ this.scrollOffset = section.scrollOffset;
383
+
384
+ return buildPanelWorkspace(width, height, {
385
+ title: 'Review',
386
+ palette: COLOR,
387
+ footerLines: [this.footer(width)],
388
+ sections: [
389
+ { title: `Selected hunk (${this.sourceLabel})`, lines: hunk ? this.detailLines(hunk, width) : [createEmptyLine(width)] },
390
+ { title: section.section.title, lines: section.section.lines },
391
+ ],
392
+ });
393
+ });
394
+ }
395
+ }
@@ -7,10 +7,20 @@
7
7
  // renderItem/renderDetail call, with no `this` dependency.
8
8
  // ---------------------------------------------------------------------------
9
9
 
10
- import type { ProcessCostState, ProcessUsage } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
10
+ import type { ProcessCostState, ProcessNode, ProcessUsage } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
11
+ import type { Line } from '../types/grid.ts';
11
12
  import { formatAgentCost } from './agent-inspector-shared.ts';
12
- import { DEFAULT_PANEL_PALETTE, type ColumnSpec, type PanelPalette } from './polish.ts';
13
+ import { buildPanelLine, DEFAULT_PANEL_PALETTE, type ColumnSpec, type PanelPalette } from './polish.ts';
13
14
  import { fleetUsageTokens, hasFleetCost, type FleetStateTone } from './fleet-read-model.ts';
15
+ import { fleetStateDisplay } from './fleet-stop.ts';
16
+ import { formatElapsed } from '../utils/format-elapsed.ts';
17
+ import { truncateDisplay } from '../utils/terminal-width.ts';
18
+ import { formatWorkItemIsolationDetailFromRaw } from './fleet-panel-worktree-detail.ts';
19
+ import { buildAlignedRow } from './polish.ts';
20
+ import { fleetKindTag } from './fleet-read-model.ts';
21
+ import { steerBadgeGlyph, steerBadgeTone } from './fleet-steer.ts';
22
+ import type { SteerBadge } from './fleet-tabs.ts';
23
+ import type { FleetTreeRow } from './fleet-read-model.ts';
14
24
 
15
25
  // Column widths for the tree row layout. `label` absorbs whatever width is
16
26
  // left over after the fixed columns + gaps; on hostile (narrow) widths the
@@ -60,3 +70,105 @@ export function formatFleetCost(costUsd: number | null | undefined, costState: P
60
70
  const formatted = formatAgentCost(costUsd as number);
61
71
  return costState === 'estimated' ? `~${formatted}` : formatted;
62
72
  }
73
+
74
+ /**
75
+ * One fleet tree row. Pure (extracted from FleetPanel.renderItem for the
76
+ * 800-line cap). Two display-only overrides in priority order: an in-flight
77
+ * `stopping` wins the glyph/label/activity, then a `blocked`-on-user node gets
78
+ * the ⚑ badge + 'blocked on you' in the activity slot. Otherwise a queued-steer
79
+ * badge (if any) prefixes the live activity text.
80
+ */
81
+ export function renderFleetRowLine(
82
+ row: FleetTreeRow,
83
+ width: number,
84
+ stopping: boolean,
85
+ blocked: boolean,
86
+ badge: SteerBadge | null,
87
+ palette: PanelPalette = DEFAULT_PANEL_PALETTE,
88
+ ): Line {
89
+ const C = palette;
90
+ const node = row.node;
91
+ const disp = fleetStateDisplay(node.state, stopping, blocked);
92
+ const color = toneColor(disp.tone, C);
93
+ // Best-of-N grouping: badge a sibling attempt so the N candidates read as one
94
+ // group and a held (pick-ready) candidate is visible (node.attemptGroup, SDK).
95
+ const attemptBadge = node.attemptGroup
96
+ ? ` [attempt ${node.attemptGroup.index + 1}/${node.attemptGroup.total}${node.attemptGroup.held ? ', held' : ''}]`
97
+ : '';
98
+ const label = `${row.treePrefix}${node.label}${attemptBadge}`;
99
+ // Activity column doubles as the badge slot: 'blocked on you' shouts the
100
+ // wait louder than the raw currentActivity text ('awaiting approval …').
101
+ const activity = stopping
102
+ ? disp.label
103
+ : blocked
104
+ ? 'blocked on you'
105
+ : badge
106
+ ? `${steerBadgeGlyph(badge.status)} ${node.currentActivity?.text ?? ''}`.trimEnd()
107
+ : (node.currentActivity?.text ?? '');
108
+ const activityColor = stopping ? color : blocked ? color : badge ? steerBadgeTone(badge.status, C) : C.dim;
109
+
110
+ return buildAlignedRow(
111
+ width,
112
+ [
113
+ { text: disp.glyph, fg: color },
114
+ { text: fleetKindTag(node.kind), fg: C.dim },
115
+ { text: label, fg: C.value },
116
+ { text: formatElapsed(node.elapsedMs), fg: C.dim },
117
+ { text: formatFleetTokens(node.usage), fg: C.dim },
118
+ { text: formatFleetCost(node.costUsd, node.costState), fg: C.value },
119
+ { text: activity, fg: activityColor },
120
+ ],
121
+ planColumns(width),
122
+ );
123
+ }
124
+
125
+ /**
126
+ * The selected-node detail block under the fleet tree. Pure (extracted from
127
+ * FleetPanel.renderDetail to hold that file under the 800-line architecture
128
+ * cap). `stopping`/`blocked` are display-only overrides the caller derives from
129
+ * the stop tracker + node state — mirror the tree row exactly so the literal
130
+ * 'state' text never claims a past-tense outcome mid-write, and a
131
+ * blocked-on-user node reads 'blocked on you' here too.
132
+ */
133
+ export function renderFleetDetailLines(
134
+ node: ProcessNode,
135
+ width: number,
136
+ stopping: boolean,
137
+ blocked: boolean,
138
+ palette: PanelPalette = DEFAULT_PANEL_PALETTE,
139
+ ): Line[] {
140
+ const C = palette;
141
+ const disp = fleetStateDisplay(node.state, stopping, blocked);
142
+ const color = toneColor(disp.tone, C);
143
+
144
+ const line1 = buildPanelLine(width, [
145
+ [' ', C.dim],
146
+ [disp.glyph, color],
147
+ [` ${node.kind}`, C.dim],
148
+ [' id ', C.label],
149
+ [node.id, C.value],
150
+ [' state ', C.label],
151
+ [disp.label, color],
152
+ [' elapsed ', C.label],
153
+ [formatElapsed(node.elapsedMs), C.value],
154
+ ]);
155
+ const line2 = buildPanelLine(width, [
156
+ [' model ', C.label],
157
+ [node.model ?? 'unknown', C.info],
158
+ [' tokens ', C.label],
159
+ [formatFleetTokens(node.usage), C.value],
160
+ [' cost ', C.label],
161
+ [formatFleetCost(node.costUsd, node.costState), C.value],
162
+ ]);
163
+ const activityText = node.currentActivity
164
+ ? `${node.currentActivity.kind}: ${node.currentActivity.text}`
165
+ : '(no recent activity)';
166
+ const line3 = buildPanelLine(width, [
167
+ [' activity ', C.label],
168
+ [truncateDisplay(activityText, Math.max(0, width - 11)), C.dim],
169
+ ]);
170
+ // Approval history attaches here once session tabs land.
171
+ const line4 = buildPanelLine(width, [[' approvals ', C.label], ['—', C.dim]]);
172
+ const isolationDetail = node.kind === 'work-item' ? formatWorkItemIsolationDetailFromRaw(node.raw) : null;
173
+ return [line1, line2, line3, line4, ...(isolationDetail ? [buildPanelLine(width, [[' isolation ', C.label], [isolationDetail, C.dim]])] : [])];
174
+ }