poly-weaver 0.11.4 → 0.12.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 (164) hide show
  1. package/dist/agents/reviewers/handler.d.ts.map +1 -1
  2. package/dist/agents/reviewers/handler.js +6 -1
  3. package/dist/agents/reviewers/handler.js.map +1 -1
  4. package/dist/agents/reviewers/sanity-inject.d.ts +7 -0
  5. package/dist/agents/reviewers/sanity-inject.d.ts.map +1 -0
  6. package/dist/agents/reviewers/sanity-inject.js +22 -0
  7. package/dist/agents/reviewers/sanity-inject.js.map +1 -0
  8. package/dist/auto-update/tui.d.ts +1 -1
  9. package/dist/auto-update/tui.d.ts.map +1 -1
  10. package/dist/auto-update/tui.js +3 -2
  11. package/dist/auto-update/tui.js.map +1 -1
  12. package/dist/dump/hotkey.d.ts +13 -2
  13. package/dist/dump/hotkey.d.ts.map +1 -1
  14. package/dist/dump/hotkey.js +22 -3
  15. package/dist/dump/hotkey.js.map +1 -1
  16. package/dist/flow/executor.d.ts +2 -2
  17. package/dist/flow/executor.d.ts.map +1 -1
  18. package/dist/flow/executor.js +79 -45
  19. package/dist/flow/executor.js.map +1 -1
  20. package/dist/flow/types.d.ts +2 -0
  21. package/dist/flow/types.d.ts.map +1 -1
  22. package/dist/flow/types.js.map +1 -1
  23. package/dist/flow-editor/tui.d.ts.map +1 -1
  24. package/dist/flow-editor/tui.js +4 -4
  25. package/dist/flow-editor/tui.js.map +1 -1
  26. package/dist/offscreen/agent-viewer.d.ts +28 -0
  27. package/dist/offscreen/agent-viewer.d.ts.map +1 -0
  28. package/dist/offscreen/agent-viewer.js +168 -0
  29. package/dist/offscreen/agent-viewer.js.map +1 -0
  30. package/dist/offscreen/controller.d.ts +38 -1
  31. package/dist/offscreen/controller.d.ts.map +1 -1
  32. package/dist/offscreen/controller.js +376 -12
  33. package/dist/offscreen/controller.js.map +1 -1
  34. package/dist/offscreen/fork-busy.d.ts +28 -0
  35. package/dist/offscreen/fork-busy.d.ts.map +1 -0
  36. package/dist/offscreen/fork-busy.js +86 -0
  37. package/dist/offscreen/fork-busy.js.map +1 -0
  38. package/dist/offscreen/fork-launch.d.ts +3 -0
  39. package/dist/offscreen/fork-launch.d.ts.map +1 -1
  40. package/dist/offscreen/fork-launch.js +1 -0
  41. package/dist/offscreen/fork-launch.js.map +1 -1
  42. package/dist/offscreen/fork.d.ts +2 -0
  43. package/dist/offscreen/fork.d.ts.map +1 -1
  44. package/dist/offscreen/fork.js +6 -2
  45. package/dist/offscreen/fork.js.map +1 -1
  46. package/dist/offscreen/inquiry-sender.d.ts +19 -0
  47. package/dist/offscreen/inquiry-sender.d.ts.map +1 -0
  48. package/dist/offscreen/inquiry-sender.js +60 -0
  49. package/dist/offscreen/inquiry-sender.js.map +1 -0
  50. package/dist/offscreen/keys.d.ts +4 -0
  51. package/dist/offscreen/keys.d.ts.map +1 -1
  52. package/dist/offscreen/keys.js +45 -0
  53. package/dist/offscreen/keys.js.map +1 -1
  54. package/dist/offscreen/resolve.d.ts.map +1 -1
  55. package/dist/offscreen/resolve.js +8 -0
  56. package/dist/offscreen/resolve.js.map +1 -1
  57. package/dist/orchestrator.d.ts.map +1 -1
  58. package/dist/orchestrator.js +6 -0
  59. package/dist/orchestrator.js.map +1 -1
  60. package/dist/providers/claude/completion-plan-mode.d.ts +3 -2
  61. package/dist/providers/claude/completion-plan-mode.d.ts.map +1 -1
  62. package/dist/providers/claude/completion-plan-mode.js +13 -11
  63. package/dist/providers/claude/completion-plan-mode.js.map +1 -1
  64. package/dist/providers/claude/completion.d.ts +4 -1
  65. package/dist/providers/claude/completion.d.ts.map +1 -1
  66. package/dist/providers/claude/completion.js +28 -9
  67. package/dist/providers/claude/completion.js.map +1 -1
  68. package/dist/providers/claude/plan-mode-pty-monitor.d.ts +9 -6
  69. package/dist/providers/claude/plan-mode-pty-monitor.d.ts.map +1 -1
  70. package/dist/providers/claude/plan-mode-pty-monitor.js +15 -6
  71. package/dist/providers/claude/plan-mode-pty-monitor.js.map +1 -1
  72. package/dist/providers/claude/session.d.ts +6 -0
  73. package/dist/providers/claude/session.d.ts.map +1 -1
  74. package/dist/providers/claude/session.js +41 -1
  75. package/dist/providers/claude/session.js.map +1 -1
  76. package/dist/providers/codex/completion.d.ts +6 -1
  77. package/dist/providers/codex/completion.d.ts.map +1 -1
  78. package/dist/providers/codex/completion.js +17 -1
  79. package/dist/providers/codex/completion.js.map +1 -1
  80. package/dist/providers/codex/plan-mode-activator.d.ts +4 -0
  81. package/dist/providers/codex/plan-mode-activator.d.ts.map +1 -1
  82. package/dist/providers/codex/plan-mode-activator.js +33 -18
  83. package/dist/providers/codex/plan-mode-activator.js.map +1 -1
  84. package/dist/providers/codex/session.d.ts +9 -0
  85. package/dist/providers/codex/session.d.ts.map +1 -1
  86. package/dist/providers/codex/session.js +34 -1
  87. package/dist/providers/codex/session.js.map +1 -1
  88. package/dist/providers/copilot/completion.d.ts +4 -2
  89. package/dist/providers/copilot/completion.d.ts.map +1 -1
  90. package/dist/providers/copilot/completion.js +25 -15
  91. package/dist/providers/copilot/completion.js.map +1 -1
  92. package/dist/resume-tui.d.ts +1 -1
  93. package/dist/resume-tui.d.ts.map +1 -1
  94. package/dist/resume-tui.js +3 -1
  95. package/dist/resume-tui.js.map +1 -1
  96. package/dist/session/manifest.d.ts +3 -1
  97. package/dist/session/manifest.d.ts.map +1 -1
  98. package/dist/session/reader.d.ts +4 -0
  99. package/dist/session/reader.d.ts.map +1 -1
  100. package/dist/session/reader.js +15 -0
  101. package/dist/session/reader.js.map +1 -1
  102. package/dist/session/restore.d.ts.map +1 -1
  103. package/dist/session/restore.js +2 -1
  104. package/dist/session/restore.js.map +1 -1
  105. package/dist/session/session-store.d.ts +8 -0
  106. package/dist/session/session-store.d.ts.map +1 -1
  107. package/dist/session/session-store.js +18 -1
  108. package/dist/session/session-store.js.map +1 -1
  109. package/dist/startup-tui.d.ts.map +1 -1
  110. package/dist/startup-tui.js +18 -7
  111. package/dist/startup-tui.js.map +1 -1
  112. package/dist/status-bar.d.ts +2 -0
  113. package/dist/status-bar.d.ts.map +1 -1
  114. package/dist/status-bar.js +28 -2
  115. package/dist/status-bar.js.map +1 -1
  116. package/dist/terminal/host.d.ts +26 -0
  117. package/dist/terminal/host.d.ts.map +1 -1
  118. package/dist/terminal/host.js +186 -19
  119. package/dist/terminal/host.js.map +1 -1
  120. package/dist/terminal/input-router.d.ts +16 -2
  121. package/dist/terminal/input-router.d.ts.map +1 -1
  122. package/dist/terminal/input-router.js +59 -3
  123. package/dist/terminal/input-router.js.map +1 -1
  124. package/dist/terminal/paste-submit.d.ts +42 -0
  125. package/dist/terminal/paste-submit.d.ts.map +1 -0
  126. package/dist/terminal/paste-submit.js +146 -0
  127. package/dist/terminal/paste-submit.js.map +1 -0
  128. package/dist/terminal-input.d.ts.map +1 -1
  129. package/dist/terminal-input.js +6 -0
  130. package/dist/terminal-input.js.map +1 -1
  131. package/dist/text-editing/emacs-input.d.ts +5 -0
  132. package/dist/text-editing/emacs-input.d.ts.map +1 -1
  133. package/dist/text-editing/emacs-input.js +17 -1
  134. package/dist/text-editing/emacs-input.js.map +1 -1
  135. package/dist/user/feedback-editing.d.ts +28 -0
  136. package/dist/user/feedback-editing.d.ts.map +1 -0
  137. package/dist/user/feedback-editing.js +72 -0
  138. package/dist/user/feedback-editing.js.map +1 -0
  139. package/dist/user/handler.d.ts.map +1 -1
  140. package/dist/user/handler.js +3 -2
  141. package/dist/user/handler.js.map +1 -1
  142. package/dist/user/host-curate-prompt.d.ts +9 -8
  143. package/dist/user/host-curate-prompt.d.ts.map +1 -1
  144. package/dist/user/host-curate-prompt.js +54 -119
  145. package/dist/user/host-curate-prompt.js.map +1 -1
  146. package/dist/user/host-prompt.d.ts +21 -11
  147. package/dist/user/host-prompt.d.ts.map +1 -1
  148. package/dist/user/host-prompt.js +424 -301
  149. package/dist/user/host-prompt.js.map +1 -1
  150. package/dist/user/issue-inquiry.d.ts +6 -0
  151. package/dist/user/issue-inquiry.d.ts.map +1 -0
  152. package/dist/user/issue-inquiry.js +7 -0
  153. package/dist/user/issue-inquiry.js.map +1 -0
  154. package/dist/user/issues-pane.d.ts +4 -0
  155. package/dist/user/issues-pane.d.ts.map +1 -0
  156. package/dist/user/issues-pane.js +38 -0
  157. package/dist/user/issues-pane.js.map +1 -0
  158. package/dist/user/prompt.d.ts +1 -1
  159. package/dist/user/prompt.d.ts.map +1 -1
  160. package/dist/user/split-surface.d.ts +12 -0
  161. package/dist/user/split-surface.d.ts.map +1 -1
  162. package/dist/user/split-surface.js +35 -0
  163. package/dist/user/split-surface.js.map +1 -1
  164. package/package.json +1 -1
