poly-weaver 0.11.4 → 0.11.5

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 (64) 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/flow/executor.d.ts +2 -2
  13. package/dist/flow/executor.d.ts.map +1 -1
  14. package/dist/flow/executor.js +30 -7
  15. package/dist/flow/executor.js.map +1 -1
  16. package/dist/flow-editor/tui.d.ts.map +1 -1
  17. package/dist/flow-editor/tui.js +4 -4
  18. package/dist/flow-editor/tui.js.map +1 -1
  19. package/dist/providers/claude/completion-plan-mode.d.ts +3 -2
  20. package/dist/providers/claude/completion-plan-mode.d.ts.map +1 -1
  21. package/dist/providers/claude/completion-plan-mode.js +13 -11
  22. package/dist/providers/claude/completion-plan-mode.js.map +1 -1
  23. package/dist/providers/claude/plan-mode-pty-monitor.d.ts +9 -6
  24. package/dist/providers/claude/plan-mode-pty-monitor.d.ts.map +1 -1
  25. package/dist/providers/claude/plan-mode-pty-monitor.js +15 -6
  26. package/dist/providers/claude/plan-mode-pty-monitor.js.map +1 -1
  27. package/dist/resume-tui.d.ts +1 -1
  28. package/dist/resume-tui.d.ts.map +1 -1
  29. package/dist/resume-tui.js +3 -1
  30. package/dist/resume-tui.js.map +1 -1
  31. package/dist/session/manifest.d.ts +3 -1
  32. package/dist/session/manifest.d.ts.map +1 -1
  33. package/dist/session/restore.d.ts.map +1 -1
  34. package/dist/session/restore.js +2 -1
  35. package/dist/session/restore.js.map +1 -1
  36. package/dist/session/session-store.d.ts +8 -0
  37. package/dist/session/session-store.d.ts.map +1 -1
  38. package/dist/session/session-store.js +18 -1
  39. package/dist/session/session-store.js.map +1 -1
  40. package/dist/startup-tui.d.ts.map +1 -1
  41. package/dist/startup-tui.js +2 -2
  42. package/dist/startup-tui.js.map +1 -1
  43. package/dist/text-editing/emacs-input.d.ts +5 -0
  44. package/dist/text-editing/emacs-input.d.ts.map +1 -1
  45. package/dist/text-editing/emacs-input.js +11 -0
  46. package/dist/text-editing/emacs-input.js.map +1 -1
  47. package/dist/user/feedback-editing.d.ts +28 -0
  48. package/dist/user/feedback-editing.d.ts.map +1 -0
  49. package/dist/user/feedback-editing.js +72 -0
  50. package/dist/user/feedback-editing.js.map +1 -0
  51. package/dist/user/handler.d.ts.map +1 -1
  52. package/dist/user/handler.js +3 -2
  53. package/dist/user/handler.js.map +1 -1
  54. package/dist/user/host-curate-prompt.d.ts +2 -6
  55. package/dist/user/host-curate-prompt.d.ts.map +1 -1
  56. package/dist/user/host-curate-prompt.js +10 -74
  57. package/dist/user/host-curate-prompt.js.map +1 -1
  58. package/dist/user/host-prompt.d.ts +4 -13
  59. package/dist/user/host-prompt.d.ts.map +1 -1
  60. package/dist/user/host-prompt.js +407 -302
  61. package/dist/user/host-prompt.js.map +1 -1
  62. package/dist/user/prompt.d.ts +1 -1
  63. package/dist/user/prompt.d.ts.map +1 -1
  64. package/package.json +1 -1
@@ -1,35 +1,28 @@
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.
21
- *
22
- * Plan on top (header rule + scrollable body), full-width dim ─ separator, and
23
- * a controls block (Approve + bounded feedback editor + compact hint row).
24
- *
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.
23
+ * Host-backed user-review prompt. The review controls use the same multi-item
24
+ * feedback model as verdict curation: add, edit, delete, then submit the list
25
+ * as discrete feedback items.
33
26
  */
