@pellux/goodvibes-tui 0.28.0 → 0.29.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 +101 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/input/commands/cost-runtime.ts +49 -0
- package/src/input/commands/operator-runtime.ts +5 -1
- package/src/input/commands.ts +2 -0
- package/src/input/handler-feed-routes.ts +12 -35
- package/src/input/handler-feed.ts +4 -8
- package/src/input/handler-shortcuts.ts +51 -0
- package/src/input/handler.ts +43 -6
- package/src/input/keybindings.ts +48 -8
- package/src/input/panel-integration-actions.ts +107 -1
- package/src/main.ts +5 -1
- package/src/panels/agent-inspector-panel.ts +125 -36
- package/src/panels/agent-inspector-shared.ts +144 -0
- package/src/panels/approval-panel.ts +67 -16
- package/src/panels/automation-control-panel.ts +368 -124
- package/src/panels/builtin/agent.ts +28 -37
- package/src/panels/builtin/development.ts +40 -32
- package/src/panels/builtin/knowledge.ts +15 -6
- package/src/panels/builtin/operations.ts +178 -117
- package/src/panels/builtin/session.ts +35 -9
- package/src/panels/builtin/shared.ts +98 -6
- package/src/panels/cockpit-panel.ts +232 -73
- package/src/panels/communication-panel.ts +58 -20
- package/src/panels/confirm-state.ts +8 -1
- package/src/panels/control-plane-panel.ts +351 -118
- package/src/panels/cost-tracker-panel.ts +165 -7
- package/src/panels/debug-panel.ts +312 -159
- package/src/panels/diff-panel.ts +203 -57
- package/src/panels/docs-panel.ts +152 -66
- package/src/panels/eval-panel.ts +220 -42
- package/src/panels/file-explorer-panel.ts +202 -122
- package/src/panels/file-preview-panel.ts +132 -57
- package/src/panels/git-panel.ts +309 -128
- package/src/panels/hooks-panel.ts +150 -22
- package/src/panels/incident-review-panel.ts +223 -35
- package/src/panels/index.ts +0 -4
- package/src/panels/intelligence-panel.ts +212 -86
- package/src/panels/knowledge-graph-panel.ts +461 -101
- package/src/panels/local-auth-panel.ts +240 -28
- package/src/panels/marketplace-panel.ts +193 -26
- package/src/panels/memory-panel.ts +78 -77
- package/src/panels/ops-control-panel.ts +146 -29
- package/src/panels/ops-strategy-panel.ts +72 -4
- package/src/panels/orchestration-panel.ts +231 -69
- package/src/panels/panel-list-panel.ts +154 -131
- package/src/panels/panel-manager.ts +97 -9
- package/src/panels/plan-dashboard-panel.ts +333 -17
- package/src/panels/plugins-panel.ts +184 -29
- package/src/panels/policy-panel.ts +210 -38
- package/src/panels/polish-core.ts +7 -2
- package/src/panels/polish.ts +23 -4
- package/src/panels/project-planning-answer-actions.ts +134 -0
- package/src/panels/project-planning-panel.ts +62 -113
- package/src/panels/provider-health-panel.ts +434 -518
- package/src/panels/provider-health-routes.ts +203 -0
- package/src/panels/provider-health-tracker.ts +194 -6
- package/src/panels/provider-health-views.ts +560 -0
- package/src/panels/qr-panel.ts +116 -30
- package/src/panels/remote-panel.ts +114 -36
- package/src/panels/routes-panel.ts +63 -22
- package/src/panels/sandbox-panel.ts +174 -42
- package/src/panels/scrollable-list-panel.ts +19 -135
- package/src/panels/security-panel.ts +133 -33
- package/src/panels/services-panel.ts +116 -64
- package/src/panels/session-browser-panel.ts +73 -2
- package/src/panels/session-maintenance.ts +4 -122
- package/src/panels/settings-sync-panel.ts +335 -72
- package/src/panels/skills-panel.ts +157 -89
- package/src/panels/subscription-panel.ts +86 -33
- package/src/panels/symbol-outline-panel.ts +248 -108
- package/src/panels/system-messages-panel.ts +114 -13
- package/src/panels/tasks-panel.ts +326 -139
- package/src/panels/thinking-panel.ts +43 -10
- package/src/panels/token-budget-panel.ts +194 -18
- package/src/panels/tool-inspector-panel.ts +144 -34
- package/src/panels/types.ts +40 -4
- package/src/panels/work-plan-panel.ts +280 -17
- package/src/panels/worktree-panel.ts +175 -42
- package/src/panels/wrfc-panel.ts +116 -24
- package/src/renderer/conversation-overlays.ts +25 -11
- package/src/renderer/footer-tips.ts +41 -0
- package/src/renderer/fullscreen-primitives.ts +22 -16
- package/src/renderer/help-overlay.ts +91 -14
- package/src/renderer/hint-grammar.ts +52 -0
- package/src/renderer/layout.ts +7 -7
- package/src/renderer/modal-factory.ts +23 -15
- package/src/renderer/model-picker-overlay.ts +21 -7
- package/src/renderer/overlay-box.ts +16 -10
- package/src/renderer/process-indicator.ts +3 -1
- package/src/renderer/progress.ts +5 -4
- package/src/renderer/search-overlay.ts +27 -6
- package/src/renderer/session-picker-modal.ts +6 -4
- package/src/renderer/settings-modal.ts +70 -10
- package/src/renderer/shell-surface.ts +41 -15
- package/src/renderer/status-glyphs.ts +11 -9
- package/src/renderer/theme.ts +60 -3
- package/src/renderer/ui-factory.ts +41 -30
- package/src/renderer/ui-primitives.ts +23 -2
- package/src/runtime/bootstrap-shell.ts +35 -18
- package/src/runtime/diagnostics/panels/index.ts +0 -3
- package/src/shell/ui-openers.ts +14 -22
- package/src/utils/format-duration.ts +2 -2
- package/src/utils/splash-lines.ts +44 -3
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +13 -0
- package/src/panels/agent-logs-panel.ts +0 -635
- package/src/panels/agent-logs-shared.ts +0 -129
- package/src/panels/context-visualizer-panel.ts +0 -238
- package/src/panels/forensics-panel.ts +0 -378
- package/src/panels/provider-account-snapshot.ts +0 -259
- package/src/panels/provider-accounts-panel.ts +0 -255
- package/src/panels/provider-stats-panel.ts +0 -391
- package/src/panels/schedule-panel.ts +0 -365
- package/src/panels/watchers-panel.ts +0 -213
- package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
- package/src/runtime/diagnostics/panels/policy.ts +0 -177
package/src/panels/git-panel.ts
CHANGED
|
@@ -4,25 +4,34 @@ import { truncateDisplay, getDisplayWidth } from '../utils/terminal-width.ts';
|
|
|
4
4
|
import { GitService } from '@pellux/goodvibes-sdk/platform/git';
|
|
5
5
|
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
6
6
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
7
|
+
import { UI_TONES } from '../renderer/ui-primitives.ts';
|
|
7
8
|
import {
|
|
8
9
|
buildEmptyState,
|
|
9
10
|
buildKeyboardHints,
|
|
10
11
|
buildPanelLine,
|
|
11
12
|
buildPanelWorkspace,
|
|
13
|
+
buildSearchInputLine,
|
|
12
14
|
resolveScrollablePanelSection,
|
|
13
15
|
buildSelectablePanelLine,
|
|
14
16
|
buildStyledPanelLine,
|
|
15
17
|
DEFAULT_PANEL_PALETTE,
|
|
16
18
|
extendPalette,
|
|
17
19
|
} from './polish.ts';
|
|
20
|
+
import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
|
|
21
|
+
import {
|
|
22
|
+
isPanelSearchBackspace,
|
|
23
|
+
isPanelSearchCancel,
|
|
24
|
+
isPanelSearchCommit,
|
|
25
|
+
isPanelSearchPrintable,
|
|
26
|
+
} from './search-focus.ts';
|
|
18
27
|
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
28
|
// Types
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
29
|
|
|
23
30
|
interface GitFileEntry {
|
|
24
31
|
path: string;
|
|
25
32
|
staged: boolean;
|
|
33
|
+
/** True when this file was touched during the current session (best-effort). */
|
|
34
|
+
sessionChanged?: boolean;
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
interface CommitEntry {
|
|
@@ -46,19 +55,19 @@ type ViewItem =
|
|
|
46
55
|
| { kind: 'section'; label: string }
|
|
47
56
|
| { kind: 'file'; entry: GitFileEntry }
|
|
48
57
|
| { kind: 'commit'; entry: CommitEntry }
|
|
49
|
-
| { kind: 'empty'; label: string }
|
|
50
|
-
|
|
58
|
+
| { kind: 'empty'; label: string };
|
|
59
|
+
|
|
60
|
+
/** Subject carried by the panel's single ConfirmState — either a repo init or a commit. */
|
|
61
|
+
type GitConfirmSubject =
|
|
62
|
+
| { kind: 'init' }
|
|
63
|
+
| { kind: 'commit'; message: string };
|
|
51
64
|
|
|
52
|
-
// ---------------------------------------------------------------------------
|
|
53
65
|
// Constants
|
|
54
|
-
// ---------------------------------------------------------------------------
|
|
55
66
|
|
|
56
67
|
/** Minimum number of diff lines kept visible when clamping scroll offset. */
|
|
57
68
|
const MIN_VISIBLE_DIFF_LINES = 5;
|
|
58
69
|
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
70
|
// Colors
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
71
|
|
|
63
72
|
const C = extendPalette(DEFAULT_PANEL_PALETTE, {
|
|
64
73
|
branch: '#00d7ff',
|
|
@@ -70,14 +79,18 @@ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
|
|
|
70
79
|
selectedFg: '#ffffff',
|
|
71
80
|
diffMeta: '#5f87ff',
|
|
72
81
|
diffNeutral: '250',
|
|
82
|
+
// Reuses the existing workflow accent token rather than adding a new hex
|
|
83
|
+
// literal (architecture gate ratchets the raw-hex-literal count).
|
|
84
|
+
sessionChanged: UI_TONES.accent.workflow,
|
|
73
85
|
});
|
|
74
86
|
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
87
|
// GitPanel
|
|
77
|
-
// ---------------------------------------------------------------------------
|
|
78
88
|
|
|
79
89
|
export class GitPanel extends BasePanel {
|
|
80
90
|
private readonly workingDirectory: string;
|
|
91
|
+
private readonly git: GitService;
|
|
92
|
+
private readonly getChangedFiles?: () => readonly string[];
|
|
93
|
+
|
|
81
94
|
private data: GitData = {
|
|
82
95
|
branch: '...',
|
|
83
96
|
ahead: 0,
|
|
@@ -93,7 +106,7 @@ export class GitPanel extends BasePanel {
|
|
|
93
106
|
/** Selected row index within `items`. */
|
|
94
107
|
private selectedIndex = 0;
|
|
95
108
|
|
|
96
|
-
/** When truthy, shows the diff for the selected file. */
|
|
109
|
+
/** When truthy, shows the diff for the selected file or commit. */
|
|
97
110
|
private expandedDiff: string[] | null = null;
|
|
98
111
|
|
|
99
112
|
/** Scroll offset for both main view and diff view. */
|
|
@@ -103,9 +116,26 @@ export class GitPanel extends BasePanel {
|
|
|
103
116
|
private loading = true;
|
|
104
117
|
private error: string | null = null;
|
|
105
118
|
|
|
106
|
-
|
|
119
|
+
/** True when the last refresh failed because `workingDirectory` isn't a git repo. */
|
|
120
|
+
private notGitRepo = false;
|
|
121
|
+
|
|
122
|
+
/** Single pending confirm — either "init this repo" or "commit with this message". */
|
|
123
|
+
private confirm: ConfirmState<GitConfirmSubject> | null = null;
|
|
124
|
+
|
|
125
|
+
/** Non-null while composing a commit message; null means the compose UI is closed. */
|
|
126
|
+
private commitMessage: string | null = null;
|
|
127
|
+
|
|
128
|
+
constructor(
|
|
129
|
+
workingDirectory: string,
|
|
130
|
+
private readonly requestRender: () => void = () => {},
|
|
131
|
+
getChangedFiles?: () => readonly string[],
|
|
132
|
+
) {
|
|
107
133
|
super('git', 'Git', 'G', 'development');
|
|
108
134
|
this.workingDirectory = workingDirectory;
|
|
135
|
+
// I2: reuse a single GitService instance across the panel's lifetime instead
|
|
136
|
+
// of constructing a new one for every refresh/diff/stage/commit call.
|
|
137
|
+
this.git = new GitService(workingDirectory);
|
|
138
|
+
this.getChangedFiles = getChangedFiles;
|
|
109
139
|
}
|
|
110
140
|
|
|
111
141
|
private _markDirtyAndRender(): void {
|
|
@@ -113,9 +143,7 @@ export class GitPanel extends BasePanel {
|
|
|
113
143
|
this.requestRender();
|
|
114
144
|
}
|
|
115
145
|
|
|
116
|
-
// ---------------------------------------------------------------------------
|
|
117
146
|
// Lifecycle
|
|
118
|
-
// ---------------------------------------------------------------------------
|
|
119
147
|
|
|
120
148
|
override onActivate(): void {
|
|
121
149
|
super.onActivate();
|
|
@@ -137,30 +165,35 @@ export class GitPanel extends BasePanel {
|
|
|
137
165
|
super.onDestroy();
|
|
138
166
|
}
|
|
139
167
|
|
|
140
|
-
// ---------------------------------------------------------------------------
|
|
141
168
|
// Data fetching
|
|
142
|
-
// ---------------------------------------------------------------------------
|
|
143
169
|
|
|
144
|
-
private async refresh(
|
|
170
|
+
private async refresh(): Promise<void> {
|
|
145
171
|
try {
|
|
146
|
-
const git = new GitService(this.workingDirectory);
|
|
147
172
|
const [statusResult, branchResult, logEntries] = await Promise.all([
|
|
148
|
-
git.status(),
|
|
149
|
-
git.branch(),
|
|
150
|
-
git.log(10),
|
|
173
|
+
this.git.status(),
|
|
174
|
+
this.git.branch(),
|
|
175
|
+
this.git.log(10),
|
|
151
176
|
]);
|
|
152
177
|
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
178
|
+
const changed = this.getChangedFiles ? new Set(this.getChangedFiles()) : null;
|
|
179
|
+
const markChanged = (path: string): boolean | undefined => (changed ? changed.has(path) : undefined);
|
|
180
|
+
|
|
181
|
+
// Classify from the raw per-file porcelain columns (index/working_dir)
|
|
182
|
+
// rather than simple-git's `modified`/`staged` convenience arrays: those
|
|
183
|
+
// arrays both include a file that is staged with no further edits (index
|
|
184
|
+
// 'M', working_dir ' '), which would otherwise show it as both staged
|
|
185
|
+
// AND unstaged and make 's'/'u' land on the wrong (phantom) row.
|
|
186
|
+
const stagedFiles: GitFileEntry[] = [];
|
|
187
|
+
const unstagedFiles: GitFileEntry[] = [];
|
|
188
|
+
for (const f of statusResult.files) {
|
|
189
|
+
const sessionChanged = markChanged(f.path);
|
|
190
|
+
if (f.index === '?' && f.working_dir === '?') {
|
|
191
|
+
unstagedFiles.push({ path: f.path, staged: false, sessionChanged }); // untracked
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (f.index !== ' ') stagedFiles.push({ path: f.path, staged: true, sessionChanged });
|
|
195
|
+
if (f.working_dir !== ' ') unstagedFiles.push({ path: f.path, staged: false, sessionChanged });
|
|
196
|
+
}
|
|
164
197
|
|
|
165
198
|
this.data = {
|
|
166
199
|
branch: branchResult.current || 'HEAD',
|
|
@@ -178,28 +211,19 @@ export class GitPanel extends BasePanel {
|
|
|
178
211
|
|
|
179
212
|
this.loading = false;
|
|
180
213
|
this.error = null;
|
|
214
|
+
this.notGitRepo = false;
|
|
181
215
|
this.rebuildItems();
|
|
182
216
|
// Do not clear expandedDiff during auto-refresh — only clear on explicit user action
|
|
183
217
|
this._markDirtyAndRender();
|
|
184
218
|
} catch (err) {
|
|
185
219
|
const msg = summarizeError(err);
|
|
186
|
-
// If the failure is because this directory isn't a git repo, auto-initialise
|
|
187
|
-
// and retry once so the panel becomes functional immediately.
|
|
188
220
|
if (/not a git\b/i.test(msg)) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
if (!isRetry) {
|
|
194
|
-
// Retry refresh now that the repo exists (once only)
|
|
195
|
-
void this.refresh(true);
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
this.error = 'Not a git repository. Auto-init succeeded but refresh failed.';
|
|
199
|
-
} else {
|
|
200
|
-
this.error = `Not a git repository. Auto-init failed: ${initResult.error ?? 'unknown error'}`;
|
|
201
|
-
}
|
|
221
|
+
// I4: no more auto `git init` side effect — surface the state and let
|
|
222
|
+
// the user explicitly confirm initialisation with 'i'.
|
|
223
|
+
this.notGitRepo = true;
|
|
224
|
+
this.error = 'Not a git repository here. Press i to initialize one (explicit confirm).';
|
|
202
225
|
} else {
|
|
226
|
+
this.notGitRepo = false;
|
|
203
227
|
this.error = msg;
|
|
204
228
|
}
|
|
205
229
|
this.loading = false;
|
|
@@ -208,56 +232,77 @@ export class GitPanel extends BasePanel {
|
|
|
208
232
|
}
|
|
209
233
|
}
|
|
210
234
|
|
|
235
|
+
/** Push a labeled section (with count) plus its rows, or an empty-row placeholder. */
|
|
236
|
+
private pushSection<T>(items: ViewItem[], label: string, entries: readonly T[], emptyLabel: string, toItem: (entry: T) => ViewItem): void {
|
|
237
|
+
items.push({ kind: 'section', label: `${label} (${entries.length})` });
|
|
238
|
+
if (entries.length === 0) items.push({ kind: 'empty', label: emptyLabel });
|
|
239
|
+
else for (const entry of entries) items.push(toItem(entry));
|
|
240
|
+
}
|
|
241
|
+
|
|
211
242
|
/** Rebuild the flat navigable item list from current data. */
|
|
212
243
|
private rebuildItems(): void {
|
|
213
|
-
const items: ViewItem[] = [];
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
items.
|
|
217
|
-
|
|
218
|
-
// Staged files
|
|
219
|
-
items.push({ kind: 'section', label: `Staged (${this.data.stagedFiles.length})` });
|
|
220
|
-
if (this.data.stagedFiles.length === 0) {
|
|
221
|
-
items.push({ kind: 'empty', label: ' (no staged files)' });
|
|
222
|
-
} else {
|
|
223
|
-
for (const entry of this.data.stagedFiles) {
|
|
224
|
-
items.push({ kind: 'file', entry });
|
|
225
|
-
}
|
|
226
|
-
}
|
|
244
|
+
const items: ViewItem[] = [{ kind: 'header' }];
|
|
245
|
+
this.pushSection(items, 'Staged', this.data.stagedFiles, ' (no staged files)', (entry) => ({ kind: 'file', entry }));
|
|
246
|
+
this.pushSection(items, 'Unstaged', this.data.unstagedFiles, ' (no unstaged files)', (entry) => ({ kind: 'file', entry }));
|
|
247
|
+
this.pushSection(items, 'Recent Commits', this.data.recentCommits, ' (no commits)', (entry) => ({ kind: 'commit', entry }));
|
|
248
|
+
this.items = items;
|
|
227
249
|
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
items.push({ kind: 'empty', label: ' (no unstaged files)' });
|
|
232
|
-
} else {
|
|
233
|
-
for (const entry of this.data.unstagedFiles) {
|
|
234
|
-
items.push({ kind: 'file', entry });
|
|
235
|
-
}
|
|
250
|
+
// Keep selection in bounds and off header/section/empty filler rows (I5).
|
|
251
|
+
if (this.selectedIndex >= this.items.length) {
|
|
252
|
+
this.selectedIndex = Math.max(0, this.items.length - 1);
|
|
236
253
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (this.data.recentCommits.length === 0) {
|
|
241
|
-
items.push({ kind: 'empty', label: ' (no commits)' });
|
|
242
|
-
} else {
|
|
243
|
-
for (const entry of this.data.recentCommits) {
|
|
244
|
-
items.push({ kind: 'commit', entry });
|
|
245
|
-
}
|
|
254
|
+
if (!this.isSelectable(this.items.at(this.selectedIndex))) {
|
|
255
|
+
const firstSelectable = this.items.findIndex((it) => this.isSelectable(it));
|
|
256
|
+
if (firstSelectable >= 0) this.selectedIndex = firstSelectable;
|
|
246
257
|
}
|
|
258
|
+
}
|
|
247
259
|
|
|
248
|
-
|
|
260
|
+
/** True for rows the cursor is allowed to rest on (file or commit rows). */
|
|
261
|
+
private isSelectable(item: ViewItem | undefined): boolean {
|
|
262
|
+
return item?.kind === 'file' || item?.kind === 'commit';
|
|
263
|
+
}
|
|
249
264
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
265
|
+
/** Move the selection to the next/previous selectable row, skipping filler rows. */
|
|
266
|
+
private moveSelection(direction: 1 | -1): void {
|
|
267
|
+
let idx = this.selectedIndex;
|
|
268
|
+
for (let i = 0; i < this.items.length; i++) {
|
|
269
|
+
idx += direction;
|
|
270
|
+
if (idx < 0 || idx >= this.items.length) return;
|
|
271
|
+
if (this.isSelectable(this.items[idx])) {
|
|
272
|
+
this.selectedIndex = idx;
|
|
273
|
+
this.markDirty();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
253
276
|
}
|
|
254
277
|
}
|
|
255
278
|
|
|
256
|
-
// ---------------------------------------------------------------------------
|
|
257
279
|
// Input handling
|
|
258
|
-
// ---------------------------------------------------------------------------
|
|
259
280
|
|
|
260
281
|
handleInput(key: string): boolean {
|
|
282
|
+
// Confirm (init repo / commit) takes priority over everything else.
|
|
283
|
+
const confirmResult = handleConfirmInput(this.confirm, key);
|
|
284
|
+
if (confirmResult === 'confirmed') {
|
|
285
|
+
const subject = this.confirm!.subject;
|
|
286
|
+
this.confirm = null;
|
|
287
|
+
if (subject.kind === 'init') {
|
|
288
|
+
void this.performInit();
|
|
289
|
+
} else {
|
|
290
|
+
void this.performCommit(subject.message);
|
|
291
|
+
}
|
|
292
|
+
this.markDirty();
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
if (confirmResult === 'cancelled') {
|
|
296
|
+
this.confirm = null;
|
|
297
|
+
this.markDirty();
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
if (confirmResult === 'absorbed') return true;
|
|
301
|
+
|
|
302
|
+
if (this.commitMessage !== null) {
|
|
303
|
+
return this.handleCommitMessageInput(key);
|
|
304
|
+
}
|
|
305
|
+
|
|
261
306
|
if (this.expandedDiff !== null) {
|
|
262
307
|
return this.handleDiffInput(key);
|
|
263
308
|
}
|
|
@@ -268,18 +313,12 @@ export class GitPanel extends BasePanel {
|
|
|
268
313
|
switch (key) {
|
|
269
314
|
case 'up':
|
|
270
315
|
case 'k': {
|
|
271
|
-
|
|
272
|
-
this.selectedIndex--;
|
|
273
|
-
this.markDirty();
|
|
274
|
-
}
|
|
316
|
+
this.moveSelection(-1);
|
|
275
317
|
return true;
|
|
276
318
|
}
|
|
277
319
|
case 'down':
|
|
278
320
|
case 'j': {
|
|
279
|
-
|
|
280
|
-
this.selectedIndex++;
|
|
281
|
-
this.markDirty();
|
|
282
|
-
}
|
|
321
|
+
this.moveSelection(1);
|
|
283
322
|
return true;
|
|
284
323
|
}
|
|
285
324
|
case 'return': {
|
|
@@ -290,11 +329,59 @@ export class GitPanel extends BasePanel {
|
|
|
290
329
|
void this.refresh();
|
|
291
330
|
return true;
|
|
292
331
|
}
|
|
332
|
+
case 's': {
|
|
333
|
+
void this.setStaged(true);
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
case 'u': {
|
|
337
|
+
void this.setStaged(false);
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
case 'c': {
|
|
341
|
+
this.commitMessage = '';
|
|
342
|
+
this.markDirty();
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
case 'i': {
|
|
346
|
+
// Only meaningful when no repo is loaded — otherwise leave the key
|
|
347
|
+
// unconsumed instead of absorbing it as a silent no-op.
|
|
348
|
+
if (!this.notGitRepo) return false;
|
|
349
|
+
this.confirm = { subject: { kind: 'init' }, label: this.workingDirectory, verb: 'Init' };
|
|
350
|
+
this.markDirty();
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
293
353
|
default:
|
|
294
354
|
return false;
|
|
295
355
|
}
|
|
296
356
|
}
|
|
297
357
|
|
|
358
|
+
private handleCommitMessageInput(key: string): boolean {
|
|
359
|
+
if (isPanelSearchCancel(key)) {
|
|
360
|
+
this.commitMessage = null;
|
|
361
|
+
this.markDirty();
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
if (isPanelSearchCommit(key)) {
|
|
365
|
+
const message = (this.commitMessage ?? '').trim();
|
|
366
|
+
if (!message) return true; // absorb — require a non-empty message before confirming
|
|
367
|
+
this.confirm = { subject: { kind: 'commit', message }, label: message, verb: 'Commit' };
|
|
368
|
+
this.commitMessage = null;
|
|
369
|
+
this.markDirty();
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
if (isPanelSearchBackspace(key)) {
|
|
373
|
+
this.commitMessage = (this.commitMessage ?? '').slice(0, -1);
|
|
374
|
+
this.markDirty();
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
if (isPanelSearchPrintable(key)) {
|
|
378
|
+
this.commitMessage = (this.commitMessage ?? '') + key;
|
|
379
|
+
this.markDirty();
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
return true; // absorb every other key while composing
|
|
383
|
+
}
|
|
384
|
+
|
|
298
385
|
private handleDiffInput(key: string): boolean {
|
|
299
386
|
switch (key) {
|
|
300
387
|
case 'up':
|
|
@@ -324,38 +411,106 @@ export class GitPanel extends BasePanel {
|
|
|
324
411
|
}
|
|
325
412
|
}
|
|
326
413
|
|
|
414
|
+
/** Commit the given lines as the new expanded diff and reset scroll/dirty state. */
|
|
415
|
+
private finishDiff(lines: string[]): void {
|
|
416
|
+
this.expandedDiff = lines;
|
|
417
|
+
this.scrollOffset = 0;
|
|
418
|
+
this.markDirty();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// I3: withLoading guarantees the spinner is cleared even if the fetch throws.
|
|
327
422
|
private async openDiff(): Promise<void> {
|
|
328
|
-
const item = this.items
|
|
329
|
-
if (!item
|
|
423
|
+
const item = this.items.at(this.selectedIndex);
|
|
424
|
+
if (!item) return;
|
|
425
|
+
|
|
426
|
+
if (item.kind === 'file') {
|
|
427
|
+
try {
|
|
428
|
+
const raw = await this.withLoading('Loading diff…', async () => this.git.diffFile(item.entry.path, item.entry.staged));
|
|
429
|
+
this.finishDiff(raw ? raw.split('\n') : ['(no diff available)']);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
this.finishDiff([`Error: ${summarizeError(err)}`]);
|
|
432
|
+
}
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (item.kind === 'commit') {
|
|
437
|
+
// Root commits have no parent (`<hash>^` is invalid) — the catch below
|
|
438
|
+
// surfaces that instead of pretending a diff exists.
|
|
439
|
+
const before = `${item.entry.hash}^`;
|
|
440
|
+
const after = item.entry.hash;
|
|
441
|
+
try {
|
|
442
|
+
const [patch, stat] = await this.withLoading('Loading commit…', async () => Promise.all([
|
|
443
|
+
this.git.diffBetween(before, after),
|
|
444
|
+
this.git.diffStat(before, after),
|
|
445
|
+
]));
|
|
446
|
+
const statLines = stat.split('\n').filter((l) => l.trim().length > 0);
|
|
447
|
+
const patchLines = patch ? patch.split('\n') : [];
|
|
448
|
+
this.finishDiff(statLines.length > 0 ? [...statLines, '', ...patchLines] : (patchLines.length > 0 ? patchLines : ['(no diff available)']));
|
|
449
|
+
} catch (err) {
|
|
450
|
+
this.finishDiff([`Error: ${summarizeError(err)}`]);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// -- Mutating actions: stage / unstage / commit / init ----------------------
|
|
330
456
|
|
|
331
|
-
|
|
457
|
+
/** Stage (target=true) or unstage (target=false) the selected file row. */
|
|
458
|
+
private async setStaged(target: boolean): Promise<void> {
|
|
459
|
+
const item = this.items.at(this.selectedIndex);
|
|
460
|
+
if (!item || item.kind !== 'file' || item.entry.staged === target) return;
|
|
332
461
|
try {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
});
|
|
337
|
-
this.expandedDiff = raw ? raw.split('\n') : ['(no diff available)'];
|
|
338
|
-
this.scrollOffset = 0;
|
|
339
|
-
this.markDirty();
|
|
462
|
+
if (target) await this.git.add(item.entry.path);
|
|
463
|
+
else await this.git.reset(item.entry.path);
|
|
464
|
+
await this.refresh();
|
|
340
465
|
} catch (err) {
|
|
341
|
-
this.
|
|
342
|
-
this.
|
|
343
|
-
|
|
466
|
+
this.error = `${target ? 'Stage' : 'Unstage'} failed: ${summarizeError(err)}`;
|
|
467
|
+
this._markDirtyAndRender();
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private async performCommit(message: string): Promise<void> {
|
|
472
|
+
try {
|
|
473
|
+
await this.withLoading('Committing…', async () => this.git.commit(message));
|
|
474
|
+
await this.refresh();
|
|
475
|
+
} catch (err) {
|
|
476
|
+
this.error = `Commit failed: ${summarizeError(err)}`;
|
|
477
|
+
this._markDirtyAndRender();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private async performInit(): Promise<void> {
|
|
482
|
+
const result = GitService.initRepo(this.workingDirectory);
|
|
483
|
+
if (result.success) {
|
|
484
|
+
logger.debug('GitPanel: repo initialised via explicit confirm', { cwd: this.workingDirectory });
|
|
485
|
+
this.notGitRepo = false;
|
|
486
|
+
this.error = null;
|
|
487
|
+
await this.refresh();
|
|
488
|
+
} else {
|
|
489
|
+
this.error = `Git init failed: ${result.error ?? 'unknown error'}`;
|
|
490
|
+
this._markDirtyAndRender();
|
|
344
491
|
}
|
|
345
492
|
}
|
|
346
493
|
|
|
347
|
-
// ---------------------------------------------------------------------------
|
|
348
494
|
// Rendering
|
|
349
|
-
// ---------------------------------------------------------------------------
|
|
350
495
|
|
|
351
496
|
override render(width: number, height: number): Line[] {
|
|
497
|
+
if (this.confirm) {
|
|
498
|
+
return buildPanelWorkspace(width, height, {
|
|
499
|
+
title: ' Git',
|
|
500
|
+
sections: [{ title: 'Confirmation', lines: renderConfirmLines(width, this.confirm) }],
|
|
501
|
+
palette: DEFAULT_PANEL_PALETTE,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
if (this.commitMessage !== null) {
|
|
505
|
+
return this.renderCommitCompose(width, height);
|
|
506
|
+
}
|
|
352
507
|
if (this.loading) {
|
|
353
508
|
return this.renderMessage(width, height, 'Loading git status...', C.branch);
|
|
354
509
|
}
|
|
355
510
|
if (this.error) {
|
|
356
|
-
return this.renderMessage(width, height,
|
|
511
|
+
return this.renderMessage(width, height, this.error, this.notGitRepo ? C.warn : C.bad);
|
|
357
512
|
}
|
|
358
|
-
// I3: spinner during openDiff() async fetch
|
|
513
|
+
// I3: spinner during openDiff()/performCommit() async fetch
|
|
359
514
|
if (this.loadingState === 'loading') {
|
|
360
515
|
return this.renderMessage(width, height, 'Loading diff...', C.branch);
|
|
361
516
|
}
|
|
@@ -373,6 +528,25 @@ export class GitPanel extends BasePanel {
|
|
|
373
528
|
return lines;
|
|
374
529
|
}
|
|
375
530
|
|
|
531
|
+
private renderCommitCompose(width: number, height: number): Line[] {
|
|
532
|
+
const inputLine = buildSearchInputLine(
|
|
533
|
+
width,
|
|
534
|
+
'Commit message: ',
|
|
535
|
+
`${this.commitMessage ?? ''}_`,
|
|
536
|
+
DEFAULT_PANEL_PALETTE,
|
|
537
|
+
{ active: true, bg: DEFAULT_PANEL_PALETTE.inputBg, valueColor: DEFAULT_PANEL_PALETTE.info },
|
|
538
|
+
);
|
|
539
|
+
const footerLines = [
|
|
540
|
+
buildKeyboardHints(width, [{ keys: 'Enter', label: 'review & confirm' }, { keys: 'Esc', label: 'cancel' }], DEFAULT_PANEL_PALETTE),
|
|
541
|
+
];
|
|
542
|
+
return buildPanelWorkspace(width, height, {
|
|
543
|
+
title: ' Git — Commit',
|
|
544
|
+
sections: [{ title: 'Message', lines: [inputLine] }],
|
|
545
|
+
footerLines,
|
|
546
|
+
palette: DEFAULT_PANEL_PALETTE,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
376
550
|
/** Paint a single text string into a new Line at x=startX. */
|
|
377
551
|
private paintText(
|
|
378
552
|
line: Line,
|
|
@@ -419,9 +593,11 @@ export class GitPanel extends BasePanel {
|
|
|
419
593
|
const fg = entry.staged ? C.good : C.bad;
|
|
420
594
|
// Single-char status marker: + staged (ready to commit), M modified/unstaged.
|
|
421
595
|
const statusGlyph = entry.staged ? '+' : 'M';
|
|
422
|
-
const
|
|
596
|
+
const sessionMarker = entry.sessionChanged ? '● ' : ' ';
|
|
597
|
+
const path = truncateDisplay(entry.path, Math.max(0, width - 8));
|
|
423
598
|
return buildSelectablePanelLine(width, [
|
|
424
599
|
{ text: ` ${statusGlyph} `, fg, bg: selected ? C.selected : undefined, bold: true },
|
|
600
|
+
{ text: sessionMarker, fg: selected ? C.selectedFg : C.sessionChanged, bg: selected ? C.selected : undefined },
|
|
425
601
|
{ text: path, fg: selected ? C.selectedFg : fg, bg: selected ? C.selected : undefined, bold: selected },
|
|
426
602
|
], { selected, selectedBg: C.selected, fillFg: selected ? C.selectedFg : '', leadingMarker: '▸' });
|
|
427
603
|
}
|
|
@@ -437,6 +613,17 @@ export class GitPanel extends BasePanel {
|
|
|
437
613
|
}
|
|
438
614
|
|
|
439
615
|
private renderList(width: number, height: number): Line[] {
|
|
616
|
+
const intro = 'Review branch status, staged and unstaged files, and recent commits. Open a file or commit row to inspect its diff.';
|
|
617
|
+
const footerHints = [
|
|
618
|
+
buildKeyboardHints(width, [
|
|
619
|
+
{ keys: '↑/↓', label: 'navigate' },
|
|
620
|
+
{ keys: 'Enter', label: 'open diff →' },
|
|
621
|
+
{ keys: 's/u', label: 'stage/unstage' },
|
|
622
|
+
{ keys: 'c', label: 'commit' },
|
|
623
|
+
{ keys: 'r', label: 'refresh' },
|
|
624
|
+
], DEFAULT_PANEL_PALETTE),
|
|
625
|
+
];
|
|
626
|
+
const emptyStateLines = buildEmptyState(width, ' No git rows', 'This repository has no staged or unstaged changes and no commits to display yet.', [{ command: 'r', summary: 'refresh working-tree status in this panel, or stage changes to populate this view' }], DEFAULT_PANEL_PALETTE); // WO-160: was '/git status' (chat-only, never touches this panel)
|
|
440
627
|
const rows: Line[] = [];
|
|
441
628
|
for (let i = 0; i < this.items.length; i++) {
|
|
442
629
|
const item = this.items[i];
|
|
@@ -467,7 +654,7 @@ export class GitPanel extends BasePanel {
|
|
|
467
654
|
const selectedRowIndex = this.getRowIndexForItem(this.selectedIndex);
|
|
468
655
|
if (selectedRowIndex >= 0) {
|
|
469
656
|
const isDirty = this.data.stagedFiles.length > 0 || this.data.unstagedFiles.length > 0;
|
|
470
|
-
const selectedItem = this.items
|
|
657
|
+
const selectedItem = this.items.at(this.selectedIndex);
|
|
471
658
|
const selectedLines: Line[] = [];
|
|
472
659
|
if (selectedItem?.kind === 'file') {
|
|
473
660
|
selectedLines.push(buildPanelLine(width, [
|
|
@@ -506,10 +693,8 @@ export class GitPanel extends BasePanel {
|
|
|
506
693
|
} as const;
|
|
507
694
|
const selectedSection = { title: 'Selected', lines: selectedLines } as const;
|
|
508
695
|
const workspaceSection = resolveScrollablePanelSection(width, height, {
|
|
509
|
-
intro
|
|
510
|
-
footerLines:
|
|
511
|
-
buildKeyboardHints(width, [{ keys: '↑/↓', label: 'navigate' }, { keys: 'Enter', label: 'open diff →' }, { keys: 'r', label: 'refresh' }], DEFAULT_PANEL_PALETTE),
|
|
512
|
-
],
|
|
696
|
+
intro,
|
|
697
|
+
footerLines: footerHints,
|
|
513
698
|
palette: DEFAULT_PANEL_PALETTE,
|
|
514
699
|
beforeSections: [summarySection],
|
|
515
700
|
section: {
|
|
@@ -525,26 +710,20 @@ export class GitPanel extends BasePanel {
|
|
|
525
710
|
|
|
526
711
|
return buildPanelWorkspace(width, height, {
|
|
527
712
|
title: ' Git',
|
|
528
|
-
intro
|
|
713
|
+
intro,
|
|
529
714
|
sections: [
|
|
530
715
|
summarySection,
|
|
531
|
-
workspaceSection.section.lines.length > 0 ? workspaceSection.section : { title: 'Workspace', lines:
|
|
716
|
+
workspaceSection.section.lines.length > 0 ? workspaceSection.section : { title: 'Workspace', lines: emptyStateLines },
|
|
532
717
|
selectedSection,
|
|
533
718
|
],
|
|
534
|
-
footerLines:
|
|
535
|
-
buildKeyboardHints(width, [{ keys: '↑/↓', label: 'navigate' }, { keys: 'Enter', label: 'open diff →' }, { keys: 'r', label: 'refresh' }], DEFAULT_PANEL_PALETTE),
|
|
536
|
-
],
|
|
719
|
+
footerLines: footerHints,
|
|
537
720
|
palette: DEFAULT_PANEL_PALETTE,
|
|
538
721
|
});
|
|
539
722
|
}
|
|
540
723
|
return buildPanelWorkspace(width, height, {
|
|
541
724
|
title: ' Git',
|
|
542
|
-
intro
|
|
543
|
-
sections: [
|
|
544
|
-
{
|
|
545
|
-
lines: buildEmptyState(width, ' No git rows', 'This repository has no staged or unstaged changes and no commits to display yet.', [{ command: '/git status', summary: 'refresh working-tree status, or stage changes to populate this view' }], DEFAULT_PANEL_PALETTE),
|
|
546
|
-
},
|
|
547
|
-
],
|
|
725
|
+
intro,
|
|
726
|
+
sections: [{ lines: emptyStateLines }],
|
|
548
727
|
palette: DEFAULT_PANEL_PALETTE,
|
|
549
728
|
});
|
|
550
729
|
}
|
|
@@ -567,7 +746,7 @@ export class GitPanel extends BasePanel {
|
|
|
567
746
|
}
|
|
568
747
|
|
|
569
748
|
private renderDiff(width: number, height: number): Line[] {
|
|
570
|
-
const item = this.items
|
|
749
|
+
const item = this.items.at(this.selectedIndex);
|
|
571
750
|
const diffLines = this.expandedDiff ?? [];
|
|
572
751
|
let added = 0;
|
|
573
752
|
let removed = 0;
|
|
@@ -578,7 +757,9 @@ export class GitPanel extends BasePanel {
|
|
|
578
757
|
const title =
|
|
579
758
|
item?.kind === 'file'
|
|
580
759
|
? `Diff: ${item.entry.path} +${added} -${removed}`
|
|
581
|
-
: '
|
|
760
|
+
: item?.kind === 'commit'
|
|
761
|
+
? `Commit ${item.entry.hash}: ${item.entry.message} +${added} -${removed}`
|
|
762
|
+
: 'Diff';
|
|
582
763
|
const renderedLines = diffLines.map((rawLine) => {
|
|
583
764
|
const dLine = createEmptyLine(width);
|
|
584
765
|
let fg: string;
|