@zergai/ztc 0.2.6-dev.36101352252 → 0.2.7-dev.36236183737

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 (112) hide show
  1. package/README.md +43 -17
  2. package/bin/zmux-darwin-arm64 +0 -0
  3. package/bin/ztc-tui-darwin-arm64 +0 -0
  4. package/bin/zterm-darwin-arm64 +0 -0
  5. package/dist/App.js +19 -5
  6. package/dist/agent/agent.d.ts +5 -1
  7. package/dist/agent/agent.js +51 -8
  8. package/dist/agent/backends/account_pool.d.ts +5 -6
  9. package/dist/agent/backends/account_pool.js +39 -14
  10. package/dist/agent/backends/account_rotation.d.ts +3 -0
  11. package/dist/agent/backends/account_rotation.js +14 -3
  12. package/dist/agent/backends/codex_account_status.d.ts +42 -0
  13. package/dist/agent/backends/codex_account_status.js +196 -0
  14. package/dist/agent/backends/codex_app_server.d.ts +12 -2
  15. package/dist/agent/backends/codex_app_server.js +66 -6
  16. package/dist/agent/backends/index.d.ts +2 -0
  17. package/dist/agent/backends/index.js +13 -5
  18. package/dist/agent/backends/openai.d.ts +2 -2
  19. package/dist/agent/backends/openai.js +4 -3
  20. package/dist/agent/backends/openai_compatible.js +10 -10
  21. package/dist/agent/backends/openai_responses.d.ts +17 -5
  22. package/dist/agent/backends/openai_responses.js +2 -4
  23. package/dist/agent/backends/token_usage.js +1 -1
  24. package/dist/agent/backends/types.d.ts +3 -0
  25. package/dist/agent/commands/accounts.d.ts +7 -1
  26. package/dist/agent/commands/accounts.js +299 -101
  27. package/dist/agent/commands/config.js +11 -3
  28. package/dist/agent/commands/config_profile_switch.d.ts +2 -0
  29. package/dist/agent/commands/config_profile_switch.js +22 -0
  30. package/dist/agent/commands/context.js +4 -3
  31. package/dist/agent/commands/index.js +1 -2
  32. package/dist/agent/commands/input_mode.js +1 -1
  33. package/dist/agent/commands/model.js +7 -2
  34. package/dist/agent/commands/plan.d.ts +12 -0
  35. package/dist/agent/commands/plan.js +39 -28
  36. package/dist/agent/commands/profile.d.ts +1 -1
  37. package/dist/agent/commands/profile.js +34 -257
  38. package/dist/agent/commands/provider_choices.d.ts +12 -1
  39. package/dist/agent/commands/provider_choices.js +29 -2
  40. package/dist/agent/commands/provider_selection.d.ts +3 -0
  41. package/dist/agent/commands/provider_selection.js +63 -0
  42. package/dist/agent/commands/settings.d.ts +2 -0
  43. package/dist/agent/commands/settings.js +655 -50
  44. package/dist/agent/commands/types.d.ts +19 -2
  45. package/dist/agent/tools/ask_user.js +2 -2
  46. package/dist/app/agent_runner.d.ts +1 -1
  47. package/dist/app/agent_runner.js +2 -2
  48. package/dist/app/ask_user_response.d.ts +3 -0
  49. package/dist/app/ask_user_response.js +16 -0
  50. package/dist/app/command_context.js +29 -13
  51. package/dist/app/controller.d.ts +17 -2
  52. package/dist/app/controller.js +24 -1
  53. package/dist/app/selection_completion.d.ts +9 -3
  54. package/dist/app/selection_completion.js +54 -22
  55. package/dist/cli_args.js +10 -1
  56. package/dist/cli_main.js +85 -2
  57. package/dist/cli_ratatui.d.ts +22 -4
  58. package/dist/cli_ratatui.js +421 -146
  59. package/dist/config/defaults.d.ts +6 -0
  60. package/dist/config/defaults.js +20 -0
  61. package/dist/config/profiles.d.ts +9 -0
  62. package/dist/config/profiles.js +87 -0
  63. package/dist/config/types.d.ts +2 -0
  64. package/dist/config.d.ts +7 -0
  65. package/dist/config.js +283 -40
  66. package/dist/headless.js +14 -0
  67. package/dist/panes/model_catalog.d.ts +1 -1
  68. package/dist/panes/model_catalog.js +24 -2
  69. package/dist/panes/ztc_launch.js +6 -0
  70. package/dist/router_auth.js +3 -2
  71. package/dist/session.d.ts +2 -0
  72. package/dist/session.js +3 -0
  73. package/dist/types.d.ts +23 -2
  74. package/dist/ui/views/app.d.ts +3 -1
  75. package/dist/ui/views/app.js +3 -2
  76. package/dist/ui/views/completion_menu.js +6 -6
  77. package/dist/ui/views/input_area.d.ts +2 -1
  78. package/dist/ui/views/input_area.js +9 -8
  79. package/dist/ui/views/status_bar.d.ts +2 -0
  80. package/dist/ui/views/status_bar.js +15 -4
  81. package/dist/ui/widgets/color_panel.d.ts +9 -0
  82. package/dist/ui/widgets/color_panel.js +41 -26
  83. package/dist/ui/widgets/navigator_panel.d.ts +9 -0
  84. package/dist/ui/widgets/navigator_panel.js +17 -16
  85. package/dist/ui/widgets/panel_frame.d.ts +83 -3
  86. package/dist/ui/widgets/panel_frame.js +107 -9
  87. package/dist/ui/widgets/questionnaire.d.ts +9 -0
  88. package/dist/ui/widgets/questionnaire.js +214 -16
  89. package/dist/ui/widgets/resume_panel.d.ts +9 -0
  90. package/dist/ui/widgets/resume_panel.js +18 -17
  91. package/dist/ui/widgets/rewind_panel.d.ts +9 -0
  92. package/dist/ui/widgets/rewind_panel.js +15 -16
  93. package/dist/ui/widgets/select.d.ts +6 -0
  94. package/dist/ui/widgets/select.js +47 -12
  95. package/dist/ui/widgets/settings_panel.d.ts +75 -0
  96. package/dist/ui/widgets/settings_panel.js +197 -39
  97. package/dist/ui/widgets/status_line_panel.d.ts +9 -0
  98. package/dist/ui/widgets/status_line_panel.js +18 -19
  99. package/dist/ui/widgets/transcript_preview.d.ts +8 -0
  100. package/dist/ui/widgets/transcript_preview.js +10 -0
  101. package/dist/utils/context.js +2 -0
  102. package/dist/utils/fireworks.d.ts +20 -0
  103. package/dist/utils/fireworks.js +23 -0
  104. package/dist/utils/models.d.ts +2 -1
  105. package/dist/utils/models.js +19 -0
  106. package/dist/web/accounts_mirror.d.ts +7 -0
  107. package/dist/web/accounts_mirror.js +59 -7
  108. package/dist/web/mirror_stream.js +19 -5
  109. package/dist/zlm-embed.mjs +5734 -5362
  110. package/package.json +2 -2
  111. package/dist/agent/commands/provider.d.ts +0 -2
  112. package/dist/agent/commands/provider.js +0 -75
