@pellux/goodvibes-tui 1.13.1 → 1.15.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/CHANGELOG.md +104 -0
- package/README.md +6 -6
- package/bin/goodvibes +6 -0
- package/bin/goodvibes-daemon +6 -0
- package/bin/launcher-support.js +34 -1
- package/docs/foundation-artifacts/operator-contract.json +8076 -2047
- package/package.json +15 -11
- package/scripts/postinstall.js +73 -48
- package/src/cli/bundle-command.ts +5 -5
- package/src/cli/completion.ts +1 -1
- package/src/cli/completions/generate.ts +15 -3
- package/src/cli/doctor.ts +385 -0
- package/src/cli/entrypoint.ts +72 -1
- package/src/cli/help.ts +29 -9
- package/src/cli/hooks-command.ts +74 -0
- package/src/cli/hooks-report.ts +167 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/management.ts +13 -1
- package/src/cli/parser.ts +10 -2
- package/src/cli/plugin-bundles-command.ts +228 -0
- package/src/cli/plugin-command.ts +179 -0
- package/src/cli/service-posture.ts +10 -0
- package/src/cli/status.ts +275 -2
- package/src/cli/tui-startup.ts +119 -1
- package/src/cli/types.ts +3 -1
- package/src/config/index.ts +16 -0
- package/src/config/tui-extension-settings.ts +169 -0
- package/src/core/approval-alert.ts +15 -2
- package/src/core/compaction-receipt.ts +127 -0
- package/src/core/model-routing-chip.ts +82 -0
- package/src/core/permission-mode.ts +89 -0
- package/src/core/rewind-receipt.ts +83 -0
- package/src/core/rewind-turn-anchors.ts +160 -0
- package/src/core/scriptable-statusline.ts +141 -0
- package/src/core/session-auto-titler.ts +110 -0
- package/src/core/stream-event-wiring.ts +20 -0
- package/src/core/system-message-router.ts +1 -1
- package/src/core/terminal-notifier.ts +102 -0
- package/src/core/turn-event-wiring.ts +80 -1
- package/src/input/command-reference.ts +96 -0
- package/src/input/command-registry.ts +25 -0
- package/src/input/commands/channel-pairing.ts +245 -0
- package/src/input/commands/channel-runtime.ts +152 -6
- package/src/input/commands/checkin-runtime.ts +89 -0
- package/src/input/commands/checkpoint-runtime.ts +7 -179
- package/src/input/commands/ci-runtime.ts +207 -0
- package/src/input/commands/cost-attribution-format.ts +52 -0
- package/src/input/commands/cost-runtime.ts +44 -4
- package/src/input/commands/editor-runtime.ts +22 -0
- package/src/input/commands/flags-runtime.ts +328 -0
- package/src/input/commands/health-metrics-format.ts +67 -0
- package/src/input/commands/health-runtime.ts +55 -1
- package/src/input/commands/memory.ts +23 -0
- package/src/input/commands/operator-rpc.ts +95 -0
- package/src/input/commands/palette-runtime.ts +22 -0
- package/src/input/commands/permissions-provenance.ts +264 -0
- package/src/input/commands/permissions-runtime.ts +21 -0
- package/src/input/commands/planning-runtime.ts +28 -1
- package/src/input/commands/platform-access-runtime.ts +2 -97
- package/src/input/commands/principals-runtime.ts +212 -0
- package/src/input/commands/product-runtime.ts +43 -3
- package/src/input/commands/recall-files-config.ts +54 -0
- package/src/input/commands/recall-files-git.ts +57 -0
- package/src/input/commands/recall-files.ts +185 -0
- package/src/input/commands/recall-query.ts +8 -3
- package/src/input/commands/recall-review.ts +2 -2
- package/src/input/commands/recall-shared.ts +13 -0
- package/src/input/commands/relay-runtime.ts +86 -0
- package/src/input/commands/review-runtime.ts +160 -0
- package/src/input/commands/rewind-runtime.ts +362 -0
- package/src/input/commands/schedule-nl.ts +147 -0
- package/src/input/commands/schedule-runtime.ts +29 -10
- package/src/input/commands/secret-runtime.ts +55 -0
- package/src/input/commands/session-content.ts +19 -8
- package/src/input/commands/session-workflow.ts +10 -0
- package/src/input/commands/update-runtime.ts +412 -0
- package/src/input/commands/workstream-attempt-dependents.ts +103 -0
- package/src/input/commands/workstream-attempts.ts +244 -0
- package/src/input/commands/workstream-runtime.ts +164 -26
- package/src/input/commands/worktree-runtime.ts +56 -2
- package/src/input/commands.ts +135 -80
- package/src/input/composer-capture.ts +67 -0
- package/src/input/composer-editor.ts +134 -0
- package/src/input/concealed-input.ts +94 -0
- package/src/input/exec-env-scrub-config.ts +84 -0
- package/src/input/feature-unit-layout.ts +142 -0
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed-routes.ts +17 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-modal-routes.ts +5 -2
- package/src/input/handler-shortcuts.ts +37 -0
- package/src/input/handler.ts +24 -2
- package/src/input/keybindings.ts +11 -2
- package/src/input/onboarding/onboarding-feature-units.ts +536 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
- package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
- package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
- package/src/input/relay-settings-descriptions.ts +35 -0
- package/src/input/sandbox-exec-config.ts +107 -0
- package/src/input/settings-modal-adjustment.ts +4 -7
- package/src/input/settings-modal-data.ts +178 -8
- package/src/input/settings-modal-mutations.ts +41 -13
- package/src/input/settings-modal-types.ts +42 -5
- package/src/input/settings-modal.ts +67 -15
- package/src/input/submission-router.ts +4 -1
- package/src/input/worktree-setup-config.ts +93 -0
- package/src/main.ts +43 -43
- package/src/panels/builtin/development.ts +14 -0
- package/src/panels/diff-review-model.ts +189 -0
- package/src/panels/diff-review-panel.ts +395 -0
- package/src/panels/fleet-panel-format.ts +114 -2
- package/src/panels/fleet-panel.ts +65 -78
- package/src/panels/fleet-read-model.ts +91 -11
- package/src/panels/fleet-stop.ts +19 -4
- package/src/panels/modals/memory-modal.ts +11 -2
- package/src/permissions/hunk-selection.ts +86 -5
- package/src/permissions/prompt.ts +108 -7
- package/src/permissions/sandbox-exec-gate.ts +162 -0
- package/src/renderer/settings-modal-helpers.ts +21 -1
- package/src/renderer/settings-modal.ts +70 -44
- package/src/renderer/shell-surface.ts +23 -1
- package/src/renderer/system-message.ts +4 -0
- package/src/renderer/tool-result-summary.ts +95 -5
- package/src/renderer/ui-factory.ts +22 -0
- package/src/runtime/bootstrap-command-context.ts +15 -1
- package/src/runtime/bootstrap-command-parts.ts +14 -2
- package/src/runtime/bootstrap-core.ts +38 -38
- package/src/runtime/bootstrap-notifier-sync.ts +41 -0
- package/src/runtime/bootstrap-shell.ts +8 -0
- package/src/runtime/bootstrap.ts +21 -23
- package/src/runtime/context-accounting-source.ts +103 -0
- package/src/runtime/conversation-rewind-port.ts +130 -0
- package/src/runtime/fleet-needs-input-push.ts +70 -0
- package/src/runtime/install-self-check-startup.ts +36 -0
- package/src/runtime/install-self-check.ts +149 -0
- package/src/runtime/onboarding/fast-path.ts +93 -0
- package/src/runtime/process-lifecycle.ts +32 -24
- package/src/runtime/relay-reachability-bridge.ts +53 -0
- package/src/runtime/release-artifacts.ts +109 -0
- package/src/runtime/services.ts +33 -33
- package/src/runtime/trust/workspace-registration.ts +184 -0
- package/src/runtime/trust/workspace-trust.ts +202 -0
- package/src/runtime/ui-services.ts +2 -1
- package/src/runtime/update-check.ts +113 -0
- package/src/runtime/workspace-checkpointing.ts +32 -0
- package/src/runtime/workstream-attempts-validation.ts +77 -0
- package/src/runtime/workstream-draft-edits.ts +95 -0
- package/src/runtime/workstream-draft-store.ts +101 -0
- package/src/runtime/workstream-draft-types.ts +42 -0
- package/src/runtime/workstream-services.ts +70 -39
- package/src/shell/blocking-input.ts +29 -0
- package/src/shell/ui-openers.ts +65 -0
- package/src/verification/verification-ledger.ts +56 -2
- package/src/version.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
- package/src/runtime/gateway-verbs.ts +0 -47
- 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
|
+
}
|