@@ -1,35 +1,35 @@
1
- import { bold, dim, green, invert, reset, visibleLength, yellow, } from "../ansi.js";
1
+ import { bold, dim, green, reset, visibleLength, yellow, } from "../ansi.js";
2
2
  import { AgentInterruptedError } from "../agents/runner.js";
3
3
  import { EmacsInputParser } from "./prompt.js";
4
- import { clipToWidth } from "./curate-prompt.js";
4
+ import { clipToWidth, wrapToWidth } from "./curate-prompt.js";
5
5
  import { buildPlanPane } from "./plan-pane.js";
6
6
  import { renderShortcutHints } from "./shortcut-hints.js";
7
- import { applyTextBufferEvent, isTextBufferEvent, splitPasteToEvents, } from "../text-editing/emacs-input.js";
8
- import { cursorToVisual, renderWrappedLine, softWrapLine } from "../text-editing/soft-wrap.js";
7
+ import { aliasSelectionNavEvent, isTextBufferEvent, } from "../text-editing/emacs-input.js";
9
8
  import { OffscreenController, } from "../offscreen/controller.js";
10
9
  import { AttachmentManager } from "../paste-image/attachment-manager.js";
11
10
  import { ALT_V_PASTE_IMAGE_HINT, handleBracketedPaste, handleImagePasteIntent, PASTING_IMAGE_HINT, } from "../paste-image/intents.js";
12
- import { ensurePersistentImagesDir } from "../paste-image/persistence.js";
13
- /** Maximum visible rows for the multiline feedback editor. */
14
- const FEEDBACK_EDITOR_MAX_ROWS = 5;
11
+ import { buildSplitSurface, computeSplitMetrics, SPLIT_COLLAPSE_AT_COLS, } from "./split-surface.js";
12
+ import { applyEditingBufferEvent, applyEditInsertedText, bufferFromText, emptyBuffer, finalizeEditText, renderMultilineEditor, } from "./feedback-editing.js";
13
+ // Max rows allotted to the scrollable feedback-list block (Approve/item/+Add
14
+ // rows only; the separator + hint are fixed chrome rendered outside this
15
+ // budget). This same constant is both the stacked-layout scroll budget and the
16
+ // wide-terminal split trigger.
17
+ export const FEEDBACK_AREA_MAX_ROWS = 10;
15
18
  /** Lines of overlap when paging the plan (PageUp/PageDown). */
16
19
  const PLAN_PAGE_OVERLAP = 1;