@@ -5,8 +5,10 @@
5
5
  */
6
6
  import { displayWidth, sanitizeTerminalText } from '../core/text_width.js';
7
7
  import { isControlKey } from '../core/input.js';
8
+ import { backspace, createEmptyState, deleteForward, deleteWordBackward, deleteWordForward, getPlainText, insertText, killToLineEnd, killToLineStart, moveLeft, moveRight, moveToEnd, moveToLineEnd, moveToLineStart, moveToStart, moveVertical, moveWordLeft, moveWordRight, } from '../core/input_segments.js';
8
9
  import { box, text } from '../core/index.js';
9
10
  import { TERMINAL_THEME } from '../views/theme.js';
11
+ import { renderLineWithCursorTokens, wrapInputSegments } from '../views/input_area.js';
10
12
  import { bottomBorder, framedLine, lineText, topBorder, } from './panel_frame.js';
11
13
  const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
12
14
  const RAW_DELETE_INPUT = new Set(['\x7f', '\b', '\x08']);
@@ -97,6 +99,8 @@ export class QuestionnaireWidget {
97
99
  otherMode = false;
98
100
  otherValue = '';
99
101
  textValues = {};
102
+ optionNotes = new Map();
103
+ editingOptionNote = false;
100
104
  constructor(host) {
101
105
  this.host = host;
102
106
  }
@@ -116,6 +120,8 @@ export class QuestionnaireWidget {
116
120
  this.otherMode = false;
117
121
  this.otherValue = '';
118
122
  this.textValues = {};
123
+ this.optionNotes.clear();
124
+ this.editingOptionNote = false;
119
125
  // Pre-fill defaults
120
126
  for (let i = 0; i < data.questions.length; i++) {
121
127
  const q = data.questions[i];
@@ -146,6 +152,8 @@ export class QuestionnaireWidget {
146
152
  this.otherMode = false;
147
153
  this.otherValue = '';
148
154
  this.textValues = {};
155
+ this.optionNotes.clear();
156
+ this.editingOptionNote = false;
149
157
  }
150
158
  isActive() {
151
159
  return this.active !== null;
@@ -160,7 +168,8 @@ export class QuestionnaireWidget {
160
168
  const answeredIndex = question.options.findIndex((option) => option.value === answer);
161
169
  this.optionIndex = answeredIndex >= 0
162
170
  ? answeredIndex
163
- : (this.active.allowOther ? question.options.length : 0);
171
+ : (this.active.allowOther && !(this.active.allowOptionNotes && question.type === 'select')
172
+ ? question.options.length : 0);
164
173
  }
165
174
  else {
166
175
  // Unanswered question: land on the recommended option when the agent
@@ -169,6 +178,34 @@ export class QuestionnaireWidget {
169
178
  }
170
179
  this.otherMode = false;
171
180
  this.otherValue = '';
181
+ this.editingOptionNote = false;
182
+ }
183
+ getOptionNote() {
184
+ return this.optionNotes.get(this.tabIndex) ?? createEmptyState();
185
+ }
186
+ setOptionNote(state) {
187
+ this.optionNotes.set(this.tabIndex, state);
188
+ this.host.render();
189
+ }
190
+ selectHighlightedOption(question) {
191
+ const option = question.options?.[this.optionIndex];
192
+ if (option)
193
+ this.answers[question.name] = option.value;
194
+ }
195
+ saveOptionNotes() {
196
+ if (!this.active?.allowOptionNotes)
197
+ return;
198
+ this.active.questions.forEach((question, index) => {
199
+ if (question.type !== 'select')
200
+ return;
201
+ const note = getPlainText((this.optionNotes.get(index) ?? createEmptyState()).segments);
202
+ // A note cleared after a failed submit must not resurface from the
203
+ // earlier attempt.
204
+ if (note.trim())
205
+ this.answers[`${question.name}_note`] = note;
206
+ else
207
+ delete this.answers[`${question.name}_note`];
208
+ });
172
209
  }
173
210
  submitAnswers() {
174
211
  if (!this.active)
@@ -184,6 +221,7 @@ export class QuestionnaireWidget {
184
221
  else
185
222
  delete this.answers[question.name];
186
223
  }
224
+ this.saveOptionNotes();
187
225
  const firstMissing = questions.findIndex(question => question.required === true && !this.answers[question.name]);
188
226
  if (firstMissing >= 0) {
189
227
  this.host.setToast(`"${questions[firstMissing].question}" is required`, 3000);
@@ -242,12 +280,101 @@ export class QuestionnaireWidget {
242
280
  }
243
281
  return null;
244
282
  }
283
+ handleOptionNoteInput(input, key, question) {
284
+ if (!this.active?.allowOptionNotes || question.type !== 'select')
285
+ return false;
286
+ if (isControlKey(input, key, 'c')) {
287
+ const callback = this.active.callback;
288
+ this.cancel();
289
+ this.host.render();
290
+ void Promise.resolve(callback(null)).catch(() => { });
291
+ return true;
292
+ }
293
+ if (!this.editingOptionNote) {
294
+ // A printable key starts the note for the highlighted option. Letter
295
+ // shortcuts must not steal the first character of the user's prose.
296
+ if (input && !key.ctrl && !key.meta && !key.escape && !key.return && !key.tab
297
+ && !key.backspace && !key.delete && !key.upArrow && !key.downArrow
298
+ && !key.leftArrow && !key.rightArrow && !key.home && !key.end
299
+ && !/^[\x00-\x1f\x7f]$/.test(input)) {
300
+ this.selectHighlightedOption(question);
301
+ this.editingOptionNote = true;
302
+ this.setOptionNote(insertText(this.getOptionNote(), input.replace(/\r\n?/g, '\n')));
303
+ return true;
304
+ }
305
+ return false;
306
+ }
307
+ if (key.escape) {
308
+ this.editingOptionNote = false;
309
+ this.host.render();
310
+ return true;
311
+ }
312
+ if (key.return) {
313
+ if (key.shift) {
314
+ this.setOptionNote(insertText(this.getOptionNote(), '\n'));
315
+ }
316
+ else {
317
+ this.selectHighlightedOption(question);
318
+ this.editingOptionNote = false;
319
+ this.advanceOrSubmit();
320
+ }
321
+ return true;
322
+ }
323
+ if (key.tab) {
324
+ this.editingOptionNote = false;
325
+ this.goToQuestion((this.tabIndex + 1) % this.active.questions.length);
326
+ this.host.render();
327
+ return true;
328
+ }
329
+ const state = this.getOptionNote();
330
+ if ((key.meta && RAW_DELETE_INPUT.has(input)) || isControlKey(input, key, 'w')
331
+ || (key.backspace && (key.meta || key.ctrl))) {
332
+ this.setOptionNote(deleteWordBackward(state));
333
+ }
334
+ else if (key.backspace) {
335
+ this.setOptionNote(backspace(state));
336
+ }
337
+ else if (key.delete && (key.meta || key.ctrl)) {
338
+ this.setOptionNote(deleteWordForward(state));
339
+ }
340
+ else if (key.delete) {
341
+ this.setOptionNote(deleteForward(state));
342
+ }
343
+ else if (key.leftArrow) {
344
+ this.setOptionNote(key.meta || key.ctrl ? moveWordLeft(state) : moveLeft(state));
345
+ }
346
+ else if (key.rightArrow) {
347
+ this.setOptionNote(key.meta || key.ctrl ? moveWordRight(state) : moveRight(state));
348
+ }
349
+ else if (key.upArrow || key.downArrow) {
350
+ this.setOptionNote(moveVertical(state, key.upArrow ? -1 : 1).state);
351
+ }
352
+ else if (key.home || isControlKey(input, key, 'a')) {
353
+ this.setOptionNote(key.meta ? moveToStart(state) : moveToLineStart(state));
354
+ }
355
+ else if (key.end || isControlKey(input, key, 'e')) {
356
+ this.setOptionNote(key.meta ? moveToEnd(state) : moveToLineEnd(state));
357
+ }
358
+ else if (isControlKey(input, key, 'u')) {
359
+ this.setOptionNote(killToLineStart(state).state);
360
+ }
361
+ else if (isControlKey(input, key, 'k')) {
362
+ this.setOptionNote(killToLineEnd(state).state);
363
+ }
364
+ else if (input && !key.ctrl && !key.meta && !/^[\x00-\x1f\x7f]$/.test(input)) {
365
+ // Multi-character paste is one insertion, including its newlines.
366
+ this.setOptionNote(insertText(state, input.replace(/\r\n?/g, '\n')));
367
+ }
368
+ return true;
369
+ }
245
370
  handleInput(input, key) {
246
371
  if (!this.active)
247
372
  return;
248
373
  const questions = this.active.questions;
249
374
  const totalTabs = questions.length;
250
375
  const currentQ = questions[this.tabIndex];
376
+ if (currentQ && this.handleOptionNoteInput(input, key, currentQ))
377
+ return;
251
378
  // If in "Other" text input mode for select/multiselect
252
379
  if (this.otherMode && currentQ) {
253
380
  if (key.escape) {
@@ -407,7 +534,8 @@ export class QuestionnaireWidget {
407
534
  // Navigate options within current question tab: Up/Down
408
535
  if (currentQ && (currentQ.type === 'select' || currentQ.type === 'multiselect')) {
409
536
  const opts = currentQ.options || [];
410
- const totalOpts = opts.length + (this.active.allowOther ? 1 : 0);
537
+ const totalOpts = opts.length + (this.active.allowOther
538
+ && !(this.active.allowOptionNotes && currentQ.type === 'select') ? 1 : 0);
411
539
  if (key.upArrow || input === 'k') {
412
540
  this.optionIndex = Math.max(0, this.optionIndex - 1);
413
541
  this.host.render();
@@ -441,7 +569,8 @@ export class QuestionnaireWidget {
441
569
  if (key.return) {
442
570
  const idx = this.optionIndex;
443
571
  // "Other" option
444
- if (this.active.allowOther && idx === opts.length) {
572
+ if (this.active.allowOther && !(this.active.allowOptionNotes && currentQ.type === 'select')
573
+ && idx === opts.length) {
445
574
  this.otherMode = true;
446
575
  this.otherValue = '';
447
576
  this.host.render();
@@ -465,7 +594,7 @@ export class QuestionnaireWidget {
465
594
  // printable character auto-jumps to Other with that character
466
595
  // already captured in `otherValue`. See the analogous block in
467
596
  // select.ts for the detailed rationale.
468
- if (this.active.allowOther &&
597
+ if (this.active.allowOther && !this.active.allowOptionNotes &&
469
598
  currentQ.type === 'select' &&
470
599
  !this.otherMode &&
471
600
  input &&
@@ -502,7 +631,7 @@ export class QuestionnaireWidget {
502
631
  buildSurface(cols, maxRows) {
503
632
  if (!this.active)
504
633
  return { content: '', view: box([], {}), lineCount: 0 };
505
- const { title, questions, allowOther } = this.active;
634
+ const { title, questions, allowOther, allowOptionNotes } = this.active;
506
635
  const question = questions[this.tabIndex];
507
636
  if (!question)
508
637
  return { content: '', view: box([], {}), lineCount: 0 };
@@ -541,9 +670,29 @@ export class QuestionnaireWidget {
541
670
  progress.push({ text: ` · ${question.header}`, style: { color: TERMINAL_THEME.subtle } });
542
671
  }
543
672
  lines.push(framed(progress));
544
- const questionLines = wrapText(question.question, innerWidth, 2);
673
+ // One content column for the whole card. The question, every option label,
674
+ // and every description start at this same column; the selection caret,
675
+ // number, and state marker are outdented into the gutter to its left. Lining
676
+ // them up separately made the card read as ragged (labels at 7, descriptions
677
+ // at 3, the question at 0).
678
+ // Size the outgoing gutter to the widest option prefix this question renders
679
+ // (`› 12. ○ ` is one cell wider than `› 1. ○ `), so the question text and
680
+ // every option label land on the same column.
681
+ // Mirror the exact option gutter, including its marker width: a multiselect
682
+ // checkbox (`[✓]`) is two cells wider than the select radio (`○`), so a
683
+ // hardcoded radio here put the question one column off its own labels.
684
+ const optionCount = question.options?.length ?? 1;
685
+ const widestIndex = allowOther ? optionCount + 1 : optionCount;
686
+ const numberWidth = String(Math.max(1, widestIndex)).length;
687
+ const markerWidth = question.type === 'multiselect' ? displayWidth('[✓]') : displayWidth('○');
688
+ // caret + space + (padded number) + space + marker + space
689
+ const gutter = ' '.repeat(displayWidth('› ') + numberWidth + 1 + 1 + markerWidth + 1);
690
+ const questionLines = wrapText(question.question, innerWidth - gutter.length, 2);
545
691
  for (const questionLine of questionLines) {
546
- lines.push(framed([{ text: questionLine, style: { color: TERMINAL_THEME.text, bold: true } }]));
692
+ lines.push(framed([
693
+ { text: gutter },
694
+ { text: questionLine, style: { color: TERMINAL_THEME.text, bold: true } },
695
+ ]));
547
696
  }
548
697
  // The box is sized to the question it renders (capped by the caller's
549
698
  // budget). Filling the whole budget left the transcript hidden behind a
@@ -616,14 +765,19 @@ export class QuestionnaireWidget {
616
765
  ? (chosen ? '[✓]' : '[ ]')
617
766
  : (chosen ? '●' : '○');
618
767
  const badge = option.recommended ? ' ★ recommended' : '';
619
- const prefix = `${highlighted ? '›' : ' '} ${index + 1}. ${marker} `;
768
+ // Gutter = caret + number + marker, then the content column. The number is
769
+ // padded to the widest index in this question so `10.` does not step one
770
+ // cell right of `1.`: the label column must be identical for every row.
771
+ const numberWidth = String(Math.max(1, options.length)).length;
772
+ const indexLabel = `${index + 1}.`.padStart(numberWidth + 1);
773
+ const prefix = `${highlighted ? '›' : ' '} ${indexLabel} ${marker} `;
620
774
  const labelWidth = Math.max(1, innerWidth - displayWidth(prefix) - displayWidth(badge));
621
775
  const row = [
622
776
  {
623
777
  text: highlighted ? '›' : ' ',
624
778
  style: highlighted ? { color: TERMINAL_THEME.info, bold: true } : undefined,
625
779
  },
626
- { text: ` ${index + 1}. `, style: { color: TERMINAL_THEME.subtle } },
780
+ { text: ` ${indexLabel} `, style: { color: TERMINAL_THEME.subtle } },
627
781
  {
628
782
  text: marker,
629
783
  style: chosen
@@ -642,26 +796,29 @@ export class QuestionnaireWidget {
642
796
  // reading to choose between options. The row budget decides how much
643
797
  // of the option LIST is visible; it never cuts the highlighted
644
798
  // description short.
645
- const descriptionWidth = Math.max(1, innerWidth - 3);
799
+ // Same content column as the label above and the question at the top.
800
+ const descriptionWidth = Math.max(1, innerWidth - displayWidth(prefix));
646
801
  for (const line of wrapText(option.description, descriptionWidth, Number.MAX_SAFE_INTEGER)) {
647
802
  rows.push(framed([
648
- { text: ' ' },
803
+ { text: ' '.repeat(displayWidth(prefix)) },
649
804
  { text: line, style: { color: TERMINAL_THEME.muted, dimColor: true } },
650
805
  ], TERMINAL_THEME.bgElevated));
651
806
  }
652
807
  }
653
808
  return { rows };
654
809
  });
655
- if (allowOther) {
810
+ if (allowOther && !(allowOptionNotes && question.type === 'select')) {
656
811
  const otherIndex = options.length;
657
812
  const highlighted = this.optionIndex === otherIndex;
658
- const prefix = `${highlighted ? '›' : ' '} ${otherIndex + 1}. ○ `;
813
+ const numberWidth = String(Math.max(1, otherIndex + 1)).length;
814
+ const indexLabel = `${otherIndex + 1}.`.padStart(numberWidth + 1);
815
+ const prefix = `${highlighted ? '›' : ' '} ${indexLabel} ○ `;
659
816
  const rows = [framed([
660
817
  {
661
818
  text: highlighted ? '›' : ' ',
662
819
  style: highlighted ? { color: TERMINAL_THEME.info, bold: true } : undefined,
663
820
  },
664
- { text: ` ${otherIndex + 1}. `, style: { color: TERMINAL_THEME.subtle } },
821
+ { text: ` ${indexLabel} `, style: { color: TERMINAL_THEME.subtle } },
665
822
  { text: '○', style: { color: TERMINAL_THEME.muted } },
666
823
  { text: ' ' },
667
824
  {
@@ -670,19 +827,55 @@ export class QuestionnaireWidget {
670
827
  },
671
828
  ], highlighted ? TERMINAL_THEME.bgElevated : undefined)];
672
829
  if (highlighted) {
830
+ // Same content column as the option labels above.
673
831
  rows.push(framed([
674
- { text: ' ' },
832
+ { text: ' '.repeat(displayWidth(prefix)) },
675
833
  { text: 'Enter a custom answer.', style: { color: TERMINAL_THEME.muted, dimColor: true } },
676
834
  ], TERMINAL_THEME.bgElevated));
677
835
  }
678
836
  blocks.push({ rows });
679
837
  }
838
+ const noteEnabled = allowOptionNotes && question.type === 'select';
839
+ const noteEditorRows = noteEnabled
840
+ ? bodyRows >= 3 ? Math.min(5, bodyRows - 2)
841
+ : this.editingOptionNote ? bodyRows : 0
842
+ : 0;
843
+ const noteLabelRows = noteEditorRows > 0 && bodyRows >= 3 ? 1 : 0;
844
+ const optionRows = bodyRows - noteEditorRows - noteLabelRows;
680
845
  const selectedBlock = Math.min(this.optionIndex, Math.max(0, blocks.length - 1));
681
- const window = windowOptionBlocks(blocks, bodyRows, selectedBlock);
846
+ const window = windowOptionBlocks(blocks, optionRows, selectedBlock);
682
847
  body.push(...window.rows);
683
848
  if (blocks.length > 0 && (window.start > 0 || window.end < blocks.length - 1)) {
684
849
  optionPosition = `option ${selectedBlock + 1}/${blocks.length}`;
685
850
  }
851
+ if (noteEditorRows > 0) {
852
+ if (noteLabelRows > 0) {
853
+ body.push(framed([{ text: 'Additional context (optional)', style: { color: TERMINAL_THEME.info, bold: true } }]));
854
+ }
855
+ const note = this.getOptionNote();
856
+ const wrapped = wrapInputSegments(note.segments, note.cursor, innerWidth, 2);
857
+ const firstLine = this.editingOptionNote
858
+ ? Math.max(0, wrapped.cursorLine - noteEditorRows + 1)
859
+ : Math.max(0, wrapped.lines.length - noteEditorRows);
860
+ const visible = wrapped.lines.slice(firstLine, firstLine + noteEditorRows);
861
+ const lastLine = firstLine + visible.length;
862
+ visible.forEach((line, index) => {
863
+ const sourceLine = firstLine + index;
864
+ const prefix = sourceLine === 0 ? '❯ '
865
+ : index === 0 && firstLine > 0 ? '↥ '
866
+ : sourceLine === lastLine - 1 && lastLine < wrapped.lines.length ? '↧ ' : ' ';
867
+ const segments = [
868
+ { text: prefix, style: { color: TERMINAL_THEME.info, bold: true } },
869
+ ];
870
+ if (note.segments.length === 0 && !this.editingOptionNote) {
871
+ segments.push({ text: 'Type to add a note', style: { color: TERMINAL_THEME.subtle } });
872
+ }
873
+ else {
874
+ segments.push(...renderLineWithCursorTokens(line, wrapped.cursorCol, this.editingOptionNote && sourceLine === wrapped.cursorLine).map(token => ({ text: token.text, style: token.style })));
875
+ }
876
+ body.push(framed(segments));
877
+ });
878
+ }
686
879
  }
687
880
  if (spacerRows > 0)
688
881
  lines.push(blank());
@@ -699,6 +892,11 @@ export class QuestionnaireWidget {
699
892
  else if (question.type === 'text') {
700
893
  hint = `${isLast ? 'Enter submit' : 'Enter next'} · ←/→ questions · Esc cancel`;
701
894
  }
895
+ else if (allowOptionNotes && question.type === 'select') {
896
+ hint = this.editingOptionNote
897
+ ? `Enter ${isLast ? 'submit' : 'next'} · Shift+Enter newline · Esc options`
898
+ : `↑↓ options · Type note · Enter ${isLast ? 'submit' : 'next'} · Esc cancel`;
899
+ }
702
900
  else if (question.type === 'multiselect') {
703
901
  hint = `↑↓ move · Space toggle · ${isLast ? 'Enter submit' : 'Enter next'} · Esc cancel`;
704
902
  }
@@ -88,6 +88,15 @@ export declare class ResumePanelWidget {
88
88
  private previewViewportRows;
89
89
  private lastBodyRows;
90
90
  private lastInnerWidth;
91
+ /**
92
+ * Column the painted frame starts on, in surface coordinates.
93
+ *
94
+ * Zero unless the terminal is wide enough to trigger the shared cap, in which
95
+ * case it is that centring margin. The wheel bands add it back: a notch lands
96
+ * in terminal columns, so without it a capped page would scroll from a pane
97
+ * that is no longer under the pointer.
98
+ */
99
+ private frameInset;
91
100
  /** Pane geometry from the last paint; see `panelSplit()`. */
92
101
  private lastSplit;
93
102
  private loading;
@@ -14,12 +14,11 @@
14
14
  * row: moving the selection reloads the transcript preview, and the row itself
15
15
  * grows a one-line snippet under it.
16
16
  */
17
- import { box, text } from '../core/index.js';
18
17
  import { displayWidth, padEndDisplay } from '../core/text_width.js';
19
18
  import { TERMINAL_THEME } from '../views/theme.js';
20
19
  import { fuzzyMatch, rankFuzzy } from '../../utils/fuzzy_match.js';
21
20
  import { buildTranscriptLines, messageLines as renderMessageLines, previewSnippet, transcriptHeaderText, windowedLines, TRANSCRIPT_PANE_MIN_BODY_ROWS, TRANSCRIPT_PANE_MIN_WIDTH, } from './transcript_preview.js';
22
- import { bodyBand, bottomBorder, clipText, dropLastGrapheme, fitSegments, formatRelativeTime, framedLine, isInsideBand, joinColumns, lineText, planPanelSplit, sideBySideBands, stackedBands, topBorder, WHEEL_LINES, } from './panel_frame.js';
21
+ import { bodyBand, bottomBorder, clipText, dropLastGrapheme, fitSegments, formatRelativeTime, framedLine, isInsideBand, joinColumns, lineText, planPanelSplit, sideBySideBands, stackedBands, topBorder, WHEEL_LINES, buildWidgetFrameView, widgetFrameGeometry, } from './panel_frame.js';
23
22
  export { formatRelativeTime };
24
23
  const TAB_LABELS = {
25
24
  workspace: 'This workspace',
@@ -61,6 +60,15 @@ export class ResumePanelWidget {
61
60
  previewViewportRows = 1;
62
61
  lastBodyRows = 1;
63
62
  lastInnerWidth = 78;
63
+ /**
64
+ * Column the painted frame starts on, in surface coordinates.
65
+ *
66
+ * Zero unless the terminal is wide enough to trigger the shared cap, in which
67
+ * case it is that centring margin. The wheel bands add it back: a notch lands
68
+ * in terminal columns, so without it a capped page would scroll from a pane
69
+ * that is no longer under the pointer.
70
+ */
71
+ frameInset = 0;
64
72
  /** Pane geometry from the last paint; see `panelSplit()`. */
65
73
  lastSplit = null;
66
74
  loading = false;
@@ -661,6 +669,8 @@ export class ResumePanelWidget {
661
669
  scroll: this.previewScroll,
662
670
  viewportRows: this.previewViewportRows,
663
671
  focused: this.focus === 'preview',
672
+ // One cell of the heading's column is the gutter between the two panes.
673
+ maxWidth: Math.max(8, this.previewPaneWidth() - 1),
664
674
  });
665
675
  }
666
676
  /** The bubble list for the highlighted row, cached by (row, width, status). */
@@ -742,17 +752,7 @@ export class ResumePanelWidget {
742
752
  const lines = this.buildSurfaceLines(cols, maxRows);
743
753
  return {
744
754
  content: lines.map(lineText).join('\n'),
745
- view: box(lines.map(line => box(line.segments.map(segment => text(segment.text, segment.style)), {
746
- flexDirection: 'row',
747
- height: 1,
748
- flexShrink: 0,
749
- backgroundColor: line.backgroundColor,
750
- })), {
751
- flexDirection: 'column',
752
- height: lines.length,
753
- flexShrink: 0,
754
- backgroundColor: TERMINAL_THEME.bg,
755
- }),
755
+ view: buildWidgetFrameView(lines, this.frameInset),
756
756
  lineCount: lines.length,
757
757
  };
758
758
  }
@@ -768,7 +768,8 @@ export class ResumePanelWidget {
768
768
  // The host embeds the surface in a `paddingX: 1` column, so `cols - 2` is
769
769
  // the full usable width. No cap: the transcript pane should absorb every
770
770
  // extra column instead of leaving dead space on wide terminals.
771
- const width = Math.max(1, termCols - 2);
771
+ const { width, inset } = widgetFrameGeometry(termCols);
772
+ this.frameInset = inset;
772
773
  const height = Math.max(1, Math.floor(maxRows ?? 20));
773
774
  const innerWidth = Math.max(0, width - 4);
774
775
  this.lastInnerWidth = innerWidth;
@@ -809,7 +810,7 @@ export class ResumePanelWidget {
809
810
  lines.push(framedLine(row ? row.segments : [], width));
810
811
  }
811
812
  // No transcript pane at this size, so only the list is a wheel target.
812
- this.listBand = bodyBand(listTop, lines.length - 1, innerWidth);
813
+ this.listBand = bodyBand(listTop, lines.length - 1, innerWidth, this.frameInset);
813
814
  this.previewBand = null;
814
815
  }
815
816
  else if (split.placement === 'bottom') {
@@ -846,7 +847,7 @@ export class ResumePanelWidget {
846
847
  lines.push(framedLine(previewRow ? previewRow.segments : [], width));
847
848
  }
848
849
  // Stacked, the panes share the width and are told apart by rows.
849
- const bands = stackedBands({ top: stackedListTop, bottom: stackedListBottom }, { top: stackedPreviewTop, bottom: lines.length - 1 }, innerWidth);
850
+ const bands = stackedBands({ top: stackedListTop, bottom: stackedListBottom }, { top: stackedPreviewTop, bottom: lines.length - 1 }, innerWidth, this.frameInset);
850
851
  this.listBand = bands.list;
851
852
  this.previewBand = bands.preview;
852
853
  }
@@ -888,7 +889,7 @@ export class ResumePanelWidget {
888
889
  ], width));
889
890
  }
890
891
  // Both panes start at the shared heading row and differ only in columns.
891
- const bands = sideBySideBands(sideBandTop, lines.length - 1, listWidth, paneWidth);
892
+ const bands = sideBySideBands(sideBandTop, lines.length - 1, listWidth, paneWidth, this.frameInset);
892
893
  this.listBand = bands.list;
893
894
  this.previewBand = bands.preview;
894
895
  }
@@ -37,6 +37,15 @@ export interface RewindPanelSurface {
37
37
  export declare class RewindPanelWidget {
38
38
  private host;
39
39
  private active;
40
+ /**
41
+ * Column the painted frame starts on, in surface coordinates.
42
+ *
43
+ * Zero unless the terminal is wide enough to trigger the shared cap, in which
44
+ * case it is that centring margin. The wheel bands add it back: a notch lands
45
+ * in terminal columns, so without it a capped page would scroll from a pane
46
+ * that is no longer under the pointer.
47
+ */
48
+ private frameInset;
40
49
  private selectedIndex;
41
50
  private focus;
42
51
  /** First preview line to draw; only read when `previewAtTail` is false. */
@@ -18,10 +18,9 @@
18
18
  * handling, and renders a framed surface. Only the visible window of prompts is
19
19
  * built, and the selection always stays inside it.
20
20
  */
21
- import { box, text } from '../core/index.js';
22
21
  import { padEndDisplay } from '../core/text_width.js';
23
22
  import { TERMINAL_THEME } from '../views/theme.js';
24
- import { bottomBorder, clipText, fitSegments, formatRelativeTime, framedLine, isInsideBand, joinColumns, lineText, planPanelSplit, sideBySideBands, stackedBands, topBorder, WHEEL_LINES, } from './panel_frame.js';
23
+ import { bottomBorder, clipText, fitSegments, formatRelativeTime, framedLine, isInsideBand, joinColumns, lineText, planPanelSplit, sideBySideBands, stackedBands, topBorder, WHEEL_LINES, buildWidgetFrameView, widgetFrameGeometry, } from './panel_frame.js';
25
24
  import { buildBubbleLines } from './transcript_preview.js';
26
25
  /** Rows each entry claims in the list (prompt line + detail line). */
27
26
  const ENTRY_ROWS = 2;
@@ -65,6 +64,15 @@ function isCtrlKey(input, key, letter) {
65
64
  export class RewindPanelWidget {
66
65
  host;
67
66
  active = null;
67
+ /**
68
+ * Column the painted frame starts on, in surface coordinates.
69
+ *
70
+ * Zero unless the terminal is wide enough to trigger the shared cap, in which
71
+ * case it is that centring margin. The wheel bands add it back: a notch lands
72
+ * in terminal columns, so without it a capped page would scroll from a pane
73
+ * that is no longer under the pointer.
74
+ */
75
+ frameInset = 0;
68
76
  selectedIndex = 0;
69
77
  focus = 'list';
70
78
  /** First preview line to draw; only read when `previewAtTail` is false. */
@@ -264,17 +272,7 @@ export class RewindPanelWidget {
264
272
  const lines = this.buildSurfaceLines(cols, maxRows);
265
273
  return {
266
274
  content: lines.map(lineText).join('\n'),
267
- view: box(lines.map(line => box(line.segments.map(segment => text(segment.text, segment.style)), {
268
- flexDirection: 'row',
269
- height: 1,
270
- flexShrink: 0,
271
- backgroundColor: line.backgroundColor,
272
- })), {
273
- flexDirection: 'column',
274
- height: lines.length,
275
- flexShrink: 0,
276
- backgroundColor: TERMINAL_THEME.bg,
277
- }),
275
+ view: buildWidgetFrameView(lines, this.frameInset),
278
276
  lineCount: lines.length,
279
277
  };
280
278
  }
@@ -289,7 +287,8 @@ export class RewindPanelWidget {
289
287
  const termCols = Math.max(1, Math.floor(cols || process.stdout.columns || 80));
290
288
  // The host embeds the surface in a `paddingX: 1` column, so `cols - 2` is
291
289
  // the usable width, same convention as the resume panel.
292
- const width = Math.max(1, termCols - 2);
290
+ const { width, inset } = widgetFrameGeometry(termCols);
291
+ this.frameInset = inset;
293
292
  const height = Math.max(1, Math.floor(maxRows ?? 20));
294
293
  const innerWidth = Math.max(0, width - 4);
295
294
  const nowMs = this.active.now ? this.active.now() : Date.now();
@@ -353,7 +352,7 @@ export class RewindPanelWidget {
353
352
  ...fitSegments(previewRow?.segments ?? [], previewWidth),
354
353
  ], width));
355
354
  }
356
- this.paneBands = sideBySideBands(sideBandTop, lines.length - 1, split.listWidth, previewWidth);
355
+ this.paneBands = sideBySideBands(sideBandTop, lines.length - 1, split.listWidth, previewWidth, this.frameInset);
357
356
  }
358
357
  else if (split.placement === 'bottom') {
359
358
  this.pushPaneHeader(lines, width, [{
@@ -377,7 +376,7 @@ export class RewindPanelWidget {
377
376
  for (let index = 0; index < split.previewRows; index += 1) {
378
377
  lines.push(framedLine(previewRows[index]?.segments ?? [], width));
379
378
  }
380
- this.paneBands = stackedBands({ top: stackedListTop, bottom: stackedListBottom }, { top: stackedPreviewTop, bottom: lines.length - 1 }, innerWidth);
379
+ this.paneBands = stackedBands({ top: stackedListTop, bottom: stackedListBottom }, { top: stackedPreviewTop, bottom: lines.length - 1 }, innerWidth, this.frameInset);
381
380
  }
382
381
  else {
383
382
  this.pushPaneHeader(lines, width, [{
@@ -30,6 +30,12 @@ export declare class SelectWidget {
30
30
  private checked;
31
31
  private otherMode;
32
32
  private otherValue;
33
+ /**
34
+ * Columns the option gutter claims before the label: the `❯ `/` ` pointer
35
+ * plus the state marker and its separator. Every body row (question, label,
36
+ * description, Other hint) is indented by this, so the card has one left edge.
37
+ */
38
+ private static labelColumn;
33
39
  constructor(host: WidgetHost);
34
40
  /**
35
41
  * Returns true if the option at the given index can be selected/activated.