@pellux/goodvibes-tui 1.7.0 → 1.9.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 (137) hide show
  1. package/README.md +10 -9
  2. package/docs/foundation-artifacts/operator-contract.json +4045 -1763
  3. package/package.json +2 -2
  4. package/src/audio/spoken-turn-controller.ts +12 -2
  5. package/src/audio/spoken-turn-wiring.ts +2 -1
  6. package/src/cli/help.ts +8 -1
  7. package/src/cli/service-posture.ts +2 -1
  8. package/src/cli/status.ts +2 -1
  9. package/src/cli/surface-command.ts +2 -2
  10. package/src/core/composer-state.ts +11 -3
  11. package/src/core/conversation-line-cache.ts +27 -3
  12. package/src/core/conversation-rendering.ts +71 -14
  13. package/src/core/stream-event-wiring.ts +20 -1
  14. package/src/core/system-message-noise.ts +87 -0
  15. package/src/core/system-message-router.ts +68 -1
  16. package/src/core/turn-cancellation.ts +7 -2
  17. package/src/core/turn-event-wiring.ts +10 -2
  18. package/src/daemon/cli.ts +29 -2
  19. package/src/daemon/handlers/register.ts +8 -1
  20. package/src/daemon/service-commands.ts +329 -0
  21. package/src/input/autocomplete.ts +27 -1
  22. package/src/input/command-registry.ts +46 -4
  23. package/src/input/commands/codebase-runtime.ts +46 -6
  24. package/src/input/commands/config.ts +43 -3
  25. package/src/input/commands/health-runtime.ts +9 -1
  26. package/src/input/commands/memory.ts +68 -35
  27. package/src/input/commands/operator-panel-runtime.ts +31 -7
  28. package/src/input/commands/planning-runtime.ts +95 -6
  29. package/src/input/commands/qrcode-runtime.ts +25 -5
  30. package/src/input/commands/remote-runtime-setup.ts +5 -3
  31. package/src/input/commands/session-content.ts +20 -9
  32. package/src/input/commands/settings-sync-runtime.ts +15 -3
  33. package/src/input/commands/shell-core.ts +10 -1
  34. package/src/input/commands/workstream-runtime.ts +168 -18
  35. package/src/input/config-modal-types.ts +15 -1
  36. package/src/input/config-modal.ts +227 -12
  37. package/src/input/feed-context-factory.ts +3 -0
  38. package/src/input/handler-command-route.ts +10 -3
  39. package/src/input/handler-content-actions.ts +17 -2
  40. package/src/input/handler-feed-routes.ts +43 -121
  41. package/src/input/handler-feed.ts +32 -4
  42. package/src/input/handler-modal-routes.ts +78 -13
  43. package/src/input/handler-modal-stack.ts +11 -2
  44. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  45. package/src/input/handler-onboarding.ts +71 -59
  46. package/src/input/handler-picker-routes.ts +15 -8
  47. package/src/input/handler-shortcuts.ts +59 -9
  48. package/src/input/handler.ts +6 -1
  49. package/src/input/keybindings.ts +6 -5
  50. package/src/input/model-picker.ts +19 -2
  51. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  52. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  53. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  54. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  55. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  56. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  57. package/src/input/panel-mouse-geometry.ts +97 -0
  58. package/src/input/panel-paste-flood-guard.ts +86 -0
  59. package/src/input/selection-modal.ts +11 -0
  60. package/src/input/session-picker-modal.ts +44 -4
  61. package/src/input/settings-modal-data.ts +51 -2
  62. package/src/input/settings-modal-types.ts +4 -2
  63. package/src/main.ts +28 -27
  64. package/src/panels/builtin/shared.ts +9 -3
  65. package/src/panels/fleet-deep-link.ts +31 -0
  66. package/src/panels/fleet-panel-format.ts +62 -0
  67. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  68. package/src/panels/fleet-panel.ts +89 -131
  69. package/src/panels/fleet-read-model.ts +43 -0
  70. package/src/panels/fleet-steer.ts +35 -2
  71. package/src/panels/fleet-stop.ts +29 -1
  72. package/src/panels/modals/keybindings-modal.ts +16 -1
  73. package/src/panels/modals/modal-theme.ts +35 -29
  74. package/src/panels/modals/pairing-modal.ts +25 -6
  75. package/src/panels/modals/planning-modal.ts +43 -21
  76. package/src/panels/modals/work-plan-modal.ts +28 -0
  77. package/src/panels/panel-manager.ts +15 -4
  78. package/src/panels/polish-core.ts +38 -25
  79. package/src/panels/project-planning-answer-actions.ts +8 -13
  80. package/src/panels/types.ts +24 -0
  81. package/src/permissions/prompt.ts +160 -13
  82. package/src/renderer/autocomplete-overlay.ts +28 -2
  83. package/src/renderer/compositor.ts +2 -3
  84. package/src/renderer/config-modal.ts +19 -5
  85. package/src/renderer/footer-tips.ts +5 -1
  86. package/src/renderer/fullscreen-primitives.ts +32 -22
  87. package/src/renderer/git-status.ts +3 -1
  88. package/src/renderer/layout.ts +0 -4
  89. package/src/renderer/markdown.ts +7 -3
  90. package/src/renderer/modal-factory.ts +25 -20
  91. package/src/renderer/model-workspace.ts +18 -3
  92. package/src/renderer/overlay-box.ts +21 -17
  93. package/src/renderer/process-indicator.ts +14 -3
  94. package/src/renderer/selection-modal-overlay.ts +6 -1
  95. package/src/renderer/session-picker-modal.ts +196 -3
  96. package/src/renderer/settings-modal-helpers.ts +2 -0
  97. package/src/renderer/settings-modal.ts +7 -0
  98. package/src/renderer/shell-surface.ts +8 -1
  99. package/src/renderer/status-glyphs.ts +14 -15
  100. package/src/renderer/system-message.ts +15 -3
  101. package/src/renderer/terminal-bg-probe.ts +339 -0
  102. package/src/renderer/terminal-escapes.ts +20 -0
  103. package/src/renderer/theme-mode-config.ts +67 -0
  104. package/src/renderer/theme.ts +91 -1
  105. package/src/renderer/thinking.ts +11 -3
  106. package/src/renderer/tool-call.ts +15 -9
  107. package/src/renderer/tool-result-summary.ts +148 -0
  108. package/src/renderer/turn-injection.ts +22 -3
  109. package/src/renderer/ui-factory.ts +154 -85
  110. package/src/renderer/ui-primitives.ts +30 -129
  111. package/src/runtime/bootstrap-command-context.ts +6 -0
  112. package/src/runtime/bootstrap-command-parts.ts +8 -4
  113. package/src/runtime/bootstrap-core.ts +33 -11
  114. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  115. package/src/runtime/bootstrap-shell.ts +19 -1
  116. package/src/runtime/bootstrap.ts +118 -5
  117. package/src/runtime/code-index-services.ts +25 -2
  118. package/src/runtime/legacy-daemon-migration.ts +516 -0
  119. package/src/runtime/memory-fold.ts +26 -0
  120. package/src/runtime/onboarding/derivation.ts +7 -2
  121. package/src/runtime/onboarding/snapshot.ts +27 -1
  122. package/src/runtime/onboarding/types.ts +31 -1
  123. package/src/runtime/operator-token-cleanup.ts +82 -1
  124. package/src/runtime/orchestrator-core-services.ts +10 -0
  125. package/src/runtime/resume-notice.ts +209 -0
  126. package/src/runtime/services.ts +12 -8
  127. package/src/runtime/session-inbound-inputs.ts +252 -0
  128. package/src/runtime/session-spine-transport.ts +64 -0
  129. package/src/runtime/terminal-output-guard.ts +15 -8
  130. package/src/runtime/ui-services.ts +19 -3
  131. package/src/runtime/workstream-services.ts +160 -28
  132. package/src/runtime/wrfc-persistence.ts +124 -17
  133. package/src/shell/blocking-input.ts +46 -3
  134. package/src/shell/recovery-input-helpers.ts +170 -1
  135. package/src/shell/ui-openers.ts +42 -9
  136. package/src/utils/terminal-width.ts +52 -0
  137. package/src/version.ts +1 -1