17
- const ACTIVE_PREFIX = " ❯";
20
+ const ACTIVE_SELECTOR = "❯";
18
21
  const INACTIVE_PREFIX = " ";
19
22
  /**
20
- * Host-backed user-review prompt with a top-down stack layout.
23
+ * Host-backed user-review prompt: a scrollable plan/spec body plus the review
24
+ * controls, stacked top-to-bottom or split on wide terminals.
21
25
  *
22
- * Plan on top (header rule + scrollable body), full-width dim ─ separator, and
23
- * a controls block (Approve + bounded feedback editor + compact hint row).
26
+ * The controls use the same multi-item feedback model as verdict curation: add,
27
+ * edit, and delete feedback items, then submit the list as discrete feedback
28
+ * items (or Approve to accept with no feedback).
24
29
  *
25
- * Key handling:
26
- * - ↑/↓ toggles between Approve and the editor (does NOT scroll the plan).
27
- * - PageUp/PageDown/Home/End/wheel scrolls the plan.
28
- * - Ctrl+D submits feedback (or accepts when on Approve).
29
- * - Ctrl+C quits poly-weaver (exit code 130). When the feedback editor has
30
- * typed text, the first Ctrl+C clears the buffer instead; a second Ctrl+C
31
- * on an empty buffer quits.
32
- * - Ctrl+] is consumed by the host's `InputRouter` for dump capture.
30
+ * ↑/↓ moves among the control rows without scrolling the plan, while
31
+ * PageUp/PageDown/Home/End/wheel scroll the plan. Alt+] is consumed by the
32
+ * host's `InputRouter` for dump capture before reaching this prompt.
33
33
  */
