@pellux/goodvibes-tui 1.16.1 → 1.18.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 (257) hide show
  1. package/CHANGELOG.md +138 -9
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +5490 -1806
  4. package/package.json +7 -7
  5. package/src/cli/completions/generate.ts +5 -4
  6. package/src/cli/config-overrides.ts +51 -11
  7. package/src/cli/doctor.ts +4 -5
  8. package/src/cli/entrypoint.ts +13 -8
  9. package/src/cli/help.ts +7 -6
  10. package/src/cli/launch-auto-update.ts +214 -0
  11. package/src/cli/management-commands.ts +82 -15
  12. package/src/cli/management.ts +0 -2
  13. package/src/cli/parser.ts +5 -0
  14. package/src/cli/status.ts +13 -0
  15. package/src/cli/surface-command.ts +5 -5
  16. package/src/cli/tui-startup.ts +60 -97
  17. package/src/cli/types.ts +2 -0
  18. package/src/config/index.ts +1 -1
  19. package/src/config/tui-extension-settings.ts +33 -0
  20. package/src/core/composer-state.ts +1 -1
  21. package/src/core/consolidation-receipt.ts +41 -0
  22. package/src/core/conversation-line-cache.ts +3 -3
  23. package/src/core/conversation-rendering.ts +18 -9
  24. package/src/core/conversation.ts +2 -2
  25. package/src/core/long-task-notifier.ts +7 -7
  26. package/src/core/memory-provenance.ts +67 -0
  27. package/src/core/pairing-devices.ts +49 -0
  28. package/src/core/pairing-handoff.ts +101 -0
  29. package/src/core/pairing-offers.ts +83 -0
  30. package/src/core/pairing-origin.ts +78 -0
  31. package/src/core/pairing-tailscale-gateway.ts +56 -0
  32. package/src/core/power-chip-source.ts +91 -0
  33. package/src/core/power-status.ts +95 -0
  34. package/src/core/stable-host.ts +171 -0
  35. package/src/core/stream-event-wiring.ts +29 -6
  36. package/src/core/system-message-noise.ts +1 -1
  37. package/src/core/system-message-router.ts +6 -6
  38. package/src/core/turn-budget-outcome.ts +60 -0
  39. package/src/core/turn-cancellation.ts +40 -1
  40. package/src/core/turn-event-wiring.ts +14 -8
  41. package/src/daemon/cli.ts +64 -58
  42. package/src/daemon/lifecycle.ts +41 -0
  43. package/src/daemon/service-commands.ts +5 -5
  44. package/src/export/cost-utils.ts +128 -6
  45. package/src/input/autocomplete.ts +2 -2
  46. package/src/input/command-registry.ts +55 -9
  47. package/src/input/commands/acp-runtime.ts +72 -0
  48. package/src/input/commands/ci-runtime.ts +5 -1
  49. package/src/input/commands/codebase-runtime.ts +6 -6
  50. package/src/input/commands/cost-runtime.ts +1 -1
  51. package/src/input/commands/devices-runtime.ts +109 -0
  52. package/src/input/commands/git-runtime.ts +28 -4
  53. package/src/input/commands/graph-runtime.ts +45 -0
  54. package/src/input/commands/health-runtime.ts +2 -2
  55. package/src/input/commands/intelligence-runtime.ts +1 -1
  56. package/src/input/commands/local-auth-runtime.ts +1 -1
  57. package/src/input/commands/local-provider-runtime.ts +49 -10
  58. package/src/input/commands/local-runtime.ts +53 -5
  59. package/src/input/commands/memory.ts +2 -2
  60. package/src/input/commands/operator-panel-runtime.ts +5 -5
  61. package/src/input/commands/operator-rpc.ts +32 -11
  62. package/src/input/commands/permissions-provenance.ts +1 -1
  63. package/src/input/commands/permissions-runtime.ts +99 -8
  64. package/src/input/commands/planning-runtime.ts +3 -3
  65. package/src/input/commands/platform-sandbox-runtime.ts +1 -1
  66. package/src/input/commands/power-runtime.ts +56 -0
  67. package/src/input/commands/provider-accounts-runtime.ts +71 -6
  68. package/src/input/commands/provider.ts +7 -5
  69. package/src/input/commands/qrcode-runtime.ts +14 -19
  70. package/src/input/commands/queue-runtime.ts +70 -0
  71. package/src/input/commands/recall-files-git.ts +13 -51
  72. package/src/input/commands/recall-files.ts +8 -11
  73. package/src/input/commands/recall-review.ts +2 -2
  74. package/src/input/commands/relay-runtime.ts +1 -1
  75. package/src/input/commands/remote-runtime.ts +1 -1
  76. package/src/input/commands/runtime-services.ts +4 -4
  77. package/src/input/commands/schedule-runtime.ts +14 -7
  78. package/src/input/commands/services-runtime.ts +1 -1
  79. package/src/input/commands/session-workflow.ts +2 -2
  80. package/src/input/commands/settings-sync-runtime.ts +2 -2
  81. package/src/input/commands/share-runtime.ts +1 -1
  82. package/src/input/commands/shell-core.ts +46 -68
  83. package/src/input/commands/subscription-runtime.ts +1 -1
  84. package/src/input/commands/update-runtime.ts +153 -96
  85. package/src/input/commands/work-plan-runtime.ts +1 -1
  86. package/src/input/commands/workstream-attempts.ts +6 -1
  87. package/src/input/commands/workstream-runtime.ts +46 -27
  88. package/src/input/commands/worktree-runtime.ts +35 -8
  89. package/src/input/commands.ts +10 -2
  90. package/src/input/config-modal-types.ts +14 -3
  91. package/src/input/config-modal.ts +75 -13
  92. package/src/input/feature-unit-layout.ts +93 -82
  93. package/src/input/feed-context-factory.ts +2 -2
  94. package/src/input/handler-command-route.ts +2 -2
  95. package/src/input/handler-content-actions.ts +1 -1
  96. package/src/input/handler-feed-routes.ts +7 -7
  97. package/src/input/handler-feed.ts +4 -4
  98. package/src/input/handler-interactions.ts +3 -2
  99. package/src/input/handler-modal-routes.ts +12 -4
  100. package/src/input/handler-modal-token-routes.ts +1 -1
  101. package/src/input/handler-onboarding-daemon-adopt.ts +2 -2
  102. package/src/input/handler-onboarding.ts +3 -2
  103. package/src/input/handler-picker-routes.ts +26 -13
  104. package/src/input/handler-shortcuts.ts +29 -6
  105. package/src/input/handler.ts +2 -2
  106. package/src/input/keybindings.ts +21 -3
  107. package/src/input/onboarding/onboarding-feature-units.ts +34 -27
  108. package/src/input/onboarding/onboarding-provider-key-fields.ts +112 -0
  109. package/src/input/onboarding/onboarding-wizard-apply.ts +22 -21
  110. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +17 -0
  111. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +2 -2
  112. package/src/input/onboarding/onboarding-wizard-steps.ts +30 -42
  113. package/src/input/panel-integration-actions.ts +2 -2
  114. package/src/input/panel-mouse-geometry.ts +1 -1
  115. package/src/input/panel-paste-flood-guard.ts +1 -1
  116. package/src/input/provider-key-intake.ts +167 -0
  117. package/src/input/sandbox-exec-config.ts +2 -2
  118. package/src/input/selection-modal.ts +1 -1
  119. package/src/input/settings-modal-activation.ts +5 -0
  120. package/src/input/settings-modal-adjustment.ts +4 -31
  121. package/src/input/settings-modal-data.ts +52 -57
  122. package/src/input/settings-modal-mutations.ts +12 -68
  123. package/src/input/settings-modal-types.ts +27 -17
  124. package/src/input/settings-modal.ts +74 -55
  125. package/src/main.ts +90 -90
  126. package/src/panels/agent-inspector-shared.ts +4 -4
  127. package/src/panels/base-panel.ts +2 -2
  128. package/src/panels/builtin/agent.ts +4 -4
  129. package/src/panels/builtin/development.ts +4 -4
  130. package/src/panels/builtin/knowledge.ts +1 -1
  131. package/src/panels/builtin/operations.ts +83 -11
  132. package/src/panels/builtin/session.ts +5 -5
  133. package/src/panels/builtin/shared.ts +16 -4
  134. package/src/panels/builtin-modals.ts +59 -42
  135. package/src/panels/builtin-panels.ts +1 -1
  136. package/src/panels/cost-tracker-panel.ts +124 -7
  137. package/src/panels/diff-panel.ts +39 -6
  138. package/src/panels/eval-registry.ts +2 -2
  139. package/src/panels/fleet-acts.ts +446 -0
  140. package/src/panels/fleet-deep-link.ts +1 -1
  141. package/src/panels/fleet-gateway.ts +117 -0
  142. package/src/panels/fleet-observed-render.ts +128 -0
  143. package/src/panels/fleet-panel-format.ts +163 -11
  144. package/src/panels/fleet-panel-worktree-detail.ts +14 -0
  145. package/src/panels/fleet-panel.ts +73 -57
  146. package/src/panels/fleet-read-model.ts +84 -14
  147. package/src/panels/fleet-spawn.ts +266 -0
  148. package/src/panels/fleet-steer.ts +2 -2
  149. package/src/panels/fleet-stop.ts +41 -5
  150. package/src/panels/fleet-tabs.ts +15 -16
  151. package/src/panels/fleet-transcript.ts +5 -5
  152. package/src/panels/git-panel.ts +2 -2
  153. package/src/panels/index.ts +4 -1
  154. package/src/panels/local-auth-panel.ts +1 -1
  155. package/src/panels/memory-consolidation-gateway.ts +94 -0
  156. package/src/panels/modals/devices-modal.ts +161 -0
  157. package/src/panels/modals/hooks-modal.ts +1 -1
  158. package/src/panels/modals/keybindings-modal.ts +2 -2
  159. package/src/panels/modals/knowledge-modal.ts +1 -1
  160. package/src/panels/modals/marketplace-modal.ts +4 -4
  161. package/src/panels/modals/memory-modal.ts +153 -9
  162. package/src/panels/modals/modal-surface-helpers.ts +1 -1
  163. package/src/panels/modals/modal-theme.ts +1 -1
  164. package/src/panels/modals/pairing-modal.ts +198 -68
  165. package/src/panels/modals/planning-modal.ts +2 -2
  166. package/src/panels/modals/plugins-modal.ts +1 -1
  167. package/src/panels/modals/policy-modal.ts +1 -1
  168. package/src/panels/modals/security-modal.ts +7 -1
  169. package/src/panels/modals/skills-modal.ts +1 -1
  170. package/src/panels/modals/work-plan-modal.ts +3 -3
  171. package/src/panels/notifications-feed.ts +127 -0
  172. package/src/panels/notifications-panel.ts +160 -0
  173. package/src/panels/panel-manager.ts +9 -9
  174. package/src/panels/plugins-panel.ts +4 -4
  175. package/src/panels/project-planning-answer-actions.ts +1 -1
  176. package/src/panels/provider-health-routes.ts +2 -2
  177. package/src/panels/skills-panel.ts +4 -5
  178. package/src/panels/token-budget-panel.ts +11 -3
  179. package/src/panels/types.ts +5 -5
  180. package/src/panels/workstream-graph-render.ts +123 -0
  181. package/src/permissions/broker-approval-card.ts +201 -0
  182. package/src/permissions/hunk-selection.ts +28 -4
  183. package/src/permissions/prompt.ts +244 -29
  184. package/src/renderer/autocomplete-overlay.ts +107 -54
  185. package/src/renderer/code-block.ts +2 -2
  186. package/src/renderer/compaction-history-modal.ts +1 -1
  187. package/src/renderer/compaction-preview.ts +1 -1
  188. package/src/renderer/compaction-quality.ts +1 -1
  189. package/src/renderer/composer-fragments.ts +69 -0
  190. package/src/renderer/compositor.ts +2 -2
  191. package/src/renderer/config-modal.ts +1 -1
  192. package/src/renderer/conversation-overlays.ts +1 -1
  193. package/src/renderer/footer-tips.ts +3 -3
  194. package/src/renderer/hint-grammar.ts +1 -1
  195. package/src/renderer/modal-factory.ts +1 -1
  196. package/src/renderer/model-picker-overlay.ts +1 -1
  197. package/src/renderer/model-workspace.ts +7 -2
  198. package/src/renderer/panel-composite.ts +2 -2
  199. package/src/renderer/panel-workspace-bar.ts +1 -1
  200. package/src/renderer/process-indicator.ts +1 -1
  201. package/src/renderer/selection-modal-overlay.ts +1 -1
  202. package/src/renderer/session-picker-modal.ts +2 -2
  203. package/src/renderer/settings-modal-helpers.ts +23 -5
  204. package/src/renderer/settings-modal.ts +129 -39
  205. package/src/renderer/shell-surface.ts +6 -0
  206. package/src/renderer/terminal-bg-probe.ts +1 -1
  207. package/src/renderer/theme.ts +3 -3
  208. package/src/renderer/tool-call.ts +13 -7
  209. package/src/renderer/tool-result-summary.ts +1 -1
  210. package/src/renderer/turn-injection.ts +7 -6
  211. package/src/renderer/ui-factory.ts +38 -1
  212. package/src/runtime/bootstrap-command-context.ts +7 -2
  213. package/src/runtime/bootstrap-command-parts.ts +9 -5
  214. package/src/runtime/bootstrap-core.ts +41 -41
  215. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  216. package/src/runtime/bootstrap-shell.ts +13 -5
  217. package/src/runtime/bootstrap.ts +34 -32
  218. package/src/runtime/channel-composition.ts +46 -0
  219. package/src/runtime/code-index-services.ts +3 -4
  220. package/src/runtime/daemon-attach-notices.ts +120 -0
  221. package/src/runtime/durability-services.ts +90 -0
  222. package/src/runtime/embedded-service-factories.ts +40 -0
  223. package/src/runtime/feature-settings.ts +147 -0
  224. package/src/runtime/fleet-services.ts +41 -0
  225. package/src/runtime/idle-power-services.ts +85 -0
  226. package/src/runtime/index.ts +6 -21
  227. package/src/runtime/interaction-seams.ts +109 -0
  228. package/src/runtime/legacy-daemon-migration.ts +29 -19
  229. package/src/runtime/memory-fold.ts +1 -1
  230. package/src/runtime/notification-dispatch.ts +110 -0
  231. package/src/runtime/onboarding/apply.ts +3 -29
  232. package/src/runtime/onboarding/fast-path.ts +39 -6
  233. package/src/runtime/onboarding/provider-key-capture.ts +126 -0
  234. package/src/runtime/onboarding/snapshot.ts +0 -1
  235. package/src/runtime/onboarding/types.ts +9 -3
  236. package/src/runtime/power-keepawake-remote.ts +75 -0
  237. package/src/runtime/process-lifecycle.ts +3 -1
  238. package/src/runtime/provider-fallback.ts +55 -0
  239. package/src/runtime/provider-optimizer-wiring.ts +55 -0
  240. package/src/runtime/release-artifacts.ts +19 -103
  241. package/src/runtime/resume-notice.ts +1 -1
  242. package/src/runtime/services.ts +113 -113
  243. package/src/runtime/session-continuity-hints.ts +38 -0
  244. package/src/runtime/surface-feature-flags.ts +40 -9
  245. package/src/runtime/terminal-output-guard.ts +2 -2
  246. package/src/runtime/trust/workspace-registration.ts +10 -2
  247. package/src/runtime/trust/workspace-trust.ts +58 -31
  248. package/src/runtime/ui-services.ts +6 -1
  249. package/src/runtime/update-check.ts +15 -69
  250. package/src/runtime/workstream-services.ts +2 -2
  251. package/src/shell/blocking-input.ts +108 -132
  252. package/src/shell/recovery-input-helpers.ts +53 -166
  253. package/src/shell/ui-openers.ts +38 -6
  254. package/src/utils/format-duration.ts +1 -1
  255. package/src/verification/verification-ledger.ts +21 -4
  256. package/src/version.ts +1 -1
  257. package/src/input/commands/flags-runtime.ts +0 -328