@@ -20,16 +20,19 @@ import { buildAnswerActions, isGenericRecommendation, type PlanningAnswerAction
20
20
  // ---------------------------------------------------------------------------
21
21
  // Project Planning → 'planning' config-modal surface (W6.1 group-B port). Shows
22
22
  // readiness/questions/decisions/task-graph/handoff — read-only except choosing
23
- // and submitting an answer to the current open question, approving execution,
24
- // or refreshing. Submit/approve route to the `/plan` command.
23
+ // an answer to the current open question, approving execution, dismissing the
24
+ // plan, or refreshing.
25
25
  //
26
- // KNOWN GAP (flagged in the report): `/plan` has no subcommand that answers the
27
- // CURRENT open question. The live app calls a submitPlanningAnswer callback that
28
- // feeds the answer through the normal chat prompt — the action context has no
29
- // such seam. Lacking a slash-command equivalent, submit routes the chosen answer
30
- // text through `/plan <answer text>` (the free-form branch, which RESEEDS the
31
- // goal rather than resolving the specific question) a known-imprecise
32
- // stand-in, surfaced honestly in an in-modal note.
26
+ // DEBT-3 the seams are now real:
27
+ // - A CANNED answer to a real open question dispatches `/plan answer <id> <text>`
28
+ // (records it; the open-question gap clears on the next refine).
29
+ // - The CUSTOM free-form typed answer (and any answer to a synthetic readiness
30
+ // question with no open-question record) is submitted to chat via the generic
31
+ // `submitInput` seam a real model turn. ORDERING GUARD: the modal closes
32
+ // BEFORE the turn starts (a turn under a live modal is the modal-liveness
33
+ // hazard). No more `/plan <text>` reseed approximation.
34
+ // - Dismiss is a first-class CONFIRMED action (`d`) dispatching `/plan dismiss`,
35
+ // plus the plain Esc close (planning unchanged).
33
36
  // ---------------------------------------------------------------------------
34
37
 
35
38
  export type PlanningModalService = Pick<ProjectPlanningService, 'status' | 'getState' | 'listDecisions' | 'getLanguage' | 'evaluate'>;
@@ -134,6 +137,7 @@ class PlanningModalSurface implements ConfigModalSurface {
134
137
  readonly actions = [
135
138
  { key: 'enter', id: 'submit', label: 'submit', enabledFor: () => this.currentAnswerActions().actions.length > 0 },
136
139
  { key: 'a', id: 'approve', label: 'approve execution' },
140
+ { key: 'd', id: 'dismiss', label: 'dismiss planning', confirm: true },
137
141
  { key: 'r', id: 'refresh', label: 'refresh' },
138
142
  ];
139
143
 
@@ -199,7 +203,7 @@ class PlanningModalSurface implements ConfigModalSurface {
199
203
  for (const action of actions) {
200
204
  rows.push({ id: action.id, label: `${action.label} - ${action.detail}`, ...(action.disabled ? { selectable: false } : {}) });
201
205
  }
202
- line({ content: 'Note: submit reseeds the plan goal via /plan <text> (no per-question answer command exists yet).', fg: undefined });
206
+ line({ content: 'Enter records a canned answer against this question; the custom row submits your typed text to chat.', fg: undefined });
203
207
  }
204
208
 
205
209
  for (const l of buildGapsLines(evaluation)) line(l);
@@ -209,31 +213,49 @@ class PlanningModalSurface implements ConfigModalSurface {
209
213
 
210
214
  return {
211
215
  title: 'Planning',
212
- tabs: [{ id: 'planning', label: 'Planning', header, rows, hints: [...(question ? ['enter submit'] : []), 'a approve execution'] }],
216
+ tabs: [{ id: 'planning', label: 'Planning', header, rows }],
213
217
  };
214
218
  }
215
219
 
216
220
  onAction(id: string, ctx: ConfigModalActionContext): void {
217
221
  if (id === 'refresh') { this.refresh(); ctx.setStatus('Reloading project planning state…'); return; }
218
222
  if (id === 'approve') { void ctx.executeCommand?.('plan', ['approve']); ctx.setStatus('Dispatched /plan approve.'); return; }
223
+ if (id === 'dismiss') {
224
+ // First-class, confirmed (host two-press) mutating dismiss.
225
+ void ctx.executeCommand?.('plan', ['dismiss']);
226
+ ctx.setStatus('Dispatched /plan dismiss.');
227
+ ctx.close();
228
+ return;
229
+ }
219
230
  if (id !== 'submit') return;
220
231
  const { question, actions } = this.currentAnswerActions();
221
232
  if (!question || actions.length === 0) return;
222
233
  const action = ctx.row ? actions.find((a) => a.id === ctx.row!.id) : undefined;
223
- if (!action || action.disabled || !action.answer.trim()) { ctx.print('Choose a non-empty answer option.'); return; }
234
+ if (!action || action.disabled) { ctx.print('Choose an answer option.'); return; }
224
235
  if (action.kind === 'approve') { void ctx.executeCommand?.('plan', ['approve']); ctx.setStatus('Dispatched /plan approve.'); return; }
225
- if (action.kind === 'dismiss') {
226
- // There is NO `/plan dismiss` subcommand. Dispatching it fell through to
227
- // planning-runtime's free-form goal-seed branch and OVERWROTE the project
228
- // goal with the literal string "dismiss". Do not dispatch and do not
229
- // mutate any state print an honest note and close the panel. Planning
230
- // is untouched (matching the relabeled "Close (planning unchanged)" row).
231
- ctx.print('Pausing project planning isn\'t available as a command yet; planning state left unchanged. Closing the panel.');
236
+
237
+ const answerText = action.answer.trim();
238
+ if (!answerText) { ctx.print('Choose a non-empty answer, or type an answer for the custom row.'); return; }
239
+
240
+ // A canned answer to a REAL open question records structurally via /plan answer.
241
+ const isOpenQuestion = this.snapshot?.state?.openQuestions.some(
242
+ (q) => q.id === question.id && (q.status ?? 'open') === 'open',
243
+ ) ?? false;
244
+ if (action.id !== 'custom' && isOpenQuestion) {
245
+ void ctx.executeCommand?.('plan', ['answer', question.id, ...answerText.split(/\s+/)]);
246
+ ctx.setStatus('Dispatched /plan answer for the current question.');
247
+ return;
248
+ }
249
+
250
+ // Free-form (custom typed) answer, or an answer to a synthetic readiness
251
+ // question with no open-question record → submit to chat as a real turn.
252
+ // ORDERING GUARD: close the modal BEFORE the turn starts (modal-liveness).
253
+ if (ctx.submitInput) {
232
254
  ctx.close();
255
+ ctx.submitInput(answerText);
233
256
  return;
234
257
  }
235
- void ctx.executeCommand?.('plan', action.answer.trim().split(/\s+/));
236
- ctx.setStatus(`Dispatched /plan ${action.answer.trim()} (reseeds the goal — see the in-modal note).`);
258
+ ctx.print('Submitting to chat is unavailable in this runtime; answer left unsent.');
237
259
  }
238
260
  }
239
261
 
@@ -64,6 +64,16 @@ class WorkPlanModalSurface implements ConfigModalSurface {
64
64
 
65
65
  private readonly hasRow = (row: ConfigModalRow | null): boolean => row !== null;
66
66
 
67
+ /** 'i'/'w' jump to the selected item's linked agent/WRFC chain in Fleet
68
+ * (DEBT-5 item 4 — restores the retired WorkPlanPanel's per-item deep-link,
69
+ * lost when W6.1 migrated this surface to a modal without it). Gated on
70
+ * the link actually being present, same as the retired panel gated the key
71
+ * on `item?.linked?.agentId`/`wrfcId` before ever reaching the jump. */
72
+ private readonly hasAgentLink = (row: ConfigModalRow | null): boolean =>
73
+ !!(row && this.itemFrom(row.id)?.linked?.agentId);
74
+ private readonly hasWrfcLink = (row: ConfigModalRow | null): boolean =>
75
+ !!(row && this.itemFrom(row.id)?.linked?.wrfcId);
76
+
67
77
  readonly actions = [
68
78
  { key: 'enter', id: 'cycleStatus', label: 'cycle status', enabledFor: this.hasRow },
69
79
  { key: '1', id: 'setPending', label: 'pending', enabledFor: this.hasRow },
@@ -75,6 +85,8 @@ class WorkPlanModalSurface implements ConfigModalSurface {
75
85
  { key: 'd', id: 'remove', label: 'delete', enabledFor: this.hasRow },
76
86
  { key: 'c', id: 'clearCompleted', label: 'clear done' },
77
87
  { key: 'a', id: 'add', label: 'add' },
88
+ { key: 'i', id: 'jumpAgent', label: 'jump agent', enabledFor: this.hasAgentLink },
89
+ { key: 'w', id: 'jumpWrfc', label: 'jump wrfc', enabledFor: this.hasWrfcLink },
78
90
  { key: 'r', id: 'refresh', label: 'refresh' },
79
91
  ];
80
92
 
@@ -122,6 +134,22 @@ class WorkPlanModalSurface implements ConfigModalSurface {
122
134
  if (!item) return;
123
135
  if (id === 'cycleStatus') { void ctx.executeCommand?.('work-plan', ['cycle', item.id]); ctx.setStatus(`Dispatched /work-plan cycle ${item.id}.`); return; }
124
136
  if (id === 'remove') { void ctx.executeCommand?.('work-plan', ['remove', item.id]); ctx.setStatus(`Dispatched /work-plan remove ${item.id}.`); return; }
137
+ if (id === 'jumpAgent') {
138
+ const agentId = item.linked?.agentId;
139
+ if (!agentId) return;
140
+ // 'agent' matches the SDK's ProcessKind for a live agent node — see
141
+ // FleetPanel.receiveDeepLink's id+kind match.
142
+ void ctx.executeCommand?.('panel', ['open', 'fleet', '--target', `${agentId}:agent`]);
143
+ ctx.setStatus(`Opened Fleet on agent ${agentId}.`);
144
+ return;
145
+ }
146
+ if (id === 'jumpWrfc') {
147
+ const wrfcId = item.linked?.wrfcId;
148
+ if (!wrfcId) return;
149
+ void ctx.executeCommand?.('panel', ['open', 'fleet', '--target', `${wrfcId}:wrfc-chain`]);
150
+ ctx.setStatus(`Opened Fleet on WRFC chain ${wrfcId}.`);
151
+ return;
152
+ }
125
153
  const status: WorkPlanItemStatus | null =
126
154
  id === 'setPending' ? 'pending' : id === 'setInProgress' ? 'in_progress' : id === 'setBlocked' ? 'blocked'
127
155
  : id === 'setDone' ? 'done' : id === 'setFailed' ? 'failed' : id === 'setCancelled' ? 'cancelled' : null;
@@ -2,7 +2,8 @@
2
2
  // PanelManager — central manager for panel lifecycle, navigation, and split
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
- import type { Panel, PanelRegistration, PanelCategory } from './types.ts';
5
+ import type { Panel, PanelRegistration, PanelCategory, PanelDeepLinkTarget } from './types.ts';
6
+ export type { PanelDeepLinkTarget } from './types.ts';
6
7
  // Type-only, erased at runtime, routed through the `@/` alias (not a relative
7
8
  // path) so it stays out of the relative-import graph the architecture
8
9
  // cycle-checker walks — same discipline as the PanelManager import in types.ts.
@@ -228,7 +229,7 @@ export class PanelManager {
228
229
  this._cachedWorkspaceTabs = null;
229
230
  }
230
231
 
231
- open(panelIdOrAlias: string, pane?: 'top' | 'bottom'): Panel {
232
+ open(panelIdOrAlias: string, pane?: 'top' | 'bottom', target?: PanelDeepLinkTarget): Panel {
232
233
  const modalName = this.modalRedirects.get(panelIdOrAlias);
233
234
  if (modalName !== undefined) {
234
235
  this.openModalCallback?.(modalName);
@@ -244,12 +245,12 @@ export class PanelManager {
244
245
  // pane (fixes `/panel open <id> top` and the panel-list T/B move keys).
245
246
  if (pane && pane !== existingPane) {
246
247
  this._moveBetweenPanes(existingPane, pane, panelId);
247
- return this.getPanel(panelId)!;
248
+ return this._deliverDeepLink(this.getPanel(panelId)!, target);
248
249
  }
249
250
  this._activateByIdInPane(panelId, existingPane);
250
251
  this._focusedPane = existingPane;
251
252
  if (existingPane === 'bottom') this._bottomPaneVisible = true;
252
- return this._getPane(existingPane).panels[this._getPane(existingPane).activeIndex]!;
253
+ return this._deliverDeepLink(this._getPane(existingPane).panels[this._getPane(existingPane).activeIndex]!, target);
253
254
  }
254
255
 
255
256
  const targetPane = pane ?? this._focusedPane;
@@ -271,6 +272,16 @@ export class PanelManager {
271
272
  }
272
273
  panel.onActivate();
273
274
  this._invalidateWorkspaceTabs();
275
+ return this._deliverDeepLink(panel, target);
276
+ }
277
+
278
+ /**
279
+ * Hand an optional deep-link target (DEBT-5 item 4) to a panel that just
280
+ * resolved from open(). Panels without a "node" concept simply don't
281
+ * implement `receiveDeepLink` — the call is a no-op via optional chaining.
282
+ */
283
+ private _deliverDeepLink(panel: Panel, target: PanelDeepLinkTarget | undefined): Panel {
284
+ if (target) panel.receiveDeepLink?.(target);
274
285
  return panel;
275
286
  }
276
287
 
@@ -1,12 +1,7 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine, createStyledCell } from '../types/grid.ts';
3
3
  import { getDisplayWidth } from '../utils/terminal-width.ts';
4
- import { resolveUiTones } from '../renderer/theme.ts';
5
-
6
- // DEFAULT_PANEL_PALETTE is built from the mode-resolved chrome tones
7
- // (resolveUiTones) rather than the static UI_TONES constant — WO-001 single
8
- // read path. Mode is fixed to 'dark' until the terminal-bg-probe lands.
9
- const UI_TONES = resolveUiTones('dark');
4
+ import { activeUiTones, registerThemeRefresh } from '../renderer/theme.ts';
10
5
 
11
6
  // ---------------------------------------------------------------------------
12
7
  // Panel palette + core line primitives.
@@ -36,24 +31,36 @@ export interface PanelPalette {
36
31
  readonly selectBg?: string;
37
32
  }
38
33
 
39
- export const DEFAULT_PANEL_PALETTE: Readonly<Required<PanelPalette>> = {
40
- header: UI_TONES.fg.primary,
41
- headerBg: UI_TONES.bg.title,
42
- label: UI_TONES.fg.muted,
43
- value: UI_TONES.fg.primary,
44
- dim: UI_TONES.fg.dim,
45
- info: UI_TONES.state.info,
46
- good: UI_TONES.state.good,
47
- warn: UI_TONES.state.warn,
48
- bad: UI_TONES.state.bad,
49
- empty: UI_TONES.fg.empty,
50
- surfaceBg: UI_TONES.bg.surface,
51
- sectionBg: UI_TONES.bg.section,
52
- summaryBg: UI_TONES.bg.summary,
53
- inputBg: UI_TONES.bg.input,
54
- accent: UI_TONES.fg.secondary,
55
- selectBg: UI_TONES.bg.selected,
56
- } as const;
34
+ // Built from the mode-resolved chrome tones (activeUiTones). Because 100+ call
35
+ // sites read this object by reference, mode changes rebuild it IN PLACE via the
36
+ // registered refresher below rather than re-resolving per call — see theme.ts's
37
+ // active-mode runtime note. This refresher is registered before any panel's
38
+ // extendPalette() runs (panels import polish → polish-core first), so on a mode
39
+ // flip the base is rebuilt before the extended palettes re-merge from it.
40
+ function buildPanelPalette(): Required<PanelPalette> {
41
+ const t = activeUiTones();
42
+ return {
43
+ header: t.fg.primary,
44
+ headerBg: t.bg.title,
45
+ label: t.fg.muted,
46
+ value: t.fg.primary,
47
+ dim: t.fg.dim,
48
+ info: t.state.info,
49
+ good: t.state.good,
50
+ warn: t.state.warn,
51
+ bad: t.state.bad,
52
+ empty: t.fg.empty,
53
+ surfaceBg: t.bg.surface,
54
+ sectionBg: t.bg.section,
55
+ summaryBg: t.bg.summary,
56
+ inputBg: t.bg.input,
57
+ accent: t.fg.secondary,
58
+ selectBg: t.bg.selected,
59
+ };
60
+ }
61
+
62
+ export const DEFAULT_PANEL_PALETTE: Readonly<Required<PanelPalette>> = buildPanelPalette();
63
+ registerThemeRefresh(() => Object.assign(DEFAULT_PANEL_PALETTE as Required<PanelPalette>, buildPanelPalette()));
57
64
 
58
65
  /**
59
66
  * Extend the base panel palette with domain-specific colors.
@@ -73,7 +80,13 @@ export function extendPalette<T extends Record<string, string>>(
73
80
  base: typeof DEFAULT_PANEL_PALETTE,
74
81
  extras: T,
75
82
  ): typeof DEFAULT_PANEL_PALETTE & T {
76
- return { ...base, ...extras };
83
+ const merged = { ...base, ...extras } as typeof DEFAULT_PANEL_PALETTE & T;
84
+ // Self-register an in-place rebuild so every extendPalette-derived panel
85
+ // palette (cost/token/git/skills/diff/wrfc) tracks the active mode with zero
86
+ // per-panel churn. Runs AFTER the base refresher (registered at module eval,
87
+ // before any panel calls this), so `base` already carries the new-mode values.
88
+ registerThemeRefresh(() => Object.assign(merged as Record<string, string>, base, extras));
89
+ return merged;
77
90
  }
78
91
 
79
92
  export function buildPanelLine(
@@ -13,7 +13,7 @@ export interface PlanningAnswerAction {
13
13
  readonly label: string;
14
14
  readonly detail: string;
15
15
  readonly answer: string;
16
- readonly kind?: 'answer' | 'approve' | 'dismiss';
16
+ readonly kind?: 'answer' | 'approve';
17
17
  readonly disabled?: boolean;
18
18
  }
19
19
 
@@ -35,8 +35,12 @@ function compactAnswerDetail(text: string): string {
35
35
  * canned suggestions first (de-duplicated by normalized answer text — a
36
36
  * question that matches more than one keyword category, e.g. scope +
37
37
  * recommendedAnswer echoing one of the scope answers verbatim, must not show
38
- * the same suggested answer twice), then the fixed ask-narrower/custom/
39
- * dismiss actions.
38
+ * the same suggested answer twice), then the fixed ask-narrower/custom actions.
39
+ *
40
+ * DEBT-3: dismissing planning is no longer a pseudo answer-row — it is a
41
+ * first-class confirmed modal action (the `d` key), so no dismiss row is
42
+ * produced here. A canned answer records against the current open question via
43
+ * /plan answer; the custom row's free-form text is submitted to chat.
40
44
  */
41
45
  export function buildAnswerActions(question: ProjectPlanningQuestion, draftAnswer: string): PlanningAnswerAction[] {
42
46
  const canned: PlanningAnswerAction[] = [];
@@ -119,18 +123,9 @@ export function buildAnswerActions(question: ProjectPlanningQuestion, draftAnswe
119
123
  actions.push({
120
124
  id: 'custom',
121
125
  label: 'Submit typed answer',
122
- detail: draftAnswer ? compactAnswerDetail(draftAnswer) : 'Type an answer first; this row becomes the custom answer.',
126
+ detail: draftAnswer ? compactAnswerDetail(draftAnswer) : 'Type an answer first; this row submits it to chat.',
123
127
  answer: draftAnswer.trim(),
124
128
  disabled: !draftAnswer.trim(),
125
129
  });
126
- actions.push({
127
- id: 'dismiss-planning',
128
- // Honest label: there is no /plan subcommand that pauses planning, so this
129
- // row only closes the panel — it does NOT change any planning state.
130
- label: 'Close (planning unchanged)',
131
- detail: 'Close this panel. Pausing project planning is not available as a command yet, so planning state is left unchanged; /plan reopens it later.',
132
- answer: 'Pause project planning for this workspace and continue without the planning panel.',
133
- kind: 'dismiss',
134
- });
135
130
  return actions;
136
131
  }
@@ -16,6 +16,18 @@ export interface PanelIntegrationContext {
16
16
  readonly executeCommand?: (name: string, args: string[]) => Promise<unknown>;
17
17
  }
18
18
 
19
+ /**
20
+ * A cross-panel deep-link target (DEBT-5 item 4): a specific node a panel
21
+ * should select + reveal when opened via `PanelManager.open(id, pane, target)`,
22
+ * e.g. a Fleet tree node keyed by its process id and (optionally) its
23
+ * ProcessKind for disambiguation. Panel-agnostic on purpose — only panels that
24
+ * have a "node" concept implement `Panel.receiveDeepLink`.
25
+ */
26
+ export interface PanelDeepLinkTarget {
27
+ readonly id: string;
28
+ readonly kind?: string;
29
+ }
30
+
19
31
  /**
20
32
  * Named logical key identifiers emitted by the input tokenizer.
21
33
  * These are the ONLY key names that will appear in `handleInput` calls;
@@ -139,6 +151,18 @@ export interface Panel {
139
151
  * to intervene before the global shortcut acts.
140
152
  */
141
153
  interceptPanelClose?(): boolean;
154
+
155
+ /**
156
+ * Optional: consume a deep-link target handed in by `PanelManager.open(id,
157
+ * pane, target)` (DEBT-5 item 4) — select + reveal the matching node on the
158
+ * panel's next snapshot. A panel without a "node" concept simply doesn't
159
+ * implement this (the call is a no-op via optional chaining in
160
+ * PanelManager). A panel that DOES implement it owns its own honest
161
+ * "not found" surface (e.g. FleetPanel.setError) when the target no longer
162
+ * resolves — PanelManager has no transcript/print seam to report on its
163
+ * behalf.
164
+ */
165
+ receiveDeepLink?(target: PanelDeepLinkTarget): void;
142
166
  }
143
167
 
144
168
  export interface PanelRegistration extends Pick<Panel, 'id' | 'name' | 'icon' | 'category'> {
@@ -12,6 +12,72 @@ export type { PermissionPromptRequest, PermissionPromptDecision, PermissionReque
12
12
  /** Visible hunk rows before a "+N more" trailer kicks in (Risk 3). */
13
13
  const MAX_VISIBLE_HUNKS = 8;
14
14
 
15
+ /** Path rows shown before collapsing to a "N files: a, b, +K more" line (UX-B 2a). */
16
+ const MAX_PATH_ROWS = 3;
17
+
18
+ /** Trailing filename for compact display of a long/absolute path. */
19
+ function baseName(p: string): string {
20
+ const cleaned = p.replace(/[/\\]+$/, '');
21
+ const idx = Math.max(cleaned.lastIndexOf('/'), cleaned.lastIndexOf('\\'));
22
+ return idx >= 0 ? cleaned.slice(idx + 1) : cleaned;
23
+ }
24
+
25
+ /** Make an absolute path relative to the working directory when it sits under it. */
26
+ function relativize(p: string, cwd?: string): string {
27
+ if (!cwd || !p.startsWith('/')) return p;
28
+ if (p === cwd) return '.';
29
+ const prefix = cwd.endsWith('/') ? cwd : `${cwd}/`;
30
+ return p.startsWith(prefix) ? p.slice(prefix.length) : p;
31
+ }
32
+
33
+ /** Collect string elements or `el[field]` strings from an array arg. */
34
+ function collectField(arr: unknown, field: string): string[] {
35
+ if (!Array.isArray(arr)) return [];
36
+ const out: string[] = [];
37
+ for (const el of arr) {
38
+ if (typeof el === 'string') out.push(el);
39
+ else if (el && typeof el === 'object' && typeof (el as Record<string, unknown>)[field] === 'string') {
40
+ out.push((el as Record<string, unknown>)[field] as string);
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /**
47
+ * The real display target(s) of a tool invocation — the paths/commands/urls a
48
+ * user needs to see, extracted from the actual arg shapes so a nested
49
+ * `{files:[{path}]}` no longer falls through to a raw JSON blob. (UX-B 2a.)
50
+ * Returns [] when nothing recognisable is present (caller falls back).
51
+ */
52
+ function cardTargets(args: Record<string, unknown>): string[] {
53
+ const files = collectField(args.files, 'path');
54
+ if (files.length) return files;
55
+ if (typeof args.path === 'string') return [args.path];
56
+ if (typeof args.file === 'string') return [args.file];
57
+ const commands = collectField(args.commands, 'cmd');
58
+ if (commands.length) return commands;
59
+ if (typeof args.command === 'string') return [args.command];
60
+ if (typeof args.cmd === 'string') return [args.cmd];
61
+ if (typeof args.pattern === 'string') return [args.pattern];
62
+ const urls = collectField(args.urls, 'url');
63
+ if (urls.length) return urls;
64
+ const queries = collectField(args.queries, 'query');
65
+ if (queries.length) return queries;
66
+ if (typeof args.query === 'string') return [args.query];
67
+ if (typeof args.task === 'string') return [args.task];
68
+ return [];
69
+ }
70
+
71
+ /** Short human verb for a permission category, for the condensed summary line. */
72
+ function categoryVerb(category: PermissionCategory): string {
73
+ switch (category) {
74
+ case 'write': return 'write';
75
+ case 'execute': return 'run';
76
+ case 'delegate': return 'delegate';
77
+ default: return 'read';
78
+ }
79
+ }
80
+
15
81
  /**
16
82
  * Single source of truth for how many Line rows the hunk-list section
17
83
  * occupies: 1 header row + up to MAX_VISIBLE_HUNKS checkbox rows + 1 trailer
@@ -49,12 +115,62 @@ export class PermissionPromptUI {
49
115
  };
50
116
  }
51
117
 
52
- static getPromptHeight(request: PermissionPromptRequest, hunkState?: HunkSelectionState): number {
118
+ /** The relativised display target(s) for the Path field, with a raw fallback. */
119
+ private static resolvedTargets(request: PermissionPromptRequest): string[] {
120
+ const cwd = request.workingDirectory;
121
+ const targets = cardTargets(request.args).map((t) => relativize(t, cwd));
122
+ return targets.length > 0 ? targets : [getDisplayArg(request.tool, request.args)];
123
+ }
124
+
125
+ /** Path-field row texts — one per target when few, a "N files:" summary when many. */
126
+ private static pathRowTexts(targets: string[], maxLen: number): string[] {
127
+ const clamp = (s: string): string => (s.length > maxLen ? `...${s.slice(-(maxLen - 3))}` : s);
128
+ if (targets.length <= 1) return [clamp(targets[0] ?? '(unknown)')];
129
+ if (targets.length <= MAX_PATH_ROWS) return targets.map(clamp);
130
+ const shown = targets.slice(0, 2).map(baseName).join(', ');
131
+ return [clamp(`${targets.length} files: ${shown}, +${targets.length - 2} more`)];
132
+ }
133
+
134
+ /**
135
+ * A low-risk, project-local request is shown condensed (one summary line +
136
+ * choices) unless the user expanded it with `d`. High/critical risk, external
137
+ * scope, and hunk-selectable edits always show the full block. (UX-B 2b.)
138
+ */
139
+ private static isCondensed(
140
+ request: PermissionPromptRequest,
141
+ hunkState: HunkSelectionState | undefined,
142
+ detailsExpanded: boolean,
143
+ ): boolean {
144
+ if (hunkState || detailsExpanded) return false;
145
+ // Shell execution, delegation and network requests always show the full
146
+ // card — their action semantics (command, side effects, host, checklist)
147
+ // warrant scrutiny even when the risk model rates them low. Only mundane
148
+ // low-risk filesystem reads/writes condense. (UX-B 2b.)
149
+ if (request.category === 'execute' || request.category === 'delegate') return false;
150
+ const analysis = this.fallbackAnalysis(request);
151
+ if (analysis.riskLevel !== 'low') return false;
152
+ const scope = analysis.blastRadius;
153
+ return scope === undefined || scope === 'local' || scope === 'project';
154
+ }
155
+
156
+ static getPromptHeight(
157
+ request: PermissionPromptRequest,
158
+ hunkState?: HunkSelectionState,
159
+ detailsExpanded = false,
160
+ ): number {
161
+ // Condensed low-risk card: top separator, title, summary, choices, bottom
162
+ // separator — see createPromptLines' condensed branch (must stay in sync).
163
+ if (this.isCondensed(request, hunkState, detailsExpanded)) return 5;
53
164
  const analysis = this.fallbackAnalysis(request);
54
165
  const reasonLines = Math.min(2, Math.max(1, analysis.reasons.length));
55
166
  const extraLines = (analysis.host ? 1 : 0) + (analysis.surface ? 1 : 0) + (analysis.sideEffects && analysis.sideEffects.length > 0 ? 1 : 0);
56
167
  const hunkLines = hunkState ? hunkListRowCount(hunkState) : 0;
57
- return 12 + reasonLines + extraLines + hunkLines;
168
+ // Base 12 counted a single arg line; the Path field now spans `pathRows`
169
+ // lines and the full card adds one raw-args row (2a reachability), so the
170
+ // arg allotment becomes pathRows + 1 → base 12 + pathRows (12 already
171
+ // included one of those). See createPromptLines' full branch.
172
+ const pathRows = this.pathRowTexts(this.resolvedTargets(request), 999).length;
173
+ return 12 + pathRows + reasonLines + extraLines + hunkLines;
58
174
  }
59
175
 
60
176
  /** Returns the key argument to display for a given tool invocation. */
@@ -84,12 +200,16 @@ export class PermissionPromptUI {
84
200
  * createPromptLines - Renders the permission prompt as an array of Lines.
85
201
  * Injected into the viewport by the render function when a request is pending.
86
202
  */
87
- static createPromptLines(width: number, request: PermissionRequest, hunkState?: HunkSelectionState): Line[] {
203
+ static createPromptLines(
204
+ width: number,
205
+ request: PermissionRequest,
206
+ hunkState?: HunkSelectionState,
207
+ detailsExpanded = false,
208
+ ): Line[] {
88
209
  const lines: Line[] = [];
89
210
  const { tool, args, category } = request;
90
211
  const analysis = this.fallbackAnalysis(request);
91
212
  const brief = buildPermissionApprovalBrief(request);
92
- const displayArg = this.getDisplayArg(tool, args);
93
213
  const { label, color } = this.getCategoryLabel(category);
94
214
 
95
215
  const ACCENT = '135'; // purple
@@ -97,6 +217,24 @@ export class PermissionPromptUI {
97
217
  const TEXT = '252';
98
218
  const DIM = '244';
99
219
 
220
+ const maxArgLen = Math.max(10, width - 16);
221
+ const pathRows = this.pathRowTexts(this.resolvedTargets(request), maxArgLen);
222
+
223
+ // Condensed low-risk / project-local card: one summary line (verb → target
224
+ // (scope)) plus the choices. Full block is one `d` away. (UX-B 2b.)
225
+ if (this.isCondensed(request, hunkState, detailsExpanded)) {
226
+ lines.push(UIFactory.stringToLine('─'.repeat(width), width, { fg: ACCENT, dim: true }));
227
+ lines.push(UIFactory.stringToLine(` [${label}] ${brief.title} `.padEnd(width), width, { fg: WARN, bold: true }));
228
+ const scopeText = analysis.blastRadius ? ` (${analysis.blastRadius})` : '';
229
+ const summaryLine = ` ${categoryVerb(category)} → ${pathRows[0]}${scopeText}`;
230
+ lines.push(UIFactory.stringToLine(summaryLine.padEnd(width), width, { fg: TEXT }));
231
+ lines.push(UIFactory.stringToLine(
232
+ ` [Y] Allow once [A] Allow always (session) [N] Deny [d] details`.padEnd(width),
233
+ width, { fg: ACCENT, bold: true }));
234
+ lines.push(UIFactory.stringToLine('─'.repeat(width), width, { fg: ACCENT, dim: true }));
235
+ return lines;
236
+ }
237
+
100
238
  // Top separator
101
239
  lines.push(UIFactory.stringToLine('─'.repeat(width), width, { fg: ACCENT, dim: true }));
102
240
 
@@ -109,13 +247,13 @@ export class PermissionPromptUI {
109
247
  const toolLine = ` Tool : ${tool}`;
110
248
  lines.push(UIFactory.stringToLine(toolLine.padEnd(width), width, { fg: TEXT }));
111
249
 
112
- // Key argument row - truncate if too long
113
- const maxArgLen = Math.max(10, width - 16);
114
- const truncatedArg = displayArg.length > maxArgLen
115
- ? '...' + displayArg.slice(-(maxArgLen - 3))
116
- : displayArg;
117
- const argLine = ` ${brief.subjectLabel.padEnd(9)}: ${truncatedArg}`;
118
- lines.push(UIFactory.stringToLine(argLine.padEnd(width), width, { fg: TEXT }));
250
+ // Path/subject row(s): actual target path(s), one per line when few, a
251
+ // "N files: a, b, +K more" summary when many. Raw args move to the Args row
252
+ // below so this field never shows a truncated JSON blob. (UX-B 2a.)
253
+ pathRows.forEach((rowText, i) => {
254
+ const labelCol = i === 0 ? brief.subjectLabel.padEnd(9) : ' '.repeat(9);
255
+ lines.push(UIFactory.stringToLine(` ${labelCol}: ${rowText}`.padEnd(width), width, { fg: TEXT }));
256
+ });
119
257
 
120
258
  // Working directory row
121
259
  const cwd = request.workingDirectory ?? '(unknown)';
@@ -170,6 +308,13 @@ export class PermissionPromptUI {
170
308
  const checklistLine = ` Checklist : ${truncatedChecklist}`;
171
309
  lines.push(UIFactory.stringToLine(checklistLine.padEnd(width), width, { fg: DIM }));
172
310
 
311
+ // Raw args row — the full tool arguments live here in the details view so
312
+ // the Path field above can render clean path(s), never a JSON blob. (2a.)
313
+ const rawArgs = JSON.stringify(args);
314
+ const maxRawLen = Math.max(10, width - 16);
315
+ const truncatedRaw = rawArgs.length > maxRawLen ? `${rawArgs.slice(0, maxRawLen - 3)}...` : rawArgs;
316
+ lines.push(UIFactory.stringToLine(` Args : ${truncatedRaw}`.padEnd(width), width, { fg: DIM }));
317
+
173
318
  // Blank spacer
174
319
  lines.push(UIFactory.stringToLine(' '.repeat(width), width));
175
320
 
@@ -207,10 +352,12 @@ export class PermissionPromptUI {
207
352
  lines.push(UIFactory.stringToLine(trailerLine.padEnd(width), width, { fg: DIM }));
208
353
  }
209
354
 
210
- // Choices row
355
+ // Choices row. A condensable card shown in full is the expanded form, so it
356
+ // offers `[d]` to collapse back; other full cards omit the details toggle.
357
+ const collapseHint = this.isCondensed(request, hunkState, false) ? ' [d] hide details' : '';
211
358
  const choicesLine = hunkState
212
359
  ? ` [j/k] Navigate [Space] Toggle [A] All [Enter] Apply selected [N] Deny`
213
- : ` [Y] Allow once [A] Allow always (session) [N] Deny`;
360
+ : ` [Y] Allow once [A] Allow always (session) [N] Deny${collapseHint}`;
214
361
  lines.push(UIFactory.stringToLine(choicesLine.padEnd(width), width, { fg: ACCENT, bold: true }));
215
362
 
216
363
  // Bottom separator