34
27
  export class HostBackedUserPrompt {
35
28
  host;
@@ -40,23 +33,19 @@ export class HostBackedUserPrompt {
40
33
  if (!this.host.isInteractive) {
41
34
  return { accepted: true };
42
35
  }
36
+ const state = {
37
+ cursor: 1,
38
+ userAdded: [],
39
+ editing: undefined,
40
+ planScrollTop: 0,
41
+ planAtEnd: false,
42
+ };
43
+ const parser = new EmacsInputParser();
43
44
  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
45
  let detach;
57
46
  let offscreenController;
58
47
  let active = true;
59
- let settling = false;
48
+ let settlingEdit = false;
60
49
  const cleanup = () => {
61
50
  active = false;
62
51
  offscreenController?.dispose();
@@ -72,185 +61,255 @@ export class HostBackedUserPrompt {
72
61
  cleanup();
73
62
  reject(err);
74
63
  };
75
- const finalizeFeedbackText = async (text) => {
76
- const targetDir = context.persistentDir
77
- ? await ensurePersistentImagesDir(context.persistentDir)
78
- : undefined;
79
- return attachments.finalize(text, targetDir ? { targetDir } : {});
64
+ const submitFeedback = () => {
65
+ finish({ accepted: false, feedback: [...state.userAdded] });
66
+ };
67
+ const cancelEdit = () => {
68
+ if (settlingEdit || !state.editing)
69
+ return;
70
+ state.editing = undefined;
71
+ clampCursor(state);
72
+ };
73
+ const applySubmittedEdit = (editing, text) => {
74
+ state.editing = undefined;
75
+ if (editing.target.kind === "user-edit") {
76
+ const index = editing.target.index;
77
+ if (text.length > 0) {
78
+ state.userAdded[index] = text;
79
+ }
80
+ else {
81
+ state.userAdded.splice(index, 1);
82
+ }
83
+ }
84
+ else if (text.length > 0) {
85
+ state.userAdded.push(text);
86
+ }
87
+ state.cursor = addRowCursorIndex(state);
88
+ clampCursor(state);
80
89
  };
81
- const submitFeedback = (text) => {
82
- settling = true;
83
- void finalizeFeedbackText(text).then((comments) => {
84
- finish({ accepted: false, comments });
90
+ const submitEdit = () => {
91
+ const editing = state.editing;
92
+ if (!editing || settlingEdit)
93
+ return;
94
+ const text = editing.buffer.lines.join("\n").trim();
95
+ if (editing.attachments.snapshot().length === 0) {
96
+ applySubmittedEdit(editing, text);
97
+ return;
98
+ }
99
+ settlingEdit = true;
100
+ void finalizeEditText(editing, text, context.persistentDir).then((finalText) => {
101
+ if (!active || state.editing !== editing)
102
+ return;
103
+ applySubmittedEdit(editing, finalText);
104
+ settlingEdit = false;
105
+ this.host.scheduleParentUiRender();
85
106
  }).catch((err) => {
107
+ settlingEdit = false;
86
108
  failWith(err instanceof Error ? err : new Error(String(err)));
87
109
  });
88
110
  };
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
- }
111
+ const openUserAddEditor = () => {
112
+ state.cursor = addRowCursorIndex(state);
113
+ state.editing = {
114
+ target: { kind: "user-add" },
115
+ buffer: emptyBuffer(),
116
+ attachments: new AttachmentManager(),
117
+ transientHint: null,
118
+ };
95
119
  };
96
- const applyPasteOutcome = (outcome) => {
97
- if (!active || settling)
120
+ const openUserEditEditor = (index) => {
121
+ state.cursor = index;
122
+ state.editing = {
123
+ target: { kind: "user-edit", index },
124
+ buffer: bufferFromText(state.userAdded[index] ?? ""),
125
+ attachments: new AttachmentManager(),
126
+ transientHint: null,
127
+ };
128
+ };
129
+ const applyEditPasteOutcome = (editing, outcome) => {
130
+ if (!active || settlingEdit || state.editing !== editing)
98
131
  return;
99
- state.transientHint = outcome.hint ?? null;
132
+ editing.transientHint = outcome.hint ?? null;
100
133
  if (outcome.inserted !== undefined)
101
- applyInsertedText(outcome.inserted);
134
+ applyEditInsertedText(editing, outcome.inserted);
102
135
  this.host.scheduleParentUiRender();
103
136
  };
104
- const launchPasteIntent = (task) => {
105
- void task.then(applyPasteOutcome).catch((err) => {
106
- if (!active || settling)
137
+ const launchEditPasteIntent = (editing, task) => {
138
+ void task.then((outcome) => applyEditPasteOutcome(editing, outcome)).catch((err) => {
139
+ if (!active || settlingEdit || state.editing !== editing)
107
140
  return;
108
- state.transientHint = err instanceof Error ? err.message : String(err);
141
+ editing.transientHint = err instanceof Error ? err.message : String(err);
109
142
  this.host.scheduleParentUiRender();
110
143
  });
111
144
  };
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;
145
+ const processEvents = (events) => {
146
+ let dirty = false;
147
+ let finished = false;
148
+ for (const rawEvent of events) {
149
+ if (finished)
150
+ break;
151
+ const event = state.editing
152
+ ? rawEvent
153
+ : aliasSelectionNavEvent(rawEvent);
154
+ if (state.editing) {
155
+ if (settlingEdit) {
156
+ dirty = true;
157
+ continue;
158
+ }
159
+ if (state.editing.transientHint) {
160
+ state.editing.transientHint = null;
127
161
  dirty = true;
128
162
  }
129
163
  if (event.type === "paste") {
130
- launchPasteIntent(handleBracketedPaste(event.content, attachments));
164
+ const editing = state.editing;
165
+ launchEditPasteIntent(editing, handleBracketedPaste(event.content, editing.attachments));
131
166
  continue;
132
167
  }
133
168
  if (event.type === "image-paste") {
134
- launchPasteIntent(handleImagePasteIntent(attachments, () => {
135
- if (!active || settling)
169
+ const editing = state.editing;
170
+ launchEditPasteIntent(editing, handleImagePasteIntent(editing.attachments, () => {
171
+ if (!active || settlingEdit || state.editing !== editing)
136
172
  return;
137
- state.transientHint = PASTING_IMAGE_HINT;
173
+ editing.transientHint = PASTING_IMAGE_HINT;
138
174
  this.host.scheduleParentUiRender();
139
175
  }));
140
176
  continue;
141
177
  }
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";
178
+ if (event.type === "ctrl-c" || event.type === "escape") {
179
+ cancelEdit();
153
180
  dirty = true;
154
181
  continue;
155
182
  }
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;
183
+ if (event.type === "ctrl-d" || event.type === "enter") {
184
+ submitEdit();
185
+ dirty = true;
174
186
  continue;
175
187
  }
176
188
  if (event.type === "ctrl-j") {
177
- if (state.mode === "input") {
178
- dirty = applyFeedbackTextBufferEvent(state, { type: "newline" }) || dirty;
179
- }
189
+ dirty = applyEditingBufferEvent(state.editing, { type: "newline" }) || dirty;
180
190
  continue;
181
191
  }
182
192
  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, {
193
+ if (event.direction === "left" || event.direction === "right") {
194
+ dirty = applyEditingBufferEvent(state.editing, {
194
195
  type: event.direction === "left" ? "ctrl-b" : "ctrl-f",
195
196
  }) || dirty;
196
197
  }
197
198
  continue;
198
199
  }
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;
200
+ if (isTextBufferEvent(event)) {
201
+ dirty = applyEditingBufferEvent(state.editing, event) || dirty;
211
202
  continue;
212
203
  }
213
- if (event.type === "scroll-home") {
214
- state.planScrollTop = 0;
215
- state.planAtEnd = false;
204
+ continue;
205
+ }
206
+ if (event.type === "ctrl-c") {
207
+ failWith(new AgentInterruptedError("Interrupted"));
208
+ finished = true;
209
+ continue;
210
+ }
211
+ if (event.type === "ctrl-d") {
212
+ if (state.userAdded.length === 0) {
213
+ finish({ accepted: true });
214
+ }
215
+ else {
216
+ submitFeedback();
217
+ }
218
+ finished = true;
219
+ continue;
220
+ }
221
+ if (event.type === "arrow") {
222
+ if (event.direction === "up" || event.direction === "down") {
223
+ const total = navigableRows(state).length;
224
+ state.cursor = event.direction === "up"
225
+ ? (state.cursor - 1 + total) % total
226
+ : (state.cursor + 1) % total;
216
227
  dirty = true;
217
- continue;
218
228
  }
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;
229
+ continue;
230
+ }
231
+ if (event.type === "scroll-page") {
232
+ const { planHeight, planLines } = planMetrics(plan, context, state, this.host);
233
+ const step = Math.max(1, planHeight - PLAN_PAGE_OVERLAP);
234
+ const max = Math.max(0, planLines - planHeight);
235
+ if (event.direction === "up") {
236
+ state.planScrollTop = Math.max(0, state.planScrollTop - step);
237
+ }
238
+ else {
239
+ state.planScrollTop = Math.min(max, state.planScrollTop + step);
240
+ }
241
+ state.planAtEnd = state.planScrollTop >= max;
242
+ dirty = true;
243
+ continue;
244
+ }
245
+ if (event.type === "scroll-home") {
246
+ state.planScrollTop = 0;
247
+ state.planAtEnd = false;
248
+ dirty = true;
249
+ continue;
250
+ }
251
+ if (event.type === "scroll-end") {
252
+ const { planHeight, planLines } = planMetrics(plan, context, state, this.host);
253
+ state.planScrollTop = Math.max(0, planLines - planHeight);
254
+ state.planAtEnd = true;
255
+ dirty = true;
256
+ continue;
257
+ }
258
+ if (event.type === "scroll-lines") {
259
+ const { planHeight, planLines } = planMetrics(plan, context, state, this.host);
260
+ const max = Math.max(0, planLines - planHeight);
261
+ state.planScrollTop = Math.min(max, Math.max(0, state.planScrollTop + event.delta));
262
+ state.planAtEnd = state.planScrollTop >= max;
263
+ dirty = true;
264
+ continue;
265
+ }
266
+ if (event.type === "enter") {
267
+ const row = currentRow(state);
268
+ if (row.kind === "approve") {
269
+ finish({ accepted: true });
270
+ finished = true;
271
+ }
272
+ else if (row.kind === "item") {
273
+ openUserEditEditor(row.index);
223
274
  dirty = true;
224
- continue;
225
275
  }
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;
276
+ else {
277
+ openUserAddEditor();
278
+ dirty = true;
279
+ }
280
+ continue;
281
+ }
282
+ if (event.type === "insert") {
283
+ const ch = event.text.toLowerCase();
284
+ if (ch === "a") {
285
+ openUserAddEditor();
231
286
  dirty = true;
232
287
  continue;
233
288
  }
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;
289
+ if (ch === "d") {
290
+ const row = currentRow(state);
291
+ if (row.kind === "item") {
292
+ state.userAdded.splice(row.index, 1);
293
+ clampCursor(state);
294
+ state.cursor = addRowCursorIndex(state);
295
+ dirty = true;
246
296
  }
247
- dirty = applyFeedbackTextBufferEvent(state, event) || dirty;
248
- continue;
249
297
  }
250
- // tab/escape/dump ignored — dump is consumed by the host router.
251
298
  }
252
- if (dirty)
253
- this.host.scheduleParentUiRender();
299
+ }
300
+ if (dirty && !finished)
301
+ this.host.scheduleParentUiRender();
302
+ return finished;
303
+ };
304
+ const baseMode = {
305
+ getSurface: () => {
306
+ const layout = this.host.getLayout();
307
+ const cols = Math.max(20, layout.columns);
308
+ const rows = Math.max(1, layout.childRows);
309
+ return buildSurface(plan, context, state, cols, rows);
310
+ },
311
+ onEvents: (keys) => {
312
+ processEvents(parser.mapKeys(keys));
254
313
  return true;
255
314
  },
256
315
  };
@@ -263,161 +322,196 @@ export class HostBackedUserPrompt {
263
322
  });
264
323
  }
265
324
  }
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));
325
+ function navigableRows(state) {
326
+ if (state.userAdded.length === 0) {
327
+ return [{ kind: "approve" }, { kind: "add" }];
328
+ }
329
+ return [
330
+ ...state.userAdded.map((_, index) => ({ kind: "item", index })),
331
+ { kind: "add" },
332
+ ];
333
+ }
334
+ function currentRow(state) {
335
+ clampCursor(state);
336
+ return navigableRows(state)[state.cursor] ?? { kind: "approve" };
271
337
  }
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;
338
+ function addRowCursorIndex(state) {
339
+ return state.userAdded.length === 0 ? 1 : state.userAdded.length;
285
340
  }
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;
341
+ function clampCursor(state) {
342
+ const max = Math.max(0, navigableRows(state).length - 1);
343
+ state.cursor = Math.max(0, Math.min(state.cursor, max));
344
+ }
345
+ // Natural (unbounded) height of the feedback-list block at the given render
346
+ // width - the dim/active Approve row + every item row (incl. inline editor) +
347
+ // the +Add row. Excludes the separator/hint chrome. Used by `planMetrics` to
348
+ // size the plan area on scroll events.
349
+ function measureFeedbackListRows(state, width) {
350
+ return buildFeedbackListRows(state, width).lines.length;
351
+ }
352
+ function buildFeedbackListRows(state, width) {
353
+ clampCursor(state);
354
+ const lines = [];
355
+ const rowStarts = [];
356
+ const rowEnds = [];
357
+ let editorRow;
358
+ let editorCol = 0;
359
+ if (state.userAdded.length === 0) {
360
+ const active = state.cursor === 0;
361
+ rowStarts[0] = lines.length;
362
+ lines.push(clipToWidth(active
363
+ ? `${ACTIVE_SELECTOR}${bold}${green}✓ Approve (Enter)${reset}`
364
+ : ` ${dim}✓ Approve${reset}`, width));
365
+ rowEnds[0] = lines.length;
329
366
  }
330
367
  else {
331
- if (cursorVisualRow < state.feedbackScrollTop) {
332
- state.feedbackScrollTop = cursorVisualRow;
368
+ lines.push(clipToWidth(` ${dim}✓ Approve${reset}`, width));
369
+ }
370
+ for (let i = 0; i < state.userAdded.length; i++) {
371
+ const isCursor = state.cursor === i;
372
+ const leadChar = isCursor ? ACTIVE_SELECTOR : " ";
373
+ const head = `${leadChar} ${i + 1}. `;
374
+ const headWidth = visibleLength(head);
375
+ const indent = " ".repeat(headWidth);
376
+ const avail = Math.max(1, width - headWidth);
377
+ rowStarts[i] = lines.length;
378
+ if (state.editing?.target.kind === "user-edit" &&
379
+ state.editing.target.index === i) {
380
+ const renderedEditor = renderMultilineEditor(state.editing.buffer, width, head, indent);
381
+ editorRow = lines.length + renderedEditor.cursorRowOffset;
382
+ editorCol = renderedEditor.cursorCol;
383
+ lines.push(...renderedEditor.lines);
333
384
  }
334
- else if (cursorVisualRow >= state.feedbackScrollTop + editorHeight) {
335
- state.feedbackScrollTop = cursorVisualRow - editorHeight + 1;
385
+ else {
386
+ const wrapped = wrapToWidth(state.userAdded[i] ?? "", avail);
387
+ lines.push(clipToWidth(`${head}${wrapped[0] ?? ""}`, width));
388
+ for (let j = 1; j < wrapped.length; j++) {
389
+ lines.push(clipToWidth(`${indent}${wrapped[j]}`, width));
390
+ }
336
391
  }
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;
392
+ rowEnds[i] = lines.length;
342
393
  }
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}`;
394
+ {
395
+ const rowIndex = addRowCursorIndex(state);
396
+ const isCursor = state.cursor === rowIndex;
397
+ rowStarts[rowIndex] = lines.length;
398
+ if (state.editing?.target.kind === "user-add") {
399
+ const head = `${ACTIVE_SELECTOR} ${state.userAdded.length + 1}. `;
400
+ const headWidth = visibleLength(head);
401
+ const renderedEditor = renderMultilineEditor(state.editing.buffer, width, head, " ".repeat(headWidth));
402
+ editorRow = lines.length + renderedEditor.cursorRowOffset;
403
+ editorCol = renderedEditor.cursorCol;
404
+ lines.push(...renderedEditor.lines);
351
405
  }
352
406
  else {
353
- first = `${INACTIVE_PREFIX}${dim}${placeholder}${reset}`;
407
+ lines.push(clipToWidth(isCursor
408
+ ? `${ACTIVE_SELECTOR}${bold}${green}+ Add feedback (Enter)${reset}`
409
+ : ` ${dim}+ Add feedback${reset}`, width));
354
410
  }
355
- editorBody.push(first);
356
- while (editorBody.length < editorHeight)
357
- editorBody.push("");
411
+ rowEnds[rowIndex] = lines.length;
358
412
  }
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;
413
+ return {
414
+ lines,
415
+ rowStarts,
416
+ rowEnds,
417
+ editorRow,
418
+ editorCol,
419
+ };
420
+ }
421
+ function buildFeedbackPane(state, rendered, width, listRows, hintTokens) {
422
+ const scrollRows = Math.max(0, listRows);
423
+ const activeStart = rendered.rowStarts[state.cursor] ?? 0;
424
+ const activeEndExclusive = rendered.rowEnds[state.cursor] ?? rendered.lines.length;
425
+ let scrollTop = 0;
426
+ const total = rendered.lines.length;
427
+ if (scrollRows > 0 && total > scrollRows) {
428
+ scrollTop = activeStart;
429
+ if (activeEndExclusive - scrollTop < scrollRows) {
430
+ scrollTop = Math.max(0, total - scrollRows);
431
+ if (scrollTop > activeStart)
432
+ scrollTop = activeStart;
433
+ }
434
+ if (rendered.editorRow !== undefined) {
435
+ if (rendered.editorRow < scrollTop)
436
+ scrollTop = rendered.editorRow;
437
+ if (rendered.editorRow >= scrollTop + scrollRows) {
438
+ scrollTop = rendered.editorRow - scrollRows + 1;
365
439
  }
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
440
  }
441
+ if (scrollTop < 0)
442
+ scrollTop = 0;
443
+ if (scrollTop + scrollRows > total)
444
+ scrollTop = total - scrollRows;
372
445
  }
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;
446
+ const listLines = scrollRows === 0
447
+ ? []
448
+ : rendered.lines.slice(scrollTop, scrollTop + scrollRows);
449
+ while (listLines.length < scrollRows)
450
+ listLines.push("");
391
451
  let cursorRow;
392
452
  let cursorCol;
393
- if (state.mode === "approve") {
394
- cursorRow = approveRowInBlock;
395
- cursorCol = activePrefixWidth;
396
- }
397
- else if (showPlaceholder) {
398
- cursorRow = editorStart;
399
- cursorCol = activePrefixWidth;
453
+ if (rendered.editorRow !== undefined) {
454
+ cursorRow = rendered.editorRow - scrollTop;
455
+ cursorCol = rendered.editorCol;
400
456
  }
401
457
  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;
458
+ cursorRow = activeStart - scrollTop;
459
+ cursorCol = currentRow(state).kind === "approve" ? visibleLength(ACTIVE_SELECTOR) : 0;
408
460
  }
409
- if (cursorRow < 0)
461
+ if (scrollRows === 0) {
410
462
  cursorRow = 0;
411
- if (cursorCol < 0)
412
463
  cursorCol = 0;
464
+ }
465
+ else {
466
+ if (cursorRow < 0)
467
+ cursorRow = 0;
468
+ if (cursorRow >= scrollRows)
469
+ cursorRow = scrollRows - 1;
470
+ if (cursorCol < 0)
471
+ cursorCol = 0;
472
+ if (cursorCol >= width)
473
+ cursorCol = Math.max(0, width - 1);
474
+ }
475
+ const lines = [
476
+ ...listLines,
477
+ `${dim}${"─".repeat(width)}${reset}`,
478
+ `${INACTIVE_PREFIX}${renderShortcutHints(hintTokens)}`,
479
+ ];
413
480
  return {
414
- lines: lines.map((l) => clipToWidth(l, width)),
481
+ lines: lines.map((line) => clipToWidth(line, width)),
415
482
  cursor: { row: cursorRow, col: cursorCol },
416
483
  };
417
484
  }
485
+ function promptHelpTokens(state, offscreenHint) {
486
+ if (state.editing) {
487
+ const tokens = ["Ctrl+J Newline", "Enter Submit", "Esc Cancel", ALT_V_PASTE_IMAGE_HINT];
488
+ if (state.editing.transientHint) {
489
+ tokens.push(`${yellow}${state.editing.transientHint}${reset}`);
490
+ }
491
+ if (offscreenHint)
492
+ tokens.push(offscreenHint);
493
+ return tokens;
494
+ }
495
+ const row = currentRow(state);
496
+ const tokens = [];
497
+ if (row.kind === "item") {
498
+ tokens.push("Enter Edit", "D Delete", "A Add");
499
+ }
500
+ else if (row.kind === "add") {
501
+ tokens.push("Enter/A Add");
502
+ }
503
+ else {
504
+ tokens.push("Enter Approve", "A Add");
505
+ }
506
+ tokens.push(state.userAdded.length === 0 ? "Ctrl+D Approve" : "Ctrl+D Submit");
507
+ tokens.push("Ctrl+C Quit");
508
+ if (offscreenHint)
509
+ tokens.push(offscreenHint);
510
+ return tokens;
511
+ }
418
512
  /**
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.
513
+ * Compute the plan area's available height given total `rows` and the controls
514
+ * block height. Reserves 1 row for the dim separator between plan and controls.
421
515
  */
422
516
  function computePlanRows(controlsHeight, rows) {
423
517
  return Math.max(1, rows - controlsHeight - 1);
@@ -426,8 +520,6 @@ function buildPlanLines(plan, context, state, width, planRows) {
426
520
  const pane = buildPlanPane(plan, context.phaseLabel, width, context.artifactKind ?? "plan");
427
521
  const total = pane.lines.length;
428
522
  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
523
  if (state.planAtEnd)
432
524
  state.planScrollTop = maxScroll;
433
525
  if (state.planScrollTop > maxScroll)
@@ -438,12 +530,24 @@ function buildPlanLines(plan, context, state, width, planRows) {
438
530
  while (sliced.length < planRows)
439
531
  sliced.push("");
440
532
  return {
441
- lines: sliced.map((l) => clipToWidth(l, width)),
533
+ lines: sliced.map((line) => clipToWidth(line, width)),
442
534
  total,
443
535
  };
444
536
  }
445
537
  function buildSurface(plan, context, state, cols, rows) {
446
- const controls = buildControlsBlock(state, cols, primaryOffscreenHint(context));
538
+ const split = computeSplitMetrics({ cols, rows, collapseAt: SPLIT_COLLAPSE_AT_COLS });
539
+ const natural = buildFeedbackListRows(state, cols);
540
+ const naturalRows = natural.lines.length;
541
+ const offscreenHint = primaryOffscreenHint(context);
542
+ const hintTokens = promptHelpTokens(state, offscreenHint);
543
+ if (!split.collapsed && naturalRows > FEEDBACK_AREA_MAX_ROWS) {
544
+ const leftRender = buildFeedbackListRows(state, split.leftWidth);
545
+ const left = buildFeedbackPane(state, leftRender, split.leftWidth, Math.min(leftRender.lines.length, Math.max(0, rows - 2)), hintTokens);
546
+ const { lines: rightLines } = buildPlanLines(plan, context, state, split.rightWidth, rows);
547
+ return buildSplitSurface({ lines: left.lines, cursor: left.cursor }, { lines: rightLines }, { cols, rows, collapseAt: SPLIT_COLLAPSE_AT_COLS }, "left");
548
+ }
549
+ const listRows = Math.min(naturalRows, FEEDBACK_AREA_MAX_ROWS);
550
+ const controls = buildFeedbackPane(state, natural, cols, listRows, hintTokens);
447
551
  const planRows = computePlanRows(controls.lines.length, rows);
448
552
  const { lines: planLines } = buildPlanLines(plan, context, state, cols, planRows);
449
553
  const separator = `${dim}${"─".repeat(cols)}${reset}`;
@@ -452,16 +556,11 @@ function buildSurface(plan, context, state, cols, rows) {
452
556
  separator,
453
557
  ...controls.lines,
454
558
  ];
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
559
  while (composed.length < rows)
458
560
  composed.push("");
459
561
  if (composed.length > rows) {
460
- // Clip from the top of the plan area; never clip controls.
461
562
  composed.splice(0, composed.length - rows);
462
563
  }
463
- // Cursor row sits in the controls block: planRows + 1 (separator) +
464
- // controls.cursor.row.
465
564
  let cursorRow = planRows + 1 + controls.cursor.row;
466
565
  if (cursorRow < 0)
467
566
  cursorRow = 0;
@@ -476,8 +575,14 @@ function planMetrics(plan, context, state, host) {
476
575
  const layout = host.getLayout();
477
576
  const cols = Math.max(20, layout.columns);
478
577
  const rows = Math.max(1, layout.childRows);
479
- const controls = buildControlsBlock(state, cols, primaryOffscreenHint(context));
480
- const planHeight = computePlanRows(controls.lines.length, rows);
578
+ const split = computeSplitMetrics({ cols, rows, collapseAt: SPLIT_COLLAPSE_AT_COLS });
579
+ const naturalRows = measureFeedbackListRows(state, cols);
580
+ if (!split.collapsed && naturalRows > FEEDBACK_AREA_MAX_ROWS) {
581
+ const pane = buildPlanPane(plan, context.phaseLabel, split.rightWidth, context.artifactKind ?? "plan");
582
+ return { planHeight: rows, planLines: pane.lines.length };
583
+ }
584
+ const controlsHeight = Math.min(naturalRows, FEEDBACK_AREA_MAX_ROWS) + 2;
585
+ const planHeight = computePlanRows(controlsHeight, rows);
481
586
  const pane = buildPlanPane(plan, context.phaseLabel, cols, context.artifactKind ?? "plan");
482
587
  return { planHeight, planLines: pane.lines.length };
483
588
  }