@@ -0,0 +1,201 @@
1
+ /**
2
+ * broker-approval-card — render a broker-originated approval ask as a real
3
+ * TUI approval card.
4
+ *
5
+ * The approval broker publishes every ask (local runtime asks, plus
6
+ * broker-originated ones like the CI fix-session offer). Local asks carry a
7
+ * `localPrompt` that opens their own card; broker-originated asks do not, so
8
+ * before this they were invisible in the TUI — the ask sat pending with no
9
+ * surface. This helper opens a card straight from broker state for those.
10
+ *
11
+ * A local ask's own prompt is opened by the broker immediately AFTER it
12
+ * publishes (see ApprovalBroker.requestApproval), so the open here is deferred
13
+ * one microtask and re-checks: if a card (the local one, or another broker
14
+ * card) is already up, it does nothing — only a genuinely unhandled ask surfaces.
15
+ * The card's resolve answers the broker directly via resolveApproval, so a TUI
16
+ * decision on a broker-originated ask reaches every waiter on that record.
17
+ */
18
+
19
+ import { buildPendingPermissionExtras } from './hunk-selection.ts';
20
+ import type { ApprovalRequesterLookup } from './hunk-selection.ts';
21
+ import type { PendingPermissionState } from '../shell/blocking-input.ts';
22
+ import type { PermissionPromptRequest, RememberTier } from '@pellux/goodvibes-sdk/platform/permissions';
23
+
24
+ /** The broker seam this helper answers through — a subset of ApprovalBroker. */
25
+ export interface BrokerApprovalCardBroker extends ApprovalRequesterLookup {
26
+ getApproval(approvalId: string): { readonly status: string; readonly request: PermissionPromptRequest } | null;
27
+ resolveApproval(
28
+ approvalId: string,
29
+ input: {
30
+ readonly approved: boolean;
31
+ readonly remember?: boolean;
32
+ readonly rememberTier?: RememberTier;
33
+ readonly reason?: string;
34
+ readonly modifiedArgs?: Record<string, unknown>;
35
+ readonly actor: string;
36
+ readonly actorSurface?: string;
37
+ },
38
+ ): Promise<unknown>;
39
+ }
40
+
41
+ export interface BrokerApprovalChangeParams {
42
+ readonly approval: {
43
+ readonly id: string;
44
+ readonly callId: string;
45
+ readonly status: string;
46
+ readonly request: PermissionPromptRequest;
47
+ /** The REAL attachable session id once an ACCEPTED ask (e.g. a ci:fix-session offer) has spawned its session. */
48
+ readonly fixSessionId?: string | undefined;
49
+ /** The honest failure when an accepted ask's spawn did NOT produce an attachable session — mutually exclusive with fixSessionId. */
50
+ readonly fixSessionError?: string | undefined;
51
+ };
52
+ readonly getPending: () => PendingPermissionState | null;
53
+ readonly setPending: (pending: PendingPermissionState | null) => void;
54
+ readonly broker: BrokerApprovalCardBroker;
55
+ readonly render: () => void;
56
+ /**
57
+ * Fired when a record carries the id of a session an accepted ask spawned
58
+ * (the CI fix-session). The surface that accepted — attached now — turns this
59
+ * into a jump/attach affordance. Called each time the field is seen; the
60
+ * caller de-duplicates by id.
61
+ */
62
+ readonly onFixSessionStarted?: (fixSessionId: string) => void;
63
+ /**
64
+ * Fired when a record carries the honest failure from an accepted ask whose
65
+ * spawn produced no attachable session. The surface renders it as an error
66
+ * line with NO action to jump to (there is no session). Called each time the
67
+ * field is seen; the caller de-duplicates.
68
+ */
69
+ readonly onFixSessionError?: (fixSessionError: string) => void;
70
+ /** Defers the open (default queueMicrotask); injectable so tests run it synchronously. */
71
+ readonly defer?: (callback: () => void) => void;
72
+ }
73
+
74
+ const isActiveStatus = (status: string): boolean => status === 'pending' || status === 'claimed';
75
+
76
+ export interface FixSessionAffordanceDeps {
77
+ /** Announce the ready fix session as one actionable line — never a retype instruction. */
78
+ readonly notify: (message: string) => void;
79
+ /** Arm the one-key jump with the spawned session id; the surface consumes it on the jump key. */
80
+ readonly arm: (fixSessionId: string) => void;
81
+ }
82
+
83
+ /**
84
+ * Build the one-key jump/attach affordance for a spawned fix-session: the first
85
+ * time a given session id is seen it ARMS the jump (the surface attaches on the
86
+ * jump key — the machine does the resume, the user never retypes an id) and
87
+ * surfaces a one-line actionable notice. Repeats are no-ops (de-duplicated by
88
+ * id), so the live record update and the listApprovals refresh path can both
89
+ * feed it safely.
90
+ */
91
+ export function buildFixSessionAffordance(deps: FixSessionAffordanceDeps): (fixSessionId: string) => void {
92
+ const seen = new Set<string>();
93
+ return (fixSessionId) => {
94
+ if (seen.has(fixSessionId)) return;
95
+ seen.add(fixSessionId);
96
+ deps.arm(fixSessionId);
97
+ deps.notify('[CI] Fix session ready — press j to jump to it.');
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Build the honest failure notice for an accepted fix-session offer whose spawn
103
+ * produced no attachable session: one error line, NO jump action (there is no
104
+ * session to attach to). De-duplicated by the error text so the live record
105
+ * update and the listApprovals refresh path can both feed it safely.
106
+ */
107
+ export function buildFixSessionErrorNotice(notify: (message: string) => void): (fixSessionError: string) => void {
108
+ const seen = new Set<string>();
109
+ return (fixSessionError) => {
110
+ if (seen.has(fixSessionError)) return;
111
+ seen.add(fixSessionError);
112
+ notify(`[CI] Fix session could not start: ${fixSessionError}`);
113
+ };
114
+ }
115
+
116
+ export interface FixSessionAttachKeyDeps {
117
+ /** The armed fix-session id (the surface clears the arm before calling this). */
118
+ readonly armedFixSessionId: string;
119
+ /** Attach/jump to the fix session — runs the resume so the user never retypes an id. */
120
+ readonly attach: (fixSessionId: string) => void;
121
+ readonly render: () => void;
122
+ }
123
+
124
+ /**
125
+ * Consume the keypress that follows an armed fix-session notice: the jump key
126
+ * ('j') attaches to the armed session; any other key just falls through (the
127
+ * caller has already cleared the arm, so the affordance is one-shot). Returns
128
+ * true only when it performed the jump.
129
+ */
130
+ export function handleFixSessionAttachKey(data: string, deps: FixSessionAttachKeyDeps): boolean {
131
+ if (data === 'j') {
132
+ deps.attach(deps.armedFixSessionId);
133
+ deps.render();
134
+ return true;
135
+ }
136
+ return false;
137
+ }
138
+
139
+ /**
140
+ * The refresh path: sweep current approval records for spawned fix-sessions and
141
+ * feed each to the (de-duplicating) affordance. Catches a session stamped before
142
+ * the live subscription attached, or a record update the subscription missed.
143
+ */
144
+ export function refreshFixSessionsFromApprovals(
145
+ listApprovals: () => readonly { readonly fixSessionId?: string | undefined; readonly fixSessionError?: string | undefined }[],
146
+ onFixSessionStarted: (fixSessionId: string) => void,
147
+ onFixSessionError?: (fixSessionError: string) => void,
148
+ ): void {
149
+ for (const record of listApprovals()) {
150
+ if (record.fixSessionId) onFixSessionStarted(record.fixSessionId);
151
+ else if (record.fixSessionError && onFixSessionError) onFixSessionError(record.fixSessionError);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * React to one broker approval change: clear the active card when ITS approval
157
+ * resolves, or open a card for a newly-pending broker-originated ask that no
158
+ * local prompt is handling.
159
+ */
160
+ export function handleBrokerApprovalChange(params: BrokerApprovalChangeParams): void {
161
+ const { approval, getPending, setPending, broker, render } = params;
162
+ const defer = params.defer ?? queueMicrotask;
163
+ const active = isActiveStatus(approval.status);
164
+
165
+ // An accepted ask that spawned a session (the CI fix-session) publishes its
166
+ // REAL session id as a record update — the live in-process handle to jump to
167
+ // it — or, when the spawn failed, an honest error instead of a dead id
168
+ // (mutually exclusive). Both arrive after the card has resolved and cleared,
169
+ // so surface them independently of the card lifecycle below.
170
+ if (approval.fixSessionId) params.onFixSessionStarted?.(approval.fixSessionId);
171
+ else if (approval.fixSessionError) params.onFixSessionError?.(approval.fixSessionError);
172
+
173
+ const pending = getPending();
174
+ if (pending && pending.callId === approval.callId) {
175
+ // This is the card already on screen — clear it once its approval resolves.
176
+ if (!active) { setPending(null); render(); }
177
+ return;
178
+ }
179
+ if (!active) return;
180
+
181
+ defer(() => {
182
+ if (getPending()) return; // a local card (or another broker card) is already up
183
+ const current = broker.getApproval(approval.id);
184
+ if (!current || !isActiveStatus(current.status)) return;
185
+ setPending({
186
+ ...current.request,
187
+ ...buildPendingPermissionExtras(current.request, (decision) => {
188
+ void broker.resolveApproval(approval.id, {
189
+ approved: decision.approved,
190
+ remember: decision.remember ?? false,
191
+ ...(decision.rememberTier ? { rememberTier: decision.rememberTier } : {}),
192
+ ...(decision.reason ? { reason: decision.reason } : {}),
193
+ ...(decision.modifiedArgs ? { modifiedArgs: decision.modifiedArgs } : {}),
194
+ actor: 'tui',
195
+ actorSurface: 'tui',
196
+ }).catch(() => {});
197
+ }, broker),
198
+ } as PendingPermissionState);
199
+ render();
200
+ });
201
+ }
@@ -1,7 +1,7 @@
1
- import type { PermissionAttribution, PermissionPromptRequest } from '@pellux/goodvibes-sdk/platform/permissions';
1
+ import type { PermissionAttribution, PermissionPromptRequest, RememberTier } from '@pellux/goodvibes-sdk/platform/permissions';
2
2
 
3
3
  /**
4
- * Per-hunk accept/reject at the approval gate (W1.3 v1).
4
+ * Per-hunk accept/reject at the approval gate (v1).
5
5
  *
6
6
  * Scoped to the `edit` tool only: EditInput.edits is already an array of
7
7
  * independent, atomic find/replace units (each a natural "hunk"), so no
@@ -48,6 +48,16 @@ export interface PermissionPromptDecisionWithModifiedArgs {
48
48
  approved: boolean;
49
49
  remember?: boolean;
50
50
  modifiedArgs?: Record<string, unknown>;
51
+ /** Generalizing remember tier: 'session' | 'exact' | 'command-class' | 'path' | 'tool'. */
52
+ rememberTier?: RememberTier;
53
+ /** Free-text from the user; most useful on deny — rides the structured "user declined" tool result. */
54
+ reason?: string;
55
+ }
56
+
57
+ /** Extra decision fields a resolve call can carry beyond approve/remember/modifiedArgs. */
58
+ export interface PermissionResolveExtras {
59
+ readonly rememberTier?: RememberTier;
60
+ readonly reason?: string;
51
61
  }
52
62
 
53
63
  export interface HunkKeyResult {
@@ -247,14 +257,28 @@ export function buildPendingPermissionExtras(
247
257
  now: number = Date.now(),
248
258
  ): {
249
259
  hunkState: HunkSelectionState | undefined;
250
- resolve: (approved: boolean, remember?: boolean, modifiedArgs?: Record<string, unknown>) => void;
260
+ resolve: (approved: boolean, remember?: boolean, modifiedArgs?: Record<string, unknown>, extras?: PermissionResolveExtras) => void;
251
261
  openedAt: number;
252
262
  requestedBy: string | undefined;
263
+ replyMode: 'exec-answer' | undefined;
264
+ replyBuffer: string | undefined;
253
265
  } {
266
+ // An exec-prompt ask (a running command waiting on stdin) opens straight in
267
+ // answer mode: every printable key types the answer — a run whose prompt
268
+ // needs a literal 'y' must never have it eaten as a card command key.
269
+ const isExecPrompt = request.attribution?.kind === 'exec-prompt';
254
270
  return {
255
271
  hunkState: isHunkSelectable(request) ? buildHunkSelectionState(request) : undefined,
256
- resolve: (approved, remember = false, modifiedArgs) => resolvePromise({ approved, remember, modifiedArgs }),
272
+ resolve: (approved, remember = false, modifiedArgs, extras) => resolvePromise({
273
+ approved,
274
+ remember,
275
+ modifiedArgs,
276
+ ...(extras?.rememberTier ? { rememberTier: extras.rememberTier } : {}),
277
+ ...(extras?.reason ? { reason: extras.reason } : {}),
278
+ }),
257
279
  openedAt: now,
258
280
  requestedBy: resolveApprovalRequester(broker, request.callId, request.attribution) ?? undefined,
281
+ replyMode: isExecPrompt ? 'exec-answer' : undefined,
282
+ replyBuffer: isExecPrompt ? '' : undefined,
259
283
  };
260
284
  }
@@ -3,6 +3,8 @@ import { UIFactory } from '../renderer/ui-factory.ts';
3
3
  import type { PermissionCategory, PermissionRequestAnalysis } from '@pellux/goodvibes-sdk/platform/permissions';
4
4
  import { buildPermissionApprovalBrief, getDisplayArg } from '@pellux/goodvibes-sdk/platform/permissions';
5
5
  import { DIFF_TONES, UI_TONES } from '../renderer/ui-primitives.ts';
6
+ import { renderDiffView } from '../renderer/diff-view.ts';
7
+ import { wrapText } from '../utils/terminal-width.ts';
6
8
  import type { HunkSelectionState } from './hunk-selection.ts';
7
9
  import { readSandboxAskAnnotation } from './sandbox-exec-gate.ts';
8
10
  export { buildPendingPermissionExtras } from './hunk-selection.ts';
@@ -13,9 +15,28 @@ export type { PermissionPromptRequest, PermissionPromptDecision, PermissionReque
13
15
  /** Visible hunk rows before a "+N more" trailer kicks in (Risk 3). */
14
16
  const MAX_VISIBLE_HUNKS = 8;
15
17
 
16
- /** Path rows shown before collapsing to a "N files: a, b, +K more" line (UX-B 2a). */
18
+ /** Path rows shown before collapsing to a "N files: a, b, +K more" line (2a). */
17
19
  const MAX_PATH_ROWS = 3;
18
20
 
21
+ /** Diff rows shown for a write/edit ask before the "+N more" trailer. */
22
+ const MAX_DIFF_LINES = 10;
23
+
24
+ /** Field-label column width shared by every " Label : value" row. */
25
+ const FIELD_PREFIX_LEN = 16;
26
+
27
+ /**
28
+ * View-only state the card renders beyond the request itself: the typed-reply
29
+ * mode/draft (deny-with-reason, exec-prompt answer), the honest count of
30
+ * OTHER broker asks still waiting, and the terminal width getPromptHeight
31
+ * needs for wrap-dependent rows (createPromptLines takes width positionally).
32
+ */
33
+ export interface PromptViewState {
34
+ readonly replyMode?: 'deny-reason' | 'exec-answer' | undefined;
35
+ readonly replyBuffer?: string | undefined;
36
+ readonly queueCount?: number | undefined;
37
+ readonly width?: number | undefined;
38
+ }
39
+
19
40
  /** Trailing filename for compact display of a long/absolute path. */
20
41
  function baseName(p: string): string {
21
42
  const cleaned = p.replace(/[/\\]+$/, '');
@@ -47,7 +68,7 @@ function collectField(arr: unknown, field: string): string[] {
47
68
  /**
48
69
  * The real display target(s) of a tool invocation — the paths/commands/urls a
49
70
  * user needs to see, extracted from the actual arg shapes so a nested
50
- * `{files:[{path}]}` no longer falls through to a raw JSON blob. (UX-B 2a.)
71
+ * `{files:[{path}]}` no longer falls through to a raw JSON blob. (2a.)
51
72
  * Returns [] when nothing recognisable is present (caller falls back).
52
73
  */
53
74
  function cardTargets(args: Record<string, unknown>): string[] {
@@ -153,7 +174,7 @@ export class PermissionPromptUI {
153
174
  /**
154
175
  * A low-risk, project-local request is shown condensed (one summary line +
155
176
  * choices) unless the user expanded it with `d`. High/critical risk, external
156
- * scope, and hunk-selectable edits always show the full block. (UX-B 2b.)
177
+ * scope, and hunk-selectable edits always show the full block. (2b.)
157
178
  */
158
179
  private static isCondensed(
159
180
  request: PermissionPromptRequest,
@@ -164,8 +185,11 @@ export class PermissionPromptUI {
164
185
  // Shell execution, delegation and network requests always show the full
165
186
  // card — their action semantics (command, side effects, host, checklist)
166
187
  // warrant scrutiny even when the risk model rates them low. Only mundane
167
- // low-risk filesystem reads/writes condense. (UX-B 2b.)
188
+ // low-risk filesystem reads/writes condense. (2b.)
168
189
  if (request.category === 'execute' || request.category === 'delegate') return false;
190
+ // Remember tiers are one-key choices that must be visible to be usable —
191
+ // a request carrying rememberOptions always shows the full card.
192
+ if (request.rememberOptions && request.rememberOptions.length > 0) return false;
169
193
  const analysis = this.fallbackAnalysis(request);
170
194
  if (analysis.riskLevel !== 'low') return false;
171
195
  const scope = analysis.blastRadius;
@@ -197,32 +221,179 @@ export class PermissionPromptUI {
197
221
  return `${prefix}${shown}${suffix}`;
198
222
  }
199
223
 
224
+ /**
225
+ * The remember block's row texts: one row per SDK remember tier (numbered,
226
+ * most specific first) when the request carries rememberOptions, else the
227
+ * legacy single [A] session-preview row. Hunk mode has no whole-request
228
+ * remember choice at all. Shared by getPromptHeight and createPromptLines.
229
+ */
230
+ private static rememberRowTexts(
231
+ request: PermissionPromptRequest,
232
+ hunkState: HunkSelectionState | undefined,
233
+ width: number,
234
+ ): string[] {
235
+ if (hunkState) return [];
236
+ const options = request.rememberOptions ?? [];
237
+ if (options.length === 0) return [this.rememberPreviewText(request, width)];
238
+ const clamp = (s: string): string => (s.length > width ? `${s.slice(0, Math.max(0, width - 3))}...` : s);
239
+ return options.map((option, index) => {
240
+ const labelCol = index === 0 ? 'Remember ' : ' '.repeat(9);
241
+ return clamp(` ${labelCol}: [${index + 1}] ${option.label} — ${option.detail}`);
242
+ });
243
+ }
244
+
245
+ /**
246
+ * The FULL command of an execute ask, wrapped across as many rows as it
247
+ * needs — never truncated. Empty for non-execute asks and for asks without
248
+ * a string command. Shared by getPromptHeight and createPromptLines.
249
+ */
250
+ private static commandRowTexts(request: PermissionPromptRequest, width: number): string[] {
251
+ if (request.category !== 'execute') return [];
252
+ // Exec-prompt asks render their command via execPromptRowTexts instead.
253
+ if (request.attribution?.kind === 'exec-prompt') return [];
254
+ const command = typeof request.args?.command === 'string'
255
+ ? request.args.command
256
+ : typeof request.args?.cmd === 'string'
257
+ ? request.args.cmd
258
+ : '';
259
+ if (command.length === 0) return [];
260
+ const budget = Math.max(10, width - FIELD_PREFIX_LEN);
261
+ const wrapped = command.split('\n').flatMap((line) => (line.length > 0 ? wrapText(line, budget) : ['']));
262
+ return wrapped.map((row, index) => (index === 0 ? ` Command : ${row}` : `${' '.repeat(FIELD_PREFIX_LEN - 1)}${row}`));
263
+ }
264
+
265
+ /**
266
+ * Unified-diff text lines for a write/edit ask (real colored rendering via
267
+ * renderDiffView), capped at MAX_DIFF_LINES with a context trailer naming
268
+ * how much is hidden. Null when the ask carries nothing diffable. Hunk-mode
269
+ * asks render the interactive hunk list instead. Shared by getPromptHeight
270
+ * and createPromptLines.
271
+ */
272
+ private static writeDiffLines(request: PermissionPromptRequest, hunkState: HunkSelectionState | undefined): string[] | null {
273
+ if (hunkState || request.category !== 'write') return null;
274
+ const args = request.args ?? {};
275
+ const target = typeof args.path === 'string' ? args.path : typeof args.file === 'string' ? args.file : '';
276
+ const out: string[] = [];
277
+ if (typeof args.content === 'string') {
278
+ const contentLines = args.content.split('\n');
279
+ out.push(`+++ ${target || '(new content)'}`);
280
+ for (const line of contentLines.slice(0, MAX_DIFF_LINES)) out.push(`+${line}`);
281
+ if (contentLines.length > MAX_DIFF_LINES) out.push(` … +${contentLines.length - MAX_DIFF_LINES} more lines`);
282
+ return out;
283
+ }
284
+ if (Array.isArray(args.edits)) {
285
+ let budget = MAX_DIFF_LINES;
286
+ let hidden = 0;
287
+ for (const edit of args.edits) {
288
+ if (!edit || typeof edit !== 'object') continue;
289
+ const e = edit as { path?: unknown; find?: unknown; replace?: unknown };
290
+ if (typeof e.find !== 'string' || typeof e.replace !== 'string') continue;
291
+ const findLines = e.find.split('\n');
292
+ const replaceLines = e.replace.split('\n');
293
+ const need = 1 + findLines.length + replaceLines.length;
294
+ if (budget < need) {
295
+ hidden += 1;
296
+ continue;
297
+ }
298
+ out.push(`@@ ${typeof e.path === 'string' ? e.path : target} @@`);
299
+ for (const line of findLines) out.push(`-${line}`);
300
+ for (const line of replaceLines) out.push(`+${line}`);
301
+ budget -= need;
302
+ }
303
+ if (out.length === 0) return null;
304
+ if (hidden > 0) out.push(` … +${hidden} more edits`);
305
+ return out;
306
+ }
307
+ return null;
308
+ }
309
+
310
+ /**
311
+ * Rows for an exec-prompt ask (a running command waiting on stdin): the
312
+ * command and the terminal prompt text, both wrapped in full. Shared by
313
+ * getPromptHeight and createPromptLines.
314
+ */
315
+ private static execPromptRowTexts(request: PermissionPromptRequest, width: number): string[] {
316
+ const attribution = request.attribution;
317
+ if (attribution?.kind !== 'exec-prompt') return [];
318
+ const budget = Math.max(10, width - FIELD_PREFIX_LEN);
319
+ const rows: string[] = [];
320
+ const pushWrapped = (label: string, text: string): void => {
321
+ const wrapped = text.split('\n').flatMap((line) => (line.length > 0 ? wrapText(line, budget) : ['']));
322
+ wrapped.forEach((row, index) => {
323
+ rows.push(index === 0 ? ` ${label.padEnd(9)}: ${row}` : `${' '.repeat(FIELD_PREFIX_LEN - 1)}${row}`);
324
+ });
325
+ };
326
+ if (typeof attribution.command === 'string' && attribution.command.length > 0) pushWrapped('Running', attribution.command);
327
+ if (typeof attribution.prompt === 'string' && attribution.prompt.length > 0) pushWrapped('Asks', attribution.prompt);
328
+ return rows;
329
+ }
330
+
331
+ /** The typed-reply input row text (deny reason / exec answer), or null when no reply mode is active. */
332
+ private static replyRowText(view: PromptViewState | undefined, width: number): string | null {
333
+ if (!view?.replyMode) return null;
334
+ const label = view.replyMode === 'exec-answer' ? 'Answer ' : 'Reason ';
335
+ const draft = view.replyBuffer ?? '';
336
+ const budget = Math.max(6, width - FIELD_PREFIX_LEN - 1);
337
+ const shown = draft.length > budget ? `...${draft.slice(-(budget - 3))}` : draft;
338
+ return ` ${label}: ${shown}█`;
339
+ }
340
+
341
+ /**
342
+ * Assemble the card's view state from the pending-permission slot and the
343
+ * broker's live queue (the honest count of OTHER pending asks — coalesced
344
+ * asks share one record, so they are never double-counted). One helper so
345
+ * main.ts passes identical state to getPromptHeight and createPromptLines.
346
+ */
347
+ static promptViewState(
348
+ pending: { readonly callId: string; readonly replyMode?: 'deny-reason' | 'exec-answer' | undefined; readonly replyBuffer?: string | undefined },
349
+ width: number,
350
+ broker?: { listApprovals(limit?: number): ReadonlyArray<{ readonly callId: string; readonly status: string }> } | null,
351
+ ): PromptViewState {
352
+ let queueCount = 0;
353
+ try {
354
+ queueCount = broker?.listApprovals().filter((record) => record.status === 'pending' && record.callId !== pending.callId).length ?? 0;
355
+ } catch {
356
+ queueCount = 0; // an unreadable broker never blocks the card
357
+ }
358
+ return { replyMode: pending.replyMode, replyBuffer: pending.replyBuffer, queueCount, width };
359
+ }
360
+
200
361
  static getPromptHeight(
201
362
  request: PermissionPromptRequest,
202
363
  hunkState?: HunkSelectionState,
203
364
  detailsExpanded = false,
204
365
  requestedBy?: string,
366
+ view?: PromptViewState,
205
367
  ): number {
206
- // Attribution line (only when known) + the always-present remember-scope
207
- // preview line are added to BOTH card shapes; keep in sync with
208
- // createPromptLines. A hunk-selection prompt has no single [A] remember key,
209
- // so the preview line is suppressed there (see createPromptLines).
368
+ const width = view?.width ?? 80;
369
+ // Attribution line (only when known) + the remember block (tier rows when
370
+ // the request carries rememberOptions, else the legacy one-line preview)
371
+ // are added to BOTH card shapes; keep in sync with createPromptLines.
210
372
  const attributionLines = requestedBy ? 1 : 0;
211
- const previewLines = hunkState ? 0 : 1;
373
+ const rememberLines = this.rememberRowTexts(request, hunkState, width).length;
212
374
  // Condensed low-risk card: top separator, title, [attribution], [preview],
213
375
  // summary, choices, bottom separator — see createPromptLines' condensed branch.
214
- if (this.isCondensed(request, hunkState, detailsExpanded)) return 5 + attributionLines + previewLines;
376
+ if (this.isCondensed(request, hunkState, detailsExpanded)) return 5 + attributionLines + rememberLines;
215
377
  const analysis = this.fallbackAnalysis(request);
216
378
  const { annotation: judgmentAnnotation, rest: reasonsMinusJudgment } = extractModelJudgmentAnnotation(analysis.reasons);
217
379
  const reasonLines = Math.min(2, Math.max(1, reasonsMinusJudgment.length));
218
380
  const extraLines = (analysis.host ? 1 : 0) + (analysis.surface ? 1 : 0) + (analysis.sideEffects && analysis.sideEffects.length > 0 ? 1 : 0) + (readSandboxAskAnnotation(request) ? 1 : 0) + (judgmentAnnotation ? 1 : 0);
219
381
  const hunkLines = hunkState ? hunkListRowCount(hunkState) : 0;
382
+ // Item-adopted blocks, each computed by the SAME helper createPromptLines
383
+ // renders from, so the two functions cannot drift: the full wrapped
384
+ // command (execute), the colored diff (write), the exec-prompt rows, and
385
+ // the typed-reply input row.
386
+ const commandLines = this.commandRowTexts(request, width).length;
387
+ const diffLines = this.writeDiffLines(request, hunkState)?.length ?? 0;
388
+ const execPromptLines = this.execPromptRowTexts(request, width).length;
389
+ const replyLines = this.replyRowText(view, width) ? 1 : 0;
220
390
  // Base 12 counted a single arg line; the Path field now spans `pathRows`
221
391
  // lines and the full card adds one raw-args row (2a reachability), so the
222
392
  // arg allotment becomes pathRows + 1 → base 12 + pathRows (12 already
223
393
  // included one of those). See createPromptLines' full branch.
224
394
  const pathRows = this.pathRowTexts(this.resolvedTargets(request), 999).length;
225
- return 12 + pathRows + reasonLines + extraLines + hunkLines + attributionLines + previewLines;
395
+ return 12 + pathRows + reasonLines + extraLines + hunkLines + attributionLines + rememberLines
396
+ + commandLines + diffLines + execPromptLines + replyLines;
226
397
  }
227
398
 
228
399
  /** Returns the key argument to display for a given tool invocation. */
@@ -258,6 +429,7 @@ export class PermissionPromptUI {
258
429
  hunkState?: HunkSelectionState,
259
430
  detailsExpanded = false,
260
431
  requestedBy?: string,
432
+ view?: PromptViewState,
261
433
  ): Line[] {
262
434
  const lines: Line[] = [];
263
435
  const { tool, args, category } = request;
@@ -270,32 +442,40 @@ export class PermissionPromptUI {
270
442
  const TEXT = '252';
271
443
  const DIM = '244';
272
444
 
273
- // Attribution line (which agent/process is asking) and the remember-scope
274
- // preview line ([A] writes exactly this key). Both are shared by the
275
- // condensed and full cards; the preview is suppressed for a hunk-selection
276
- // prompt, which has no single whole-request [A] remember key.
445
+ // Honest queue: how many OTHER broker asks are waiting behind this one.
446
+ // They surface in turn as each is answered; the count keeps that visible.
447
+ const queueSuffix = view?.queueCount && view.queueCount > 0
448
+ ? ` — ${view.queueCount} more waiting`
449
+ : '';
450
+
451
+ // Attribution line (which agent/process is asking) and the remember block
452
+ // (numbered tier rows from the SDK's rememberOptions, or the legacy [A]
453
+ // session-preview row). Both are shared by the condensed and full cards;
454
+ // the remember block is suppressed for a hunk-selection prompt, which has
455
+ // no single whole-request remember key.
277
456
  const pushAttribution = (): void => {
278
457
  if (!requestedBy) return;
279
458
  lines.push(UIFactory.stringToLine(` Requested by: ${requestedBy}`.padEnd(width), width, { fg: DIM }));
280
459
  };
281
- const pushRememberPreview = (): void => {
282
- if (hunkState) return;
283
- lines.push(UIFactory.stringToLine(this.rememberPreviewText(request, width).padEnd(width), width, { fg: DIM }));
460
+ const pushRememberBlock = (): void => {
461
+ for (const rowText of this.rememberRowTexts(request, hunkState, width)) {
462
+ lines.push(UIFactory.stringToLine(rowText.padEnd(width), width, { fg: DIM }));
463
+ }
284
464
  };
285
465
 
286
466
  const maxArgLen = Math.max(10, width - 16);
287
467
  const pathRows = this.pathRowTexts(this.resolvedTargets(request), maxArgLen);
288
468
 
289
469
  // Condensed low-risk / project-local card: one summary line (verb → target
290
- // (scope)) plus the choices. Full block is one `d` away. (UX-B 2b.)
470
+ // (scope)) plus the choices. Full block is one `d` away. (2b.)
291
471
  if (this.isCondensed(request, hunkState, detailsExpanded)) {
292
472
  lines.push(UIFactory.stringToLine('─'.repeat(width), width, { fg: ACCENT, dim: true }));
293
- lines.push(UIFactory.stringToLine(` [${label}] ${brief.title} `.padEnd(width), width, { fg: WARN, bold: true }));
473
+ lines.push(UIFactory.stringToLine(` [${label}] ${brief.title}${queueSuffix} `.padEnd(width), width, { fg: WARN, bold: true }));
294
474
  pushAttribution();
295
475
  const scopeText = analysis.blastRadius ? ` (${analysis.blastRadius})` : '';
296
476
  const summaryLine = ` ${categoryVerb(category)} → ${pathRows[0]}${scopeText}`;
297
477
  lines.push(UIFactory.stringToLine(summaryLine.padEnd(width), width, { fg: TEXT }));
298
- pushRememberPreview();
478
+ pushRememberBlock();
299
479
  lines.push(UIFactory.stringToLine(
300
480
  ` [Y] Allow once [A] Allow always (session) [N] Deny [d] details`.padEnd(width),
301
481
  width, { fg: ACCENT, bold: true }));
@@ -306,9 +486,9 @@ export class PermissionPromptUI {
306
486
  // Top separator
307
487
  lines.push(UIFactory.stringToLine('─'.repeat(width), width, { fg: ACCENT, dim: true }));
308
488
 
309
- // Title bar: category badge + title
489
+ // Title bar: category badge + title (+ honest waiting count)
310
490
  const titleText = brief.title;
311
- const titleLine = ` [${label}] ${titleText} `;
491
+ const titleLine = ` [${label}] ${titleText}${queueSuffix} `;
312
492
  lines.push(UIFactory.stringToLine(titleLine.padEnd(width), width, { fg: WARN, bold: true }));
313
493
 
314
494
  // Requester attribution (which agent/process raised this request).
@@ -320,12 +500,24 @@ export class PermissionPromptUI {
320
500
 
321
501
  // Path/subject row(s): actual target path(s), one per line when few, a
322
502
  // "N files: a, b, +K more" summary when many. Raw args move to the Args row
323
- // below so this field never shows a truncated JSON blob. (UX-B 2a.)
503
+ // below so this field never shows a truncated JSON blob. (2a.)
324
504
  pathRows.forEach((rowText, i) => {
325
505
  const labelCol = i === 0 ? brief.subjectLabel.padEnd(9) : ' '.repeat(9);
326
506
  lines.push(UIFactory.stringToLine(` ${labelCol}: ${rowText}`.padEnd(width), width, { fg: TEXT }));
327
507
  });
328
508
 
509
+ // The FULL command of an execute ask, wrapped — never truncated. The Path
510
+ // row above stays as the one-line subject; this block is the whole truth.
511
+ for (const rowText of this.commandRowTexts(request, width)) {
512
+ lines.push(UIFactory.stringToLine(rowText.padEnd(width), width, { fg: TEXT }));
513
+ }
514
+
515
+ // Exec-prompt attribution: the running command and the terminal prompt it
516
+ // is stuck on, wrapped in full — the typed answer feeds this run's stdin.
517
+ for (const rowText of this.execPromptRowTexts(request, width)) {
518
+ lines.push(UIFactory.stringToLine(rowText.padEnd(width), width, { fg: WARN }));
519
+ }
520
+
329
521
  // Working directory row
330
522
  const cwd = request.workingDirectory ?? '(unknown)';
331
523
  const maxCwdLen = Math.max(10, width - 16);
@@ -416,6 +608,13 @@ export class PermissionPromptUI {
416
608
  // Blank spacer
417
609
  lines.push(UIFactory.stringToLine(' '.repeat(width), width));
418
610
 
611
+ // Real colored diff for a write/edit ask (adds green, removals red, hunk
612
+ // headers blue — the same diff-view machinery the transcript uses).
613
+ const diffTextLines = this.writeDiffLines(request, hunkState);
614
+ if (diffTextLines) {
615
+ lines.push(...renderDiffView(diffTextLines.join('\n'), width));
616
+ }
617
+
419
618
  if (hunkState) {
420
619
  // Hunk list — see hunkListRowCount() for the row-count contract this
421
620
  // block must match exactly (Risk 2: getPromptHeight/createPromptLines
@@ -450,15 +649,31 @@ export class PermissionPromptUI {
450
649
  lines.push(UIFactory.stringToLine(trailerLine.padEnd(width), width, { fg: DIM }));
451
650
  }
452
651
 
453
- // Remember-scope preview: exactly what [A] will write (omitted for hunk mode).
454
- pushRememberPreview();
652
+ // Remember block: numbered tier rows (rememberOptions) or the legacy [A]
653
+ // session preview (omitted for hunk mode).
654
+ pushRememberBlock();
655
+
656
+ // Typed-reply input row: the deny reason (Enter denies with it as
657
+ // feedback) or the exec-prompt answer (Enter feeds the running command).
658
+ const replyRow = this.replyRowText(view, width);
659
+ if (replyRow) {
660
+ lines.push(UIFactory.stringToLine(replyRow.padEnd(width), width, { fg: TEXT, bold: true }));
661
+ }
455
662
 
456
663
  // Choices row. A condensable card shown in full is the expanded form, so it
457
664
  // offers `[d]` to collapse back; other full cards omit the details toggle.
458
665
  const collapseHint = this.isCondensed(request, hunkState, false) ? ' [d] hide details' : '';
459
- const choicesLine = hunkState
460
- ? ` [j/k] Navigate [Space] Toggle [A] All [Enter] Apply selected [N] Deny`
461
- : ` [Y] Allow once [A] Allow always (session) [N] Deny${collapseHint}`;
666
+ const tierCount = hunkState ? 0 : (request.rememberOptions?.length ?? 0);
667
+ const rememberHint = tierCount > 0
668
+ ? ` [1${tierCount > 1 ? `-${tierCount}` : ''}] Allow + remember`
669
+ : ' [A] Allow always (session)';
670
+ const choicesLine = view?.replyMode === 'exec-answer'
671
+ ? ` [Enter] Send answer [Esc] ${((view.replyBuffer ?? '').length > 0) ? 'Clear' : 'Decline'} [Ctrl+C] Abort turn`
672
+ : view?.replyMode === 'deny-reason'
673
+ ? ` [Enter] Deny with this reason [Esc] Back [Ctrl+C] Abort turn`
674
+ : hunkState
675
+ ? ` [j/k] Navigate [Space] Toggle [A] All [Enter] Apply selected [N] Deny`
676
+ : ` [Y] Allow once${rememberHint} [N] Deny type a reason to deny${collapseHint}`;
462
677
  lines.push(UIFactory.stringToLine(choicesLine.padEnd(width), width, { fg: ACCENT, bold: true }));
463
678
 
464
679
  // Bottom separator