34
34
  export class HostBackedUserPrompt {
35
35
  host;
@@ -40,23 +40,19 @@ export class HostBackedUserPrompt {
40
40
  if (!this.host.isInteractive) {
41
41
  return { accepted: true };
42
42
  }
43
+ const state = {
44
+ cursor: 1,
45
+ userAdded: [],
46
+ editing: undefined,
47
+ planScrollTop: 0,
48
+ planAtEnd: false,
49
+ };
50
+ const parser = new EmacsInputParser();
43
51
  return new Promise((resolve, reject) => {
44
- const state = {
45
- mode: "input",
46
- lines: [""],
47
- cursorLine: 0,
48
- cursorCol: 0,
49
- feedbackScrollTop: 0,
50
- planScrollTop: 0,
51
- planAtEnd: false,
52
- transientHint: null,
53
- };
54
- const parser = new EmacsInputParser();
55
- const attachments = new AttachmentManager();
56
52
  let detach;
57
53
  let offscreenController;
58
54
  let active = true;
59
- let settling = false;
55
+ let settlingEdit = false;
60
56
  const cleanup = () => {
61
57
  active = false;
62
58
  offscreenController?.dispose();
@@ -72,185 +68,255 @@ export class HostBackedUserPrompt {
72
68
  cleanup();
73
69
  reject(err);
74
70
  };
75
- const finalizeFeedbackText = async (text) => {
76
- const targetDir = context.persistentDir
77
- ? await ensurePersistentImagesDir(context.persistentDir)
78
- : undefined;
79
- return attachments.finalize(text, targetDir ? { targetDir } : {});
71
+ const submitFeedback = () => {
72
+ finish({ accepted: false, feedback: [...state.userAdded] });
73
+ };
74
+ const cancelEdit = () => {
75
+ if (settlingEdit || !state.editing)
76
+ return;
77
+ state.editing = undefined;
78
+ clampCursor(state);
79
+ };
80
+ const applySubmittedEdit = (editing, text) => {
81
+ state.editing = undefined;
82
+ if (editing.target.kind === "user-edit") {
83
+ const index = editing.target.index;
84
+ if (text.length > 0) {
85
+ state.userAdded[index] = text;
86
+ }
87
+ else {
88
+ state.userAdded.splice(index, 1);
89
+ }
90
+ }
91
+ else if (text.length > 0) {
92
+ state.userAdded.push(text);
93
+ }
94
+ state.cursor = addRowCursorIndex(state);
95
+ clampCursor(state);
80
96
  };
81
- const submitFeedback = (text) => {
82
- settling = true;
83
- void finalizeFeedbackText(text).then((comments) => {
84
- finish({ accepted: false, comments });
97
+ const submitEdit = () => {
98
+ const editing = state.editing;
99
+ if (!editing || settlingEdit)
100
+ return;
101
+ const text = editing.buffer.lines.join("\n").trim();
102
+ if (editing.attachments.snapshot().length === 0) {
103
+ applySubmittedEdit(editing, text);
104
+ return;
105
+ }
106
+ settlingEdit = true;
107
+ void finalizeEditText(editing, text, context.persistentDir).then((finalText) => {
108
+ if (!active || state.editing !== editing)
109
+ return;
110
+ applySubmittedEdit(editing, finalText);
111
+ settlingEdit = false;
112
+ this.host.scheduleParentUiRender();
85
113
  }).catch((err) => {
114
+ settlingEdit = false;
86
115
  failWith(err instanceof Error ? err : new Error(String(err)));
87
116
  });
88
117
  };
89
- const applyInsertedText = (text) => {
90
- if (state.mode === "approve")
91
- state.mode = "input";
92
- for (const textEvent of splitPasteToEvents(text)) {
93
- applyFeedbackTextBufferEvent(state, textEvent);
94
- }
118
+ const openUserAddEditor = () => {
119
+ state.cursor = addRowCursorIndex(state);
120
+ state.editing = {
121
+ target: { kind: "user-add" },
122
+ buffer: emptyBuffer(),
123
+ attachments: new AttachmentManager(),
124
+ transientHint: null,
125
+ };
95
126
  };
96
- const applyPasteOutcome = (outcome) => {
97
- if (!active || settling)
127
+ const openUserEditEditor = (index) => {
128
+ state.cursor = index;
129
+ state.editing = {
130
+ target: { kind: "user-edit", index },
131
+ buffer: bufferFromText(state.userAdded[index] ?? ""),
132
+ attachments: new AttachmentManager(),
133
+ transientHint: null,
134
+ };
135
+ };
136
+ const applyEditPasteOutcome = (editing, outcome) => {
137
+ if (!active || settlingEdit || state.editing !== editing)
98
138
  return;
99
- state.transientHint = outcome.hint ?? null;
139
+ editing.transientHint = outcome.hint ?? null;
100
140
  if (outcome.inserted !== undefined)
101
- applyInsertedText(outcome.inserted);
141
+ applyEditInsertedText(editing, outcome.inserted);
102
142
  this.host.scheduleParentUiRender();
103
143
  };
104
- const launchPasteIntent = (task) => {
105
- void task.then(applyPasteOutcome).catch((err) => {
106
- if (!active || settling)
144
+ const launchEditPasteIntent = (editing, task) => {
145
+ void task.then((outcome) => applyEditPasteOutcome(editing, outcome)).catch((err) => {
146
+ if (!active || settlingEdit || state.editing !== editing)
107
147
  return;
108
- state.transientHint = err instanceof Error ? err.message : String(err);
148
+ editing.transientHint = err instanceof Error ? err.message : String(err);
109
149
  this.host.scheduleParentUiRender();
110
150
  });
111
151
  };
112
- const baseMode = {
113
- getSurface: () => {
114
- const layout = this.host.getLayout();
115
- const cols = Math.max(20, layout.columns);
116
- const rows = Math.max(1, layout.childRows);
117
- return buildSurface(plan, context, state, cols, rows);
118
- },
119
- onEvents: (keys) => {
120
- const events = parser.mapKeys(keys);
121
- let dirty = false;
122
- for (const event of events) {
123
- if (settling)
124
- return true;
125
- if (state.transientHint) {
126
- state.transientHint = null;
152
+ const processEvents = (events) => {
153
+ let dirty = false;
154
+ let finished = false;
155
+ for (const rawEvent of events) {
156
+ if (finished)
157
+ break;
158
+ const event = state.editing
159
+ ? rawEvent
160
+ : aliasSelectionNavEvent(rawEvent);
161
+ if (state.editing) {
162
+ if (settlingEdit) {
163
+ dirty = true;
164
+ continue;
165
+ }
166
+ if (state.editing.transientHint) {
167
+ state.editing.transientHint = null;
127
168
  dirty = true;
128
169
  }
129
170
  if (event.type === "paste") {
130
- launchPasteIntent(handleBracketedPaste(event.content, attachments));
171
+ const editing = state.editing;
172
+ launchEditPasteIntent(editing, handleBracketedPaste(event.content, editing.attachments));
131
173
  continue;
132
174
  }
133
175
  if (event.type === "image-paste") {
134
- launchPasteIntent(handleImagePasteIntent(attachments, () => {
135
- if (!active || settling)
176
+ const editing = state.editing;
177
+ launchEditPasteIntent(editing, handleImagePasteIntent(editing.attachments, () => {
178
+ if (!active || settlingEdit || state.editing !== editing)
136
179
  return;
137
- state.transientHint = PASTING_IMAGE_HINT;
180
+ editing.transientHint = PASTING_IMAGE_HINT;
138
181
  this.host.scheduleParentUiRender();
139
182
  }));
140
183
  continue;
141
184
  }
142
- if (event.type === "ctrl-c") {
143
- const bufferEmpty = state.lines.length === 1 && state.lines[0] === "";
144
- if (bufferEmpty) {
145
- failWith(new AgentInterruptedError("Interrupted"));
146
- return true;
147
- }
148
- state.lines = [""];
149
- state.cursorLine = 0;
150
- state.cursorCol = 0;
151
- state.feedbackScrollTop = 0;
152
- state.mode = "input";
185
+ if (event.type === "ctrl-c" || event.type === "escape") {
186
+ cancelEdit();
153
187
  dirty = true;
154
188
  continue;
155
189
  }
156
- if (event.type === "ctrl-d") {
157
- if (state.mode === "approve") {
158
- finish({ accepted: true });
159
- return true;
160
- }
161
- const text = state.lines.join("\n").trim();
162
- if (text.length > 0) {
163
- submitFeedback(text);
164
- return true;
165
- }
166
- continue;
167
- }
168
- if (event.type === "enter") {
169
- if (state.mode === "approve") {
170
- finish({ accepted: true });
171
- return true;
172
- }
173
- dirty = applyFeedbackTextBufferEvent(state, { type: "newline" }) || dirty;
190
+ if (event.type === "ctrl-d" || event.type === "enter") {
191
+ submitEdit();
192
+ dirty = true;
174
193
  continue;
175
194
  }
176
195
  if (event.type === "ctrl-j") {
177
- if (state.mode === "input") {
178
- dirty = applyFeedbackTextBufferEvent(state, { type: "newline" }) || dirty;
179
- }
196
+ dirty = applyEditingBufferEvent(state.editing, { type: "newline" }) || dirty;
180
197
  continue;
181
198
  }
182
199
  if (event.type === "arrow") {
183
- if (event.direction === "up" && state.mode === "input") {
184
- state.mode = "approve";
185
- dirty = true;
186
- }
187
- else if (event.direction === "down" && state.mode === "approve") {
188
- state.mode = "input";
189
- dirty = true;
190
- }
191
- else if (state.mode === "input" &&
192
- (event.direction === "left" || event.direction === "right")) {
193
- dirty = applyFeedbackTextBufferEvent(state, {
200
+ if (event.direction === "left" || event.direction === "right") {
201
+ dirty = applyEditingBufferEvent(state.editing, {
194
202
  type: event.direction === "left" ? "ctrl-b" : "ctrl-f",
195
203
  }) || dirty;
196
204
  }
197
205
  continue;
198
206
  }
199
- if (event.type === "scroll-page") {
200
- const { planHeight, planLines } = planMetrics(plan, context, state, this.host);
201
- const step = Math.max(1, planHeight - PLAN_PAGE_OVERLAP);
202
- const max = Math.max(0, planLines - planHeight);
203
- if (event.direction === "up") {
204
- state.planScrollTop = Math.max(0, state.planScrollTop - step);
205
- }
206
- else {
207
- state.planScrollTop = Math.min(max, state.planScrollTop + step);
208
- }
209
- state.planAtEnd = state.planScrollTop >= max;
210
- dirty = true;
207
+ if (isTextBufferEvent(event)) {
208
+ dirty = applyEditingBufferEvent(state.editing, event) || dirty;
211
209
  continue;
212
210
  }
213
- if (event.type === "scroll-home") {
214
- state.planScrollTop = 0;
215
- state.planAtEnd = false;
211
+ continue;
212
+ }
213
+ if (event.type === "ctrl-c") {
214
+ failWith(new AgentInterruptedError("Interrupted"));
215
+ finished = true;
216
+ continue;
217
+ }
218
+ if (event.type === "ctrl-d") {
219
+ if (state.userAdded.length === 0) {
220
+ finish({ accepted: true });
221
+ }
222
+ else {
223
+ submitFeedback();
224
+ }
225
+ finished = true;
226
+ continue;
227
+ }
228
+ if (event.type === "arrow") {
229
+ if (event.direction === "up" || event.direction === "down") {
230
+ const total = navigableRows(state).length;
231
+ state.cursor = event.direction === "up"
232
+ ? (state.cursor - 1 + total) % total
233
+ : (state.cursor + 1) % total;
234
+ dirty = true;
235
+ }
236
+ continue;
237
+ }
238
+ if (event.type === "scroll-page") {
239
+ const { planHeight, planLines } = planMetrics(plan, context, state, this.host);
240
+ const step = Math.max(1, planHeight - PLAN_PAGE_OVERLAP);
241
+ const max = Math.max(0, planLines - planHeight);
242
+ if (event.direction === "up") {
243
+ state.planScrollTop = Math.max(0, state.planScrollTop - step);
244
+ }
245
+ else {
246
+ state.planScrollTop = Math.min(max, state.planScrollTop + step);
247
+ }
248
+ state.planAtEnd = state.planScrollTop >= max;
249
+ dirty = true;
250
+ continue;
251
+ }
252
+ if (event.type === "scroll-home") {
253
+ state.planScrollTop = 0;
254
+ state.planAtEnd = false;
255
+ dirty = true;
256
+ continue;
257
+ }
258
+ if (event.type === "scroll-end") {
259
+ const { planHeight, planLines } = planMetrics(plan, context, state, this.host);
260
+ state.planScrollTop = Math.max(0, planLines - planHeight);
261
+ state.planAtEnd = true;
262
+ dirty = true;
263
+ continue;
264
+ }
265
+ if (event.type === "scroll-lines") {
266
+ const { planHeight, planLines } = planMetrics(plan, context, state, this.host);
267
+ const max = Math.max(0, planLines - planHeight);
268
+ state.planScrollTop = Math.min(max, Math.max(0, state.planScrollTop + event.delta));
269
+ state.planAtEnd = state.planScrollTop >= max;
270
+ dirty = true;
271
+ continue;
272
+ }
273
+ if (event.type === "enter") {
274
+ const row = currentRow(state);
275
+ if (row.kind === "approve") {
276
+ finish({ accepted: true });
277
+ finished = true;
278
+ }
279
+ else if (row.kind === "item") {
280
+ openUserEditEditor(row.index);
216
281
  dirty = true;
217
- continue;
218
282
  }
219
- if (event.type === "scroll-end") {
220
- const { planHeight, planLines } = planMetrics(plan, context, state, this.host);
221
- state.planScrollTop = Math.max(0, planLines - planHeight);
222
- state.planAtEnd = true;
283
+ else {
284
+ openUserAddEditor();
223
285
  dirty = true;
224
- continue;
225
286
  }
226
- if (event.type === "scroll-lines") {
227
- const { planHeight, planLines } = planMetrics(plan, context, state, this.host);
228
- const max = Math.max(0, planLines - planHeight);
229
- state.planScrollTop = Math.min(max, Math.max(0, state.planScrollTop + event.delta));
230
- state.planAtEnd = state.planScrollTop >= max;
287
+ continue;
288
+ }
289
+ if (event.type === "insert") {
290
+ const ch = event.text.toLowerCase();
291
+ if (ch === "a") {
292
+ openUserAddEditor();
231
293
  dirty = true;
232
294
  continue;
233
295
  }
234
- if (isTextBufferEvent(event)) {
235
- if (state.mode === "approve") {
236
- if (event.type === "insert") {
237
- state.mode = "input";
238
- applyFeedbackTextBufferEvent(state, event);
239
- dirty = true;
240
- }
241
- else if (event.type === "backspace") {
242
- state.mode = "input";
243
- dirty = true;
244
- }
245
- continue;
296
+ if (ch === "d") {
297
+ const row = currentRow(state);
298
+ if (row.kind === "item") {
299
+ state.userAdded.splice(row.index, 1);
300
+ clampCursor(state);
301
+ state.cursor = addRowCursorIndex(state);
302
+ dirty = true;
246
303
  }
247
- dirty = applyFeedbackTextBufferEvent(state, event) || dirty;
248
- continue;
249
304
  }
250
- // tab/escape/dump ignored — dump is consumed by the host router.
251
305
  }
252
- if (dirty)
253
- this.host.scheduleParentUiRender();
306
+ }
307
+ if (dirty && !finished)
308
+ this.host.scheduleParentUiRender();
309
+ return finished;
310
+ };
311
+ const baseMode = {
312
+ getSurface: () => {
313
+ const layout = this.host.getLayout();
314
+ const cols = Math.max(20, layout.columns);
315
+ const rows = Math.max(1, layout.childRows);
316
+ return buildSurface(plan, context, state, cols, rows, offscreenController);
317
+ },
318
+ onEvents: (keys) => {
319
+ processEvents(parser.mapKeys(keys));
254
320
  return true;
255
321
  },
256
322
  };
@@ -263,161 +329,200 @@ export class HostBackedUserPrompt {
263
329
  });
264
330
  }
265
331
  }
266
- function normalizeFeedbackCursor(state) {
267
- if (state.lines.length === 0)
268
- state.lines = [""];
269
- state.cursorLine = Math.max(0, Math.min(state.cursorLine, state.lines.length - 1));
270
- state.cursorCol = Math.max(0, Math.min(state.cursorCol, state.lines[state.cursorLine].length));
332
+ function navigableRows(state) {
333
+ if (state.userAdded.length === 0) {
334
+ return [{ kind: "approve" }, { kind: "add" }];
335
+ }
336
+ return [
337
+ ...state.userAdded.map((_, index) => ({ kind: "item", index })),
338
+ { kind: "add" },
339
+ ];
271
340
  }
272
- function applyFeedbackTextBufferEvent(state, event) {
273
- normalizeFeedbackCursor(state);
274
- const buffer = {
275
- lines: state.lines,
276
- cursorRow: state.cursorLine,
277
- cursorCol: state.cursorCol,
278
- };
279
- const changed = applyTextBufferEvent(buffer, event);
280
- state.lines = buffer.lines;
281
- state.cursorLine = buffer.cursorRow;
282
- state.cursorCol = buffer.cursorCol;
283
- normalizeFeedbackCursor(state);
284
- return changed;
341
+ function currentRow(state) {
342
+ clampCursor(state);
343
+ return navigableRows(state)[state.cursor] ?? { kind: "approve" };
285
344
  }
286
- /**
287
- * Build the natural-height controls block (no padding):
288
- * ❯ Approve (Enter) — when state.mode === "approve"
289
- * _ — feedback editor (1..5 rows; placeholder when empty)
290
- * Ctrl+D Submit — hint, only when input mode and editor non-empty
291
- *
292
- * Returns its natural height (1..7 rows). Cursor is pane-local.
293
- */
294
- function buildControlsBlock(state, width, offscreenHint) {
295
- const activePrefixWidth = visibleLength(ACTIVE_PREFIX);
296
- const inactivePrefixWidth = visibleLength(INACTIVE_PREFIX);
297
- const bodyWidth = Math.max(1, width - inactivePrefixWidth);
298
- const approveRow = state.mode === "approve"
299
- ? `${ACTIVE_PREFIX}${bold}${green}Approve (Enter)${reset}`
300
- : `${INACTIVE_PREFIX}${dim}Approve${reset}`;
301
- const showPlaceholder = state.lines.length === 1 && state.lines[0].length === 0;
302
- const hasFeedback = state.lines.some((line) => line.length > 0);
303
- const visualRows = [];
304
- let cursorVisualRow = 0;
305
- let cursorVisCol = 0;
306
- if (!showPlaceholder) {
307
- const cursorLine = Math.min(Math.max(0, state.cursorLine), state.lines.length - 1);
308
- for (let logIdx = 0; logIdx < state.lines.length; logIdx++) {
309
- const line = state.lines[logIdx] ?? "";
310
- const segments = softWrapLine(line, bodyWidth);
311
- if (logIdx < cursorLine) {
312
- cursorVisualRow += segments.length;
313
- }
314
- else if (logIdx === cursorLine) {
315
- const cursor = cursorToVisual(segments, state.cursorCol, line.length, bodyWidth);
316
- cursorVisualRow += cursor.subRow;
317
- cursorVisCol = cursor.visCol;
318
- }
319
- const rendered = renderWrappedLine(line, bodyWidth, state.mode === "input" && logIdx === cursorLine ? state.cursorCol : null);
320
- for (let subRow = 0; subRow < rendered.length; subRow++) {
321
- visualRows.push({ logIdx, subRow, body: rendered[subRow] });
322
- }
323
- }
324
- }
325
- const unboundedEditor = showPlaceholder ? 1 : visualRows.length;
326
- const editorHeight = Math.min(FEEDBACK_EDITOR_MAX_ROWS, Math.max(1, unboundedEditor));
327
- if (showPlaceholder) {
328
- state.feedbackScrollTop = 0;
345
+ function addRowCursorIndex(state) {
346
+ return state.userAdded.length === 0 ? 1 : state.userAdded.length;
347
+ }
348
+ function clampCursor(state) {
349
+ const max = Math.max(0, navigableRows(state).length - 1);
350
+ state.cursor = Math.max(0, Math.min(state.cursor, max));
351
+ }
352
+ // Natural (unbounded) height of the feedback-list block at the given render
353
+ // width - the dim/active Approve row + every item row (incl. inline editor) +
354
+ // the +Add row. Excludes the separator/hint chrome. Used by `planMetrics` to
355
+ // size the plan area on scroll events.
356
+ function measureFeedbackListRows(state, width) {
357
+ return buildFeedbackListRows(state, width).lines.length;
358
+ }
359
+ function buildFeedbackListRows(state, width) {
360
+ clampCursor(state);
361
+ const lines = [];
362
+ const rowStarts = [];
363
+ const rowEnds = [];
364
+ let editorRow;
365
+ let editorCol = 0;
366
+ if (state.userAdded.length === 0) {
367
+ const active = state.cursor === 0;
368
+ rowStarts[0] = lines.length;
369
+ lines.push(clipToWidth(active
370
+ ? `${ACTIVE_SELECTOR}${bold}${green}✓ Approve (Enter)${reset}`
371
+ : ` ${dim}✓ Approve${reset}`, width));
372
+ rowEnds[0] = lines.length;
329
373
  }
330
374
  else {
331
- if (cursorVisualRow < state.feedbackScrollTop) {
332
- state.feedbackScrollTop = cursorVisualRow;
375
+ lines.push(clipToWidth(` ${dim}✓ Approve${reset}`, width));
376
+ }
377
+ for (let i = 0; i < state.userAdded.length; i++) {
378
+ const isCursor = state.cursor === i;
379
+ const leadChar = isCursor ? ACTIVE_SELECTOR : " ";
380
+ const head = `${leadChar} ${i + 1}. `;
381
+ const headWidth = visibleLength(head);
382
+ const indent = " ".repeat(headWidth);
383
+ const avail = Math.max(1, width - headWidth);
384
+ rowStarts[i] = lines.length;
385
+ if (state.editing?.target.kind === "user-edit" &&
386
+ state.editing.target.index === i) {
387
+ const renderedEditor = renderMultilineEditor(state.editing.buffer, width, head, indent);
388
+ editorRow = lines.length + renderedEditor.cursorRowOffset;
389
+ editorCol = renderedEditor.cursorCol;
390
+ lines.push(...renderedEditor.lines);
333
391
  }
334
- else if (cursorVisualRow >= state.feedbackScrollTop + editorHeight) {
335
- state.feedbackScrollTop = cursorVisualRow - editorHeight + 1;
392
+ else {
393
+ const wrapped = wrapToWidth(state.userAdded[i] ?? "", avail);
394
+ lines.push(clipToWidth(`${head}${wrapped[0] ?? ""}`, width));
395
+ for (let j = 1; j < wrapped.length; j++) {
396
+ lines.push(clipToWidth(`${indent}${wrapped[j]}`, width));
397
+ }
336
398
  }
337
- const maxScroll = Math.max(0, visualRows.length - editorHeight);
338
- if (state.feedbackScrollTop > maxScroll)
339
- state.feedbackScrollTop = maxScroll;
340
- if (state.feedbackScrollTop < 0)
341
- state.feedbackScrollTop = 0;
399
+ rowEnds[i] = lines.length;
342
400
  }
343
- const placeholder = "Type feedback";
344
- const editorBody = [];
345
- if (showPlaceholder) {
346
- let first;
347
- if (state.mode === "input") {
348
- const head = placeholder[0] ?? " ";
349
- const tail = placeholder.slice(1);
350
- first = `${ACTIVE_PREFIX}${dim}${invert}${head}${reset}${dim}${tail}${reset}`;
401
+ {
402
+ const rowIndex = addRowCursorIndex(state);
403
+ const isCursor = state.cursor === rowIndex;
404
+ rowStarts[rowIndex] = lines.length;
405
+ if (state.editing?.target.kind === "user-add") {
406
+ const head = `${ACTIVE_SELECTOR} ${state.userAdded.length + 1}. `;
407
+ const headWidth = visibleLength(head);
408
+ const renderedEditor = renderMultilineEditor(state.editing.buffer, width, head, " ".repeat(headWidth));
409
+ editorRow = lines.length + renderedEditor.cursorRowOffset;
410
+ editorCol = renderedEditor.cursorCol;
411
+ lines.push(...renderedEditor.lines);
351
412
  }
352
413
  else {
353
- first = `${INACTIVE_PREFIX}${dim}${placeholder}${reset}`;
414
+ lines.push(clipToWidth(isCursor
415
+ ? `${ACTIVE_SELECTOR}${bold}${green}+ Add feedback (Enter)${reset}`
416
+ : ` ${dim}+ Add feedback${reset}`, width));
354
417
  }
355
- editorBody.push(first);
356
- while (editorBody.length < editorHeight)
357
- editorBody.push("");
418
+ rowEnds[rowIndex] = lines.length;
358
419
  }
359
- else {
360
- for (let i = 0; i < editorHeight; i++) {
361
- const visual = visualRows[state.feedbackScrollTop + i];
362
- if (!visual) {
363
- editorBody.push("");
364
- continue;
420
+ return {
421
+ lines,
422
+ rowStarts,
423
+ rowEnds,
424
+ editorRow,
425
+ editorCol,
426
+ };
427
+ }
428
+ function buildFeedbackPane(state, rendered, width, listRows, hintTokens) {
429
+ const scrollRows = Math.max(0, listRows);
430
+ const activeStart = rendered.rowStarts[state.cursor] ?? 0;
431
+ const activeEndExclusive = rendered.rowEnds[state.cursor] ?? rendered.lines.length;
432
+ let scrollTop = 0;
433
+ const total = rendered.lines.length;
434
+ if (scrollRows > 0 && total > scrollRows) {
435
+ scrollTop = activeStart;
436
+ if (activeEndExclusive - scrollTop < scrollRows) {
437
+ scrollTop = Math.max(0, total - scrollRows);
438
+ if (scrollTop > activeStart)
439
+ scrollTop = activeStart;
440
+ }
441
+ if (rendered.editorRow !== undefined) {
442
+ if (rendered.editorRow < scrollTop)
443
+ scrollTop = rendered.editorRow;
444
+ if (rendered.editorRow >= scrollTop + scrollRows) {
445
+ scrollTop = rendered.editorRow - scrollRows + 1;
365
446
  }
366
- const isFirstUnderlying = visual.logIdx === 0 && visual.subRow === 0;
367
- const prefix = isFirstUnderlying && state.mode === "input"
368
- ? ACTIVE_PREFIX
369
- : INACTIVE_PREFIX;
370
- editorBody.push(`${prefix}${visual.body}`);
371
447
  }
448
+ if (scrollTop < 0)
449
+ scrollTop = 0;
450
+ if (scrollTop + scrollRows > total)
451
+ scrollTop = total - scrollRows;
372
452
  }
373
- const tokens = [];
374
- if (state.mode === "input")
375
- tokens.push("Ctrl+J Newline");
376
- if (hasFeedback)
377
- tokens.push("Ctrl+D Submit");
378
- if (offscreenHint)
379
- tokens.push(offscreenHint);
380
- tokens.push(ALT_V_PASTE_IMAGE_HINT);
381
- if (state.transientHint)
382
- tokens.push(`${yellow}${state.transientHint}${reset}`);
383
- const lines = [
384
- approveRow,
385
- ...editorBody,
386
- `${dim}${"─".repeat(width)}${reset}`,
387
- `${INACTIVE_PREFIX}${renderShortcutHints(tokens)}`,
388
- ];
389
- const approveRowInBlock = 0;
390
- const editorStart = 1;
453
+ const listLines = scrollRows === 0
454
+ ? []
455
+ : rendered.lines.slice(scrollTop, scrollTop + scrollRows);
456
+ while (listLines.length < scrollRows)
457
+ listLines.push("");
391
458
  let cursorRow;
392
459
  let cursorCol;
393
- if (state.mode === "approve") {
394
- cursorRow = approveRowInBlock;
395
- cursorCol = activePrefixWidth;
396
- }
397
- else if (showPlaceholder) {
398
- cursorRow = editorStart;
399
- cursorCol = activePrefixWidth;
460
+ if (rendered.editorRow !== undefined) {
461
+ cursorRow = rendered.editorRow - scrollTop;
462
+ cursorCol = rendered.editorCol;
400
463
  }
401
464
  else {
402
- const offset = Math.min(Math.max(0, cursorVisualRow - state.feedbackScrollTop), editorHeight - 1);
403
- cursorRow = editorStart + offset;
404
- const cursorLine = Math.min(Math.max(0, state.cursorLine), state.lines.length - 1);
405
- const isFirstUnderlying = cursorLine === 0 && cursorVisualRow === 0;
406
- const prefixLen = isFirstUnderlying ? activePrefixWidth : inactivePrefixWidth;
407
- cursorCol = prefixLen + cursorVisCol;
465
+ cursorRow = activeStart - scrollTop;
466
+ cursorCol = currentRow(state).kind === "approve" ? visibleLength(ACTIVE_SELECTOR) : 0;
408
467
  }
409
- if (cursorRow < 0)
468
+ if (scrollRows === 0) {
410
469
  cursorRow = 0;
411
- if (cursorCol < 0)
412
470
  cursorCol = 0;
471
+ }
472
+ else {
473
+ if (cursorRow < 0)
474
+ cursorRow = 0;
475
+ if (cursorRow >= scrollRows)
476
+ cursorRow = scrollRows - 1;
477
+ if (cursorCol < 0)
478
+ cursorCol = 0;
479
+ if (cursorCol >= width)
480
+ cursorCol = Math.max(0, width - 1);
481
+ }
482
+ const lines = [
483
+ ...listLines,
484
+ `${dim}${"─".repeat(width)}${reset}`,
485
+ `${INACTIVE_PREFIX}${renderShortcutHints(hintTokens)}`,
486
+ ];
413
487
  return {
414
- lines: lines.map((l) => clipToWidth(l, width)),
488
+ lines: lines.map((line) => clipToWidth(line, width)),
415
489
  cursor: { row: cursorRow, col: cursorCol },
416
490
  };
417
491
  }
492
+ export function promptHelpTokens(state, offscreenHint, summarizeHint) {
493
+ if (state.editing) {
494
+ const tokens = ["Ctrl+J Newline", "Enter Submit", "Esc Cancel", ALT_V_PASTE_IMAGE_HINT];
495
+ if (state.editing.transientHint) {
496
+ tokens.push(`${yellow}${state.editing.transientHint}${reset}`);
497
+ }
498
+ if (offscreenHint)
499
+ tokens.push(offscreenHint);
500
+ if (summarizeHint)
501
+ tokens.push(summarizeHint);
502
+ return tokens;
503
+ }
504
+ const row = currentRow(state);
505
+ const tokens = [];
506
+ if (row.kind === "item") {
507
+ tokens.push("Enter Edit", "D Delete", "A Add");
508
+ }
509
+ else if (row.kind === "add") {
510
+ tokens.push("Enter/A Add");
511
+ }
512
+ else {
513
+ tokens.push("Enter Approve", "A Add");
514
+ }
515
+ tokens.push(state.userAdded.length === 0 ? "Ctrl+D Approve" : "Ctrl+D Submit");
516
+ tokens.push("Ctrl+C Quit");
517
+ if (offscreenHint)
518
+ tokens.push(offscreenHint);
519
+ if (summarizeHint)
520
+ tokens.push(summarizeHint);
521
+ return tokens;
522
+ }
418
523
  /**
419
- * Compute the plan area's available height given total `rows` and the natural
420
- * controls block height. Reserves 1 row for the dim ─ separator.
524
+ * Compute the plan area's available height given total `rows` and the controls
525
+ * block height. Reserves 1 row for the dim separator between plan and controls.
421
526
  */
422
527
  function computePlanRows(controlsHeight, rows) {
423
528
  return Math.max(1, rows - controlsHeight - 1);
@@ -426,8 +531,6 @@ function buildPlanLines(plan, context, state, width, planRows) {
426
531
  const pane = buildPlanPane(plan, context.phaseLabel, width, context.artifactKind ?? "plan");
427
532
  const total = pane.lines.length;
428
533
  const maxScroll = Math.max(0, total - planRows);
429
- // Re-pin to the new bottom when the user last scrolled to End and the plan
430
- // window has since shrunk (e.g. the controls block grew while typing).
431
534
  if (state.planAtEnd)
432
535
  state.planScrollTop = maxScroll;
433
536
  if (state.planScrollTop > maxScroll)
@@ -438,12 +541,25 @@ function buildPlanLines(plan, context, state, width, planRows) {
438
541
  while (sliced.length < planRows)
439
542
  sliced.push("");
440
543
  return {
441
- lines: sliced.map((l) => clipToWidth(l, width)),
544
+ lines: sliced.map((line) => clipToWidth(line, width)),
442
545
  total,
443
546
  };
444
547
  }
445
- function buildSurface(plan, context, state, cols, rows) {
446
- const controls = buildControlsBlock(state, cols, primaryOffscreenHint(context));
548
+ function buildSurface(plan, context, state, cols, rows, offscreenController) {
549
+ const split = computeSplitMetrics({ cols, rows, collapseAt: SPLIT_COLLAPSE_AT_COLS });
550
+ const natural = buildFeedbackListRows(state, cols);
551
+ const naturalRows = natural.lines.length;
552
+ const offscreenHint = primaryOffscreenHint(context);
553
+ const summarizeHint = primarySummarizeHint(context, offscreenController);
554
+ const hintTokens = promptHelpTokens(state, offscreenHint, summarizeHint);
555
+ if (!split.collapsed && naturalRows > FEEDBACK_AREA_MAX_ROWS) {
556
+ const leftRender = buildFeedbackListRows(state, split.leftWidth);
557
+ const left = buildFeedbackPane(state, leftRender, split.leftWidth, Math.min(leftRender.lines.length, Math.max(0, rows - 2)), hintTokens);
558
+ const { lines: rightLines } = buildPlanLines(plan, context, state, split.rightWidth, rows);
559
+ return buildSplitSurface({ lines: left.lines, cursor: left.cursor }, { lines: rightLines }, { cols, rows, collapseAt: SPLIT_COLLAPSE_AT_COLS }, "left");
560
+ }
561
+ const listRows = Math.min(naturalRows, FEEDBACK_AREA_MAX_ROWS);
562
+ const controls = buildFeedbackPane(state, natural, cols, listRows, hintTokens);
447
563
  const planRows = computePlanRows(controls.lines.length, rows);
448
564
  const { lines: planLines } = buildPlanLines(plan, context, state, cols, planRows);
449
565
  const separator = `${dim}${"─".repeat(cols)}${reset}`;
@@ -452,16 +568,11 @@ function buildSurface(plan, context, state, cols, rows) {
452
568
  separator,
453
569
  ...controls.lines,
454
570
  ];
455
- // Pad / clip to exactly `rows` rows. The plan area is the only flex region;
456
- // controls + separator + margins remain anchored to the bottom.
457
571
  while (composed.length < rows)
458
572
  composed.push("");
459
573
  if (composed.length > rows) {
460
- // Clip from the top of the plan area; never clip controls.
461
574
  composed.splice(0, composed.length - rows);
462
575
  }
463
- // Cursor row sits in the controls block: planRows + 1 (separator) +
464
- // controls.cursor.row.
465
576
  let cursorRow = planRows + 1 + controls.cursor.row;
466
577
  if (cursorRow < 0)
467
578
  cursorRow = 0;
@@ -476,14 +587,26 @@ function planMetrics(plan, context, state, host) {
476
587
  const layout = host.getLayout();
477
588
  const cols = Math.max(20, layout.columns);
478
589
  const rows = Math.max(1, layout.childRows);
479
- const controls = buildControlsBlock(state, cols, primaryOffscreenHint(context));
480
- const planHeight = computePlanRows(controls.lines.length, rows);
590
+ const split = computeSplitMetrics({ cols, rows, collapseAt: SPLIT_COLLAPSE_AT_COLS });
591
+ const naturalRows = measureFeedbackListRows(state, cols);
592
+ if (!split.collapsed && naturalRows > FEEDBACK_AREA_MAX_ROWS) {
593
+ const pane = buildPlanPane(plan, context.phaseLabel, split.rightWidth, context.artifactKind ?? "plan");
594
+ return { planHeight: rows, planLines: pane.lines.length };
595
+ }
596
+ const controlsHeight = Math.min(naturalRows, FEEDBACK_AREA_MAX_ROWS) + 2;
597
+ const planHeight = computePlanRows(controlsHeight, rows);
481
598
  const pane = buildPlanPane(plan, context.phaseLabel, cols, context.artifactKind ?? "plan");
482
599
  return { planHeight, planLines: pane.lines.length };
483
600
  }
484
601
  function primaryOffscreenHint(context) {
485
602
  return context.offscreen?.slots.find((slot) => slot.binding === "primary")?.hint;
486
603
  }
604
+ function primarySummarizeHint(context, controller) {
605
+ const hint = context.offscreen?.slots.find((slot) => slot.binding === "primary")?.summarizeHint;
606
+ if (!hint)
607
+ return undefined;
608
+ return controller?.isIssueInquiryReady("primary") === false ? `${dim}${hint}${reset}` : hint;
609
+ }
487
610
  function reviewArtifactPaneConfig(plan, context) {
488
611
  return {
489
612
  inquirySide: "left",