@pellux/goodvibes-tui 0.27.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 (143) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -1,25 +1,37 @@
1
1
  import { BasePanel } from './base-panel.ts';
2
2
  import { createEmptyLine, createStyledCell, type Line } from '../types/grid.ts';
3
+ import { truncateDisplay, getDisplayWidth } from '../utils/terminal-width.ts';
3
4
  import { GitService } from '@pellux/goodvibes-sdk/platform/git';
4
5
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
5
6
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
7
+ import { UI_TONES } from '../renderer/ui-primitives.ts';
6
8
  import {
7
9
  buildEmptyState,
10
+ buildKeyboardHints,
8
11
  buildPanelLine,
9
12
  buildPanelWorkspace,
13
+ buildSearchInputLine,
10
14
  resolveScrollablePanelSection,
15
+ buildSelectablePanelLine,
11
16
  buildStyledPanelLine,
12
17
  DEFAULT_PANEL_PALETTE,
13
18
  extendPalette,
14
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';
15
27
 
16
- // ---------------------------------------------------------------------------
17
28
  // Types
18
- // ---------------------------------------------------------------------------
19
29
 
20
30
  interface GitFileEntry {
21
31
  path: string;
22
32
  staged: boolean;
33
+ /** True when this file was touched during the current session (best-effort). */
34
+ sessionChanged?: boolean;
23
35
  }
24
36
 
25
37
  interface CommitEntry {
@@ -43,19 +55,19 @@ type ViewItem =
43
55
  | { kind: 'section'; label: string }
44
56
  | { kind: 'file'; entry: GitFileEntry }
45
57
  | { kind: 'commit'; entry: CommitEntry }
46
- | { kind: 'empty'; label: string }
47
- | { 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 };
48
64
 
49
- // ---------------------------------------------------------------------------
50
65
  // Constants
51
- // ---------------------------------------------------------------------------
52
66
 
53
67
  /** Minimum number of diff lines kept visible when clamping scroll offset. */
54
68
  const MIN_VISIBLE_DIFF_LINES = 5;
55
69
 
56
- // ---------------------------------------------------------------------------
57
70
  // Colors
58
- // ---------------------------------------------------------------------------
59
71
 
60
72
  const C = extendPalette(DEFAULT_PANEL_PALETTE, {
61
73
  branch: '#00d7ff',
@@ -67,14 +79,18 @@ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
67
79
  selectedFg: '#ffffff',
68
80
  diffMeta: '#5f87ff',
69
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,
70
85
  });
71
86
 
72
- // ---------------------------------------------------------------------------
73
87
  // GitPanel
74
- // ---------------------------------------------------------------------------
75
88
 
76
89
  export class GitPanel extends BasePanel {
77
90
  private readonly workingDirectory: string;
91
+ private readonly git: GitService;
92
+ private readonly getChangedFiles?: () => readonly string[];
93
+
78
94
  private data: GitData = {
79
95
  branch: '...',
80
96
  ahead: 0,
@@ -90,7 +106,7 @@ export class GitPanel extends BasePanel {
90
106
  /** Selected row index within `items`. */
91
107
  private selectedIndex = 0;
92
108
 
93
- /** When truthy, shows the diff for the selected file. */
109
+ /** When truthy, shows the diff for the selected file or commit. */
94
110
  private expandedDiff: string[] | null = null;
95
111
 
96
112
  /** Scroll offset for both main view and diff view. */
@@ -100,14 +116,34 @@ export class GitPanel extends BasePanel {
100
116
  private loading = true;
101
117
  private error: string | null = null;
102
118
 
103
- constructor(workingDirectory: string) {
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
+ ) {
104
133
  super('git', 'Git', 'G', 'development');
105
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;
139
+ }
140
+
141
+ private _markDirtyAndRender(): void {
142
+ this.markDirty();
143
+ this.requestRender();
106
144
  }
107
145
 
108
- // ---------------------------------------------------------------------------
109
146
  // Lifecycle
110
- // ---------------------------------------------------------------------------
111
147
 
112
148
  override onActivate(): void {
113
149
  super.onActivate();
@@ -129,30 +165,35 @@ export class GitPanel extends BasePanel {
129
165
  super.onDestroy();
130
166
  }
131
167
 
132
- // ---------------------------------------------------------------------------
133
168
  // Data fetching
134
- // ---------------------------------------------------------------------------
135
169
 
136
- private async refresh(isRetry = false): Promise<void> {
170
+ private async refresh(): Promise<void> {
137
171
  try {
138
- const git = new GitService(this.workingDirectory);
139
172
  const [statusResult, branchResult, logEntries] = await Promise.all([
140
- git.status(),
141
- git.branch(),
142
- git.log(10),
173
+ this.git.status(),
174
+ this.git.branch(),
175
+ this.git.log(10),
143
176
  ]);
144
177
 
145
- const stagedFiles: GitFileEntry[] = [
146
- ...statusResult.staged.map((p) => ({ path: p, staged: true })),
147
- ...statusResult.created.map((p) => ({ path: p, staged: true })),
148
- ];
149
-
150
- const unstagedFiles: GitFileEntry[] = [
151
- ...statusResult.modified.map((p) => ({ path: p, staged: false })),
152
- ...statusResult.deleted.map((p) => ({ path: p, staged: false })),
153
- ...statusResult.not_added.map((p) => ({ path: p, staged: false })),
154
- ...statusResult.conflicted.map((p) => ({ path: p, staged: false })),
155
- ];
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
+ }
156
197
 
157
198
  this.data = {
158
199
  branch: branchResult.current || 'HEAD',
@@ -170,86 +211,98 @@ export class GitPanel extends BasePanel {
170
211
 
171
212
  this.loading = false;
172
213
  this.error = null;
214
+ this.notGitRepo = false;
173
215
  this.rebuildItems();
174
216
  // Do not clear expandedDiff during auto-refresh — only clear on explicit user action
175
- this.markDirty();
217
+ this._markDirtyAndRender();
176
218
  } catch (err) {
177
219
  const msg = summarizeError(err);
178
- // If the failure is because this directory isn't a git repo, auto-initialise
179
- // and retry once so the panel becomes functional immediately.
180
220
  if (/not a git\b/i.test(msg)) {
181
- const cwd = this.workingDirectory;
182
- const initResult = GitService.initRepo(cwd);
183
- if (initResult.success) {
184
- logger.debug('GitPanel: auto-initialised git repo', { cwd });
185
- if (!isRetry) {
186
- // Retry refresh now that the repo exists (once only)
187
- void this.refresh(true);
188
- return;
189
- }
190
- this.error = 'Not a git repository. Auto-init succeeded but refresh failed.';
191
- } else {
192
- this.error = `Not a git repository. Auto-init failed: ${initResult.error ?? 'unknown error'}`;
193
- }
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).';
194
225
  } else {
226
+ this.notGitRepo = false;
195
227
  this.error = msg;
196
228
  }
197
229
  this.loading = false;
198
230
  logger.debug('GitPanel: refresh failed', { error: this.error });
199
- this.markDirty();
231
+ this._markDirtyAndRender();
200
232
  }
201
233
  }
202
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
+
203
242
  /** Rebuild the flat navigable item list from current data. */
204
243
  private rebuildItems(): void {
205
- const items: ViewItem[] = [];
206
-
207
- // Branch / status header row
208
- items.push({ kind: 'header' });
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;
209
249
 
210
- // Staged files
211
- items.push({ kind: 'section', label: `Staged (${this.data.stagedFiles.length})` });
212
- if (this.data.stagedFiles.length === 0) {
213
- items.push({ kind: 'empty', label: ' (no staged files)' });
214
- } else {
215
- for (const entry of this.data.stagedFiles) {
216
- items.push({ kind: 'file', entry });
217
- }
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);
218
253
  }
219
-
220
- // Unstaged files
221
- items.push({ kind: 'section', label: `Unstaged (${this.data.unstagedFiles.length})` });
222
- if (this.data.unstagedFiles.length === 0) {
223
- items.push({ kind: 'empty', label: ' (no unstaged files)' });
224
- } else {
225
- for (const entry of this.data.unstagedFiles) {
226
- items.push({ kind: 'file', entry });
227
- }
228
- }
229
-
230
- // Recent commits
231
- items.push({ kind: 'section', label: `Recent Commits (${this.data.recentCommits.length})` });
232
- if (this.data.recentCommits.length === 0) {
233
- items.push({ kind: 'empty', label: ' (no commits)' });
234
- } else {
235
- for (const entry of this.data.recentCommits) {
236
- items.push({ kind: 'commit', entry });
237
- }
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;
238
257
  }
258
+ }
239
259
 
240
- 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
+ }
241
264
 
242
- // Keep selection in bounds
243
- if (this.selectedIndex >= this.items.length) {
244
- 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
+ }
245
276
  }
246
277
  }
247
278
 
248
- // ---------------------------------------------------------------------------
249
279
  // Input handling
250
- // ---------------------------------------------------------------------------
251
280
 
252
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
+
253
306
  if (this.expandedDiff !== null) {
254
307
  return this.handleDiffInput(key);
255
308
  }
@@ -260,18 +313,12 @@ export class GitPanel extends BasePanel {
260
313
  switch (key) {
261
314
  case 'up':
262
315
  case 'k': {
263
- if (this.selectedIndex > 0) {
264
- this.selectedIndex--;
265
- this.markDirty();
266
- }
316
+ this.moveSelection(-1);
267
317
  return true;
268
318
  }
269
319
  case 'down':
270
320
  case 'j': {
271
- if (this.selectedIndex < this.items.length - 1) {
272
- this.selectedIndex++;
273
- this.markDirty();
274
- }
321
+ this.moveSelection(1);
275
322
  return true;
276
323
  }
277
324
  case 'return': {
@@ -282,11 +329,59 @@ export class GitPanel extends BasePanel {
282
329
  void this.refresh();
283
330
  return true;
284
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
+ }
285
353
  default:
286
354
  return false;
287
355
  }
288
356
  }
289
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
+
290
385
  private handleDiffInput(key: string): boolean {
291
386
  switch (key) {
292
387
  case 'up':
@@ -316,38 +411,106 @@ export class GitPanel extends BasePanel {
316
411
  }
317
412
  }
318
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.
319
422
  private async openDiff(): Promise<void> {
320
- const item = this.items[this.selectedIndex];
321
- 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
+ }
322
435
 
323
- // I3: withLoading guarantees spinner is cleared even if diffFile throws
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 ----------------------
456
+
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;
324
461
  try {
325
- const raw = await this.withLoading('Loading diff…', async () => {
326
- const git = new GitService(this.workingDirectory);
327
- return git.diffFile(item.entry.path, item.entry.staged);
328
- });
329
- this.expandedDiff = raw ? raw.split('\n') : ['(no diff available)'];
330
- this.scrollOffset = 0;
331
- 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();
332
465
  } catch (err) {
333
- this.expandedDiff = [`Error: ${summarizeError(err)}`];
334
- this.scrollOffset = 0;
335
- 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();
336
491
  }
337
492
  }
338
493
 
339
- // ---------------------------------------------------------------------------
340
494
  // Rendering
341
- // ---------------------------------------------------------------------------
342
495
 
343
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
+ }
344
507
  if (this.loading) {
345
508
  return this.renderMessage(width, height, 'Loading git status...', C.branch);
346
509
  }
347
510
  if (this.error) {
348
- 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);
349
512
  }
350
- // I3: spinner during openDiff() async fetch
513
+ // I3: spinner during openDiff()/performCommit() async fetch
351
514
  if (this.loadingState === 'loading') {
352
515
  return this.renderMessage(width, height, 'Loading diff...', C.branch);
353
516
  }
@@ -365,6 +528,25 @@ export class GitPanel extends BasePanel {
365
528
  return lines;
366
529
  }
367
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
+
368
550
  /** Paint a single text string into a new Line at x=startX. */
369
551
  private paintText(
370
552
  line: Line,
@@ -383,80 +565,65 @@ export class GitPanel extends BasePanel {
383
565
  }
384
566
 
385
567
  private renderBranchLine(width: number): Line {
386
- const line = createEmptyLine(width);
387
- let x = 0;
388
-
389
- const branchIcon = ' git: ';
390
- x = this.paintText(line, branchIcon, x, width, C.sectionHeader);
391
- x = this.paintText(line, this.data.branch, x, width, C.branch, { bold: true });
392
-
393
- if (this.data.ahead > 0) {
394
- x = this.paintText(line, ` +${this.data.ahead}`, x, width, C.good);
395
- }
396
- if (this.data.behind > 0) {
397
- x = this.paintText(line, ` -${this.data.behind}`, x, width, C.bad);
398
- }
399
-
400
568
  const isDirty = this.data.stagedFiles.length > 0 || this.data.unstagedFiles.length > 0;
401
- const statusText = isDirty ? ' * dirty' : ' y clean';
402
- const statusFg = isDirty ? C.warn : C.good;
403
- this.paintText(line, statusText, x, width, statusFg);
404
-
405
- return line;
569
+ const segments: Array<{ text: string; fg: string; bold?: boolean }> = [
570
+ { text: ' ', fg: C.sectionHeader },
571
+ { text: this.data.branch, fg: C.branch, bold: true },
572
+ ];
573
+ if (this.data.ahead > 0) segments.push({ text: ` ↑${this.data.ahead}`, fg: C.good });
574
+ if (this.data.behind > 0) segments.push({ text: ` ↓${this.data.behind}`, fg: C.bad });
575
+ if (this.data.ahead === 0 && this.data.behind === 0) {
576
+ segments.push({ text: ' ≡ up to date', fg: C.dim });
577
+ }
578
+ segments.push({
579
+ text: isDirty ? ' ● dirty' : ' ✓ clean',
580
+ fg: isDirty ? C.warn : C.good,
581
+ bold: true,
582
+ });
583
+ segments.push({ text: ` ${this.data.stagedFiles.length} staged`, fg: this.data.stagedFiles.length > 0 ? C.good : C.dim });
584
+ segments.push({ text: ` · ${this.data.unstagedFiles.length} unstaged`, fg: this.data.unstagedFiles.length > 0 ? C.warn : C.dim });
585
+ return buildStyledPanelLine(width, segments);
406
586
  }
407
587
 
408
588
  private renderSectionHeader(label: string, width: number): Line {
409
- return buildStyledPanelLine(width, [{ text: `-- ${label} `, fg: C.sectionHeader, dim: true }]);
589
+ return buildStyledPanelLine(width, [{ text: ` ${label}`, fg: C.sectionHeader, bold: true }]);
410
590
  }
411
591
 
412
592
  private renderFileRow(entry: GitFileEntry, selected: boolean, width: number): Line {
413
- const line = createEmptyLine(width);
414
593
  const fg = entry.staged ? C.good : C.bad;
415
- const prefix = ' ';
416
- const label = `${prefix}${entry.path}`;
417
-
418
- if (selected) {
419
- // Fill background highlight
420
- for (let i = 0; i < width; i++) {
421
- line[i] = createStyledCell(' ', { bg: C.selected, fg: C.selectedFg });
422
- }
423
- let x = 0;
424
- for (const ch of label) {
425
- if (x >= width) break;
426
- line[x++] = createStyledCell(ch, { fg, bg: C.selected, bold: true });
427
- }
428
- } else {
429
- this.paintText(line, label, 0, width, fg);
430
- }
431
- return line;
594
+ // Single-char status marker: + staged (ready to commit), M modified/unstaged.
595
+ const statusGlyph = entry.staged ? '+' : 'M';
596
+ const sessionMarker = entry.sessionChanged ? '● ' : ' ';
597
+ const path = truncateDisplay(entry.path, Math.max(0, width - 8));
598
+ return buildSelectablePanelLine(width, [
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 },
601
+ { text: path, fg: selected ? C.selectedFg : fg, bg: selected ? C.selected : undefined, bold: selected },
602
+ ], { selected, selectedBg: C.selected, fillFg: selected ? C.selectedFg : '', leadingMarker: '▸' });
432
603
  }
433
604
 
434
605
  private renderCommitRow(entry: CommitEntry, selected: boolean, width: number): Line {
435
- const line = createEmptyLine(width);
436
- const hashPart = ` ${entry.hash} `;
437
- const msgPart = entry.message.length > 60 ? `${entry.message.slice(0, 57)}...` : entry.message;
438
-
439
- if (selected) {
440
- for (let i = 0; i < width; i++) {
441
- line[i] = createStyledCell(' ', { bg: C.selected, fg: C.selectedFg });
442
- }
443
- let x = 0;
444
- for (const ch of hashPart) {
445
- if (x >= width) break;
446
- line[x++] = createStyledCell(ch, { fg: C.commitHash, bg: C.selected });
447
- }
448
- for (const ch of msgPart) {
449
- if (x >= width) break;
450
- line[x++] = createStyledCell(ch, { fg: C.selectedFg, bg: C.selected });
451
- }
452
- } else {
453
- let x = this.paintText(line, hashPart, 0, width, C.commitHash);
454
- this.paintText(line, msgPart, x, width, C.commit);
455
- }
456
- return line;
606
+ const hashPart = ` ${entry.hash} `;
607
+ const msgBudget = Math.max(0, width - getDisplayWidth(hashPart) - 3);
608
+ const msgPart = truncateDisplay(entry.message, msgBudget);
609
+ return buildSelectablePanelLine(width, [
610
+ { text: hashPart, fg: selected ? C.selectedFg : C.commitHash, bg: selected ? C.selected : undefined },
611
+ { text: msgPart, fg: selected ? C.selectedFg : C.commit, bg: selected ? C.selected : undefined, bold: selected },
612
+ ], { selected, selectedBg: C.selected, fillFg: selected ? C.selectedFg : '', leadingMarker: '▸' });
457
613
  }
458
614
 
459
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)
460
627
  const rows: Line[] = [];
461
628
  for (let i = 0; i < this.items.length; i++) {
462
629
  const item = this.items[i];
@@ -487,7 +654,7 @@ export class GitPanel extends BasePanel {
487
654
  const selectedRowIndex = this.getRowIndexForItem(this.selectedIndex);
488
655
  if (selectedRowIndex >= 0) {
489
656
  const isDirty = this.data.stagedFiles.length > 0 || this.data.unstagedFiles.length > 0;
490
- const selectedItem = this.items[this.selectedIndex];
657
+ const selectedItem = this.items.at(this.selectedIndex);
491
658
  const selectedLines: Line[] = [];
492
659
  if (selectedItem?.kind === 'file') {
493
660
  selectedLines.push(buildPanelLine(width, [
@@ -526,10 +693,8 @@ export class GitPanel extends BasePanel {
526
693
  } as const;
527
694
  const selectedSection = { title: 'Selected', lines: selectedLines } as const;
528
695
  const workspaceSection = resolveScrollablePanelSection(width, height, {
529
- intro: 'Review branch status, staged and unstaged files, and recent commits. Open a file row to inspect its diff.',
530
- footerLines: [
531
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter', DEFAULT_PANEL_PALETTE.info], [' diff', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
532
- ],
696
+ intro,
697
+ footerLines: footerHints,
533
698
  palette: DEFAULT_PANEL_PALETTE,
534
699
  beforeSections: [summarySection],
535
700
  section: {
@@ -545,26 +710,20 @@ export class GitPanel extends BasePanel {
545
710
 
546
711
  return buildPanelWorkspace(width, height, {
547
712
  title: ' Git',
548
- intro: 'Review branch status, staged and unstaged files, and recent commits. Open a file row to inspect its diff.',
713
+ intro,
549
714
  sections: [
550
715
  summarySection,
551
- workspaceSection.section.lines.length > 0 ? workspaceSection.section : { title: 'Workspace', lines: buildEmptyState(width, ' No git rows', 'This repository has no files or commits to display yet.', [], DEFAULT_PANEL_PALETTE) },
716
+ workspaceSection.section.lines.length > 0 ? workspaceSection.section : { title: 'Workspace', lines: emptyStateLines },
552
717
  selectedSection,
553
718
  ],
554
- footerLines: [
555
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter', DEFAULT_PANEL_PALETTE.info], [' diff', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
556
- ],
719
+ footerLines: footerHints,
557
720
  palette: DEFAULT_PANEL_PALETTE,
558
721
  });
559
722
  }
560
723
  return buildPanelWorkspace(width, height, {
561
724
  title: ' Git',
562
- intro: 'Review branch status, staged and unstaged files, and recent commits. Open a file row to inspect its diff.',
563
- sections: [
564
- {
565
- lines: buildEmptyState(width, ' No git rows', 'This repository has no files or commits to display yet.', [], DEFAULT_PANEL_PALETTE),
566
- },
567
- ],
725
+ intro,
726
+ sections: [{ lines: emptyStateLines }],
568
727
  palette: DEFAULT_PANEL_PALETTE,
569
728
  });
570
729
  }
@@ -587,10 +746,20 @@ export class GitPanel extends BasePanel {
587
746
  }
588
747
 
589
748
  private renderDiff(width: number, height: number): Line[] {
590
- const item = this.items[this.selectedIndex];
591
- const title =
592
- item?.kind === 'file' ? `Diff: ${item.entry.path}` : 'Diff';
749
+ const item = this.items.at(this.selectedIndex);
593
750
  const diffLines = this.expandedDiff ?? [];
751
+ let added = 0;
752
+ let removed = 0;
753
+ for (const l of diffLines) {
754
+ if (l.startsWith('+') && !l.startsWith('+++')) added++;
755
+ else if (l.startsWith('-') && !l.startsWith('---')) removed++;
756
+ }
757
+ const title =
758
+ item?.kind === 'file'
759
+ ? `Diff: ${item.entry.path} +${added} -${removed}`
760
+ : item?.kind === 'commit'
761
+ ? `Commit ${item.entry.hash}: ${item.entry.message} +${added} -${removed}`
762
+ : 'Diff';
594
763
  const renderedLines = diffLines.map((rawLine) => {
595
764
  const dLine = createEmptyLine(width);
596
765
  let fg: string;
@@ -607,7 +776,7 @@ export class GitPanel extends BasePanel {
607
776
  return dLine;
608
777
  });
609
778
  const footerLines = [
610
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' scroll', DEFAULT_PANEL_PALETTE.dim], [' Esc/q', DEFAULT_PANEL_PALETTE.info], [' close', DEFAULT_PANEL_PALETTE.dim]]),
779
+ buildKeyboardHints(width, [{ keys: '↑/↓', label: 'scroll' }, { keys: 'Esc/q', label: 'back to files' }], DEFAULT_PANEL_PALETTE),
611
780
  ];
612
781
  const diffSection = resolveScrollablePanelSection(width, height, {
613
782
  palette: DEFAULT_PANEL_PALETTE,