@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.
Files changed (118) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/input/commands/cost-runtime.ts +49 -0
  5. package/src/input/commands/operator-runtime.ts +5 -1
  6. package/src/input/commands.ts +2 -0
  7. package/src/input/handler-feed-routes.ts +12 -35
  8. package/src/input/handler-feed.ts +4 -8
  9. package/src/input/handler-shortcuts.ts +51 -0
  10. package/src/input/handler.ts +43 -6
  11. package/src/input/keybindings.ts +48 -8
  12. package/src/input/panel-integration-actions.ts +107 -1
  13. package/src/main.ts +5 -1
  14. package/src/panels/agent-inspector-panel.ts +125 -36
  15. package/src/panels/agent-inspector-shared.ts +144 -0
  16. package/src/panels/approval-panel.ts +67 -16
  17. package/src/panels/automation-control-panel.ts +368 -124
  18. package/src/panels/builtin/agent.ts +28 -37
  19. package/src/panels/builtin/development.ts +40 -32
  20. package/src/panels/builtin/knowledge.ts +15 -6
  21. package/src/panels/builtin/operations.ts +178 -117
  22. package/src/panels/builtin/session.ts +35 -9
  23. package/src/panels/builtin/shared.ts +98 -6
  24. package/src/panels/cockpit-panel.ts +232 -73
  25. package/src/panels/communication-panel.ts +58 -20
  26. package/src/panels/confirm-state.ts +8 -1
  27. package/src/panels/control-plane-panel.ts +351 -118
  28. package/src/panels/cost-tracker-panel.ts +165 -7
  29. package/src/panels/debug-panel.ts +312 -159
  30. package/src/panels/diff-panel.ts +203 -57
  31. package/src/panels/docs-panel.ts +152 -66
  32. package/src/panels/eval-panel.ts +220 -42
  33. package/src/panels/file-explorer-panel.ts +202 -122
  34. package/src/panels/file-preview-panel.ts +132 -57
  35. package/src/panels/git-panel.ts +309 -128
  36. package/src/panels/hooks-panel.ts +150 -22
  37. package/src/panels/incident-review-panel.ts +223 -35
  38. package/src/panels/index.ts +0 -4
  39. package/src/panels/intelligence-panel.ts +212 -86
  40. package/src/panels/knowledge-graph-panel.ts +461 -101
  41. package/src/panels/local-auth-panel.ts +240 -28
  42. package/src/panels/marketplace-panel.ts +193 -26
  43. package/src/panels/memory-panel.ts +78 -77
  44. package/src/panels/ops-control-panel.ts +146 -29
  45. package/src/panels/ops-strategy-panel.ts +72 -4
  46. package/src/panels/orchestration-panel.ts +231 -69
  47. package/src/panels/panel-list-panel.ts +154 -131
  48. package/src/panels/panel-manager.ts +97 -9
  49. package/src/panels/plan-dashboard-panel.ts +333 -17
  50. package/src/panels/plugins-panel.ts +184 -29
  51. package/src/panels/policy-panel.ts +210 -38
  52. package/src/panels/polish-core.ts +7 -2
  53. package/src/panels/polish.ts +23 -4
  54. package/src/panels/project-planning-answer-actions.ts +134 -0
  55. package/src/panels/project-planning-panel.ts +62 -113
  56. package/src/panels/provider-health-panel.ts +434 -518
  57. package/src/panels/provider-health-routes.ts +203 -0
  58. package/src/panels/provider-health-tracker.ts +194 -6
  59. package/src/panels/provider-health-views.ts +560 -0
  60. package/src/panels/qr-panel.ts +116 -30
  61. package/src/panels/remote-panel.ts +114 -36
  62. package/src/panels/routes-panel.ts +63 -22
  63. package/src/panels/sandbox-panel.ts +174 -42
  64. package/src/panels/scrollable-list-panel.ts +19 -135
  65. package/src/panels/security-panel.ts +133 -33
  66. package/src/panels/services-panel.ts +116 -64
  67. package/src/panels/session-browser-panel.ts +73 -2
  68. package/src/panels/session-maintenance.ts +4 -122
  69. package/src/panels/settings-sync-panel.ts +335 -72
  70. package/src/panels/skills-panel.ts +157 -89
  71. package/src/panels/subscription-panel.ts +86 -33
  72. package/src/panels/symbol-outline-panel.ts +248 -108
  73. package/src/panels/system-messages-panel.ts +114 -13
  74. package/src/panels/tasks-panel.ts +326 -139
  75. package/src/panels/thinking-panel.ts +43 -10
  76. package/src/panels/token-budget-panel.ts +194 -18
  77. package/src/panels/tool-inspector-panel.ts +144 -34
  78. package/src/panels/types.ts +40 -4
  79. package/src/panels/work-plan-panel.ts +280 -17
  80. package/src/panels/worktree-panel.ts +175 -42
  81. package/src/panels/wrfc-panel.ts +116 -24
  82. package/src/renderer/conversation-overlays.ts +25 -11
  83. package/src/renderer/footer-tips.ts +41 -0
  84. package/src/renderer/fullscreen-primitives.ts +22 -16
  85. package/src/renderer/help-overlay.ts +91 -14
  86. package/src/renderer/hint-grammar.ts +52 -0
  87. package/src/renderer/layout.ts +7 -7
  88. package/src/renderer/modal-factory.ts +23 -15
  89. package/src/renderer/model-picker-overlay.ts +21 -7
  90. package/src/renderer/overlay-box.ts +16 -10
  91. package/src/renderer/process-indicator.ts +3 -1
  92. package/src/renderer/progress.ts +5 -4
  93. package/src/renderer/search-overlay.ts +27 -6
  94. package/src/renderer/session-picker-modal.ts +6 -4
  95. package/src/renderer/settings-modal.ts +70 -10
  96. package/src/renderer/shell-surface.ts +41 -15
  97. package/src/renderer/status-glyphs.ts +11 -9
  98. package/src/renderer/theme.ts +60 -3
  99. package/src/renderer/ui-factory.ts +41 -30
  100. package/src/renderer/ui-primitives.ts +23 -2
  101. package/src/runtime/bootstrap-shell.ts +35 -18
  102. package/src/runtime/diagnostics/panels/index.ts +0 -3
  103. package/src/shell/ui-openers.ts +14 -22
  104. package/src/utils/format-duration.ts +2 -2
  105. package/src/utils/splash-lines.ts +44 -3
  106. package/src/version.ts +1 -1
  107. package/src/work-plans/work-plan-store.ts +13 -0
  108. package/src/panels/agent-logs-panel.ts +0 -635
  109. package/src/panels/agent-logs-shared.ts +0 -129
  110. package/src/panels/context-visualizer-panel.ts +0 -238
  111. package/src/panels/forensics-panel.ts +0 -378
  112. package/src/panels/provider-account-snapshot.ts +0 -259
  113. package/src/panels/provider-accounts-panel.ts +0 -255
  114. package/src/panels/provider-stats-panel.ts +0 -391
  115. package/src/panels/schedule-panel.ts +0 -365
  116. package/src/panels/watchers-panel.ts +0 -213
  117. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  118. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -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
- | { kind: 'diff-line'; text: string; diffType: 'add' | 'remove' | 'meta' | 'neutral' };
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
- constructor(workingDirectory: string, private readonly requestRender: () => void = () => {}) {
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(isRetry = false): Promise<void> {
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 stagedFiles: GitFileEntry[] = [
154
- ...statusResult.staged.map((p) => ({ path: p, staged: true })),
155
- ...statusResult.created.map((p) => ({ path: p, staged: true })),
156
- ];
157
-
158
- const unstagedFiles: GitFileEntry[] = [
159
- ...statusResult.modified.map((p) => ({ path: p, staged: false })),
160
- ...statusResult.deleted.map((p) => ({ path: p, staged: false })),
161
- ...statusResult.not_added.map((p) => ({ path: p, staged: false })),
162
- ...statusResult.conflicted.map((p) => ({ path: p, staged: false })),
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
- const cwd = this.workingDirectory;
190
- const initResult = GitService.initRepo(cwd);
191
- if (initResult.success) {
192
- logger.debug('GitPanel: auto-initialised git repo', { cwd });
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
- // Branch / status header row
216
- items.push({ kind: 'header' });
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
- // Unstaged files
229
- items.push({ kind: 'section', label: `Unstaged (${this.data.unstagedFiles.length})` });
230
- if (this.data.unstagedFiles.length === 0) {
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
- // Recent commits
239
- items.push({ kind: 'section', label: `Recent Commits (${this.data.recentCommits.length})` });
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
- this.items = items;
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
- // Keep selection in bounds
251
- if (this.selectedIndex >= this.items.length) {
252
- this.selectedIndex = Math.max(0, this.items.length - 1);
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
- if (this.selectedIndex > 0) {
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
- if (this.selectedIndex < this.items.length - 1) {
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[this.selectedIndex];
329
- if (!item || item.kind !== 'file') return;
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
- // I3: withLoading guarantees spinner is cleared even if diffFile throws
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
- const raw = await this.withLoading('Loading diff…', async () => {
334
- const git = new GitService(this.workingDirectory);
335
- return git.diffFile(item.entry.path, item.entry.staged);
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.expandedDiff = [`Error: ${summarizeError(err)}`];
342
- this.scrollOffset = 0;
343
- this.markDirty();
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, `Git error: ${this.error}`, C.bad);
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 path = truncateDisplay(entry.path, Math.max(0, width - 6));
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[this.selectedIndex];
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: 'Review branch status, staged and unstaged files, and recent commits. Open a file row to inspect its diff.',
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: 'Review branch status, staged and unstaged files, and recent commits. Open a file row to inspect its diff.',
713
+ intro,
529
714
  sections: [
530
715
  summarySection,
531
- workspaceSection.section.lines.length > 0 ? workspaceSection.section : { title: 'Workspace', 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) },
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: 'Review branch status, staged and unstaged files, and recent commits. Open a file row to inspect its diff.',
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[this.selectedIndex];
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
- : 'Diff';
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;