@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
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Extracted from cli_ratatui.ts to reduce file size.
5
5
  */
6
+ import { displayWidth } from '../core/text_width.js';
6
7
  export class SelectWidget {
7
8
  host;
8
9
  active = null;
@@ -10,6 +11,15 @@ export class SelectWidget {
10
11
  checked = new Set();
11
12
  otherMode = false;
12
13
  otherValue = '';
14
+ /**
15
+ * Columns the option gutter claims before the label: the `❯ `/` ` pointer
16
+ * plus the state marker and its separator. Every body row (question, label,
17
+ * description, Other hint) is indented by this, so the card has one left edge.
18
+ */
19
+ static labelColumn(type) {
20
+ const marker = type === 'multiselect' ? '[✓]' : '○';
21
+ return ' '.repeat(displayWidth(' ') + displayWidth(marker) + 1);
22
+ }
13
23
  constructor(host) {
14
24
  this.host = host;
15
25
  }
@@ -250,26 +260,51 @@ export class SelectWidget {
250
260
  const { title, question, type, options, allowOther } = this.active;
251
261
  const lines = [];
252
262
  const width = Math.min(Math.max(cols - 4, 40), 72);
263
+ // `pad` sizes to the full interior by default. Rows that carry a leading
264
+ // gutter pass an explicit width so the indent does not eat into the text
265
+ // budget (which would clip a line that still had room).
253
266
  const pad = (s, w = width - 4) => {
254
- if (s.length > w)
255
- return s.slice(0, w - 1) + '…';
256
- return s.padEnd(w);
267
+ if (displayWidth(s) > w) {
268
+ // Slice on visible width, not code units: the gutter holds wide glyphs
269
+ // (❯, ○, [✓]) and a code-unit slice would cut them in half.
270
+ let clipped = '';
271
+ for (const ch of s) {
272
+ if (displayWidth(clipped + ch) > w - 1)
273
+ break;
274
+ clipped += ch;
275
+ }
276
+ return clipped + '…';
277
+ }
278
+ return s + ' '.repeat(Math.max(0, w - displayWidth(s)));
257
279
  };
258
280
  // Top border with title
259
281
  const titlePart = title ? ` ${title} ` : '';
260
- const topLine = '╭─' + titlePart + '─'.repeat(Math.max(0, width - 4 - titlePart.length)) + '╮';
282
+ // Body rows reserve 4 fixed cells ('│ ' + ' │'). The title border reserves
283
+ // only 3 ('╭─' + '╮'), so it was drawn one column short of every other row
284
+ // and the right edge did not line up. Fill to the same interior width.
285
+ const titleFill = Math.max(0, width - 3 - displayWidth(titlePart));
286
+ const topLine = '╭─' + titlePart + '─'.repeat(titleFill) + '╮';
261
287
  lines.push(topLine);
262
288
  // Question (word-wrap if longer than available width)
263
289
  const maxQWidth = width - 4;
264
- if (question.length <= maxQWidth) {
265
- lines.push('│ ' + pad(question) + ' │');
290
+ // Both branches indent to the option label column. Fixing only the wrapped
291
+ // branch left short questions flush at column 1 while their labels started
292
+ // at the gutter, so the two disagreed for the commonest case.
293
+ const questionGutter = SelectWidget.labelColumn(type);
294
+ if (displayWidth(questionGutter + question) <= maxQWidth) {
295
+ lines.push('│ ' + pad(questionGutter + question, maxQWidth) + ' │');
266
296
  }
267
297
  else {
298
+ // Indent the question to the option label column so the card has one left
299
+ // edge. Deriving it from the real gutter keeps the two in step for both
300
+ // the select radio and the wider multiselect checkbox.
301
+ const gutter = questionGutter;
302
+ const innerQ = Math.max(1, maxQWidth - displayWidth(gutter));
268
303
  const words = question.split(' ');
269
304
  let line = '';
270
305
  for (const word of words) {
271
- if ((line + word).length > maxQWidth && line.length > 0) {
272
- lines.push('│ ' + pad(line.trimEnd()) + ' │');
306
+ if (displayWidth(line + word) > innerQ && line.length > 0) {
307
+ lines.push('│ ' + pad(gutter + line.trimEnd(), maxQWidth) + ' │');
273
308
  line = word + ' ';
274
309
  }
275
310
  else {
@@ -277,7 +312,7 @@ export class SelectWidget {
277
312
  }
278
313
  }
279
314
  if (line.trimEnd().length > 0) {
280
- lines.push('│ ' + pad(line.trimEnd()) + ' │');
315
+ lines.push('│ ' + pad(gutter + line.trimEnd(), maxQWidth) + ' │');
281
316
  }
282
317
  }
283
318
  lines.push('│' + ' '.repeat(width - 2) + '│');
@@ -328,8 +363,8 @@ export class SelectWidget {
328
363
  }
329
364
  // Description (if selected and has description) - word-wrap instead of truncate
330
365
  if (isSelected && opt.description) {
331
- const indent = ' ';
332
- const maxDescWidth = width - 8; // borders + padding
366
+ const indent = SelectWidget.labelColumn(type);
367
+ const maxDescWidth = width - 3 - displayWidth(indent); // borders + padding
333
368
  const words = opt.description.split(' ');
334
369
  let line = indent;
335
370
  for (const word of words) {
@@ -354,7 +389,7 @@ export class SelectWidget {
354
389
  const labelLine = `${pointer}${radio} Other...`;
355
390
  lines.push('│ ' + pad(labelLine) + ' │');
356
391
  if (isSelected) {
357
- lines.push('│ ' + pad(' Enter a custom value') + ' │');
392
+ lines.push('│ ' + pad(SelectWidget.labelColumn(type) + 'Enter a custom value') + ' │');
358
393
  }
359
394
  }
360
395
  // Bottom separator and instructions
@@ -9,6 +9,10 @@ export interface SettingsPanelSurface {
9
9
  export interface SettingsPanelData {
10
10
  title?: string;
11
11
  categories: SettingCategory[];
12
+ layout?: 'page';
13
+ notice?: string;
14
+ initialSetting?: string;
15
+ initialCategory?: string;
12
16
  initialFilter?: string;
13
17
  onSettingChange: (setting: string, value: string) => void | Promise<void>;
14
18
  /**
@@ -18,6 +22,33 @@ export interface SettingsPanelData {
18
22
  onCaptureStateChange?: (capturing: boolean) => void;
19
23
  onClose?: () => void;
20
24
  }
25
+ /**
26
+ * Columns between the longest label and the value column.
27
+ *
28
+ * Two blank columns read as a deliberate gap: one is indistinguishable from a
29
+ * label that happens to end one cell early, which is what made the value column
30
+ * look ragged across rows of different label lengths.
31
+ */
32
+ export declare const SETTINGS_VALUE_COLUMN_GAP = 2;
33
+ /**
34
+ * Floor on the label column, in cells.
35
+ *
36
+ * Clipping a label is worse than a wide gap: the label is what identifies the
37
+ * row, and the two-column table has nothing else to name it. This is the
38
+ * smallest column that still shows a label like "Notifications" whole on the
39
+ * narrowest pane the panel supports, so it stays a fixed cell count rather than
40
+ * a share of a width that can shrink below it.
41
+ */
42
+ export declare const SETTINGS_LABEL_COLUMN_MIN = 14;
43
+ /**
44
+ * Cells the `› ` cursor claims before a label.
45
+ *
46
+ * Every body row in the pane indents by this — the label, its description, the
47
+ * captured-key notice, and the option list — so the pane has one content
48
+ * column. Exported so a test can assert the rows agree instead of pinning the
49
+ * number twice.
50
+ */
51
+ export declare const SETTINGS_ROW_INDENT = 2;
21
52
  export declare class SettingsPanelWidget {
22
53
  private host;
23
54
  private active;
@@ -48,7 +79,17 @@ export declare class SettingsPanelWidget {
48
79
  private categoryBand;
49
80
  private settingsBand;
50
81
  private lastBodyRows;
82
+ /**
83
+ * Column the painted frame starts on, in surface coordinates.
84
+ *
85
+ * Zero when the frame fills the width. On a terminal wide enough to trigger
86
+ * the cap it is the centring margin, and the wheel bands have to add it back:
87
+ * a click lands in terminal columns, so without it a capped page would scroll
88
+ * from a pane that is no longer under the pointer.
89
+ */
90
+ private frameInset;
51
91
  constructor(host: WidgetHost);
92
+ private isPage;
52
93
  show(data: SettingsPanelData): void;
53
94
  cancel(): void;
54
95
  isActive(): boolean;
@@ -96,10 +137,44 @@ export declare class SettingsPanelWidget {
96
137
  private notifyCaptureState;
97
138
  private commitSettingValue;
98
139
  handleInput(input: string, key: KeyEvent): void;
140
+ /**
141
+ * A value that always reads the same for a given setting, so a row can show
142
+ * it without consulting live state.
143
+ *
144
+ * The rows the settings panel paints come from `buildSettingRows`, which
145
+ * needs a value before it has decided anything else. A row that overrides
146
+ * `formatValue` is the exception: it renders from state the panel holds (the
147
+ * caret in an edit field, a recorded chord), so those rows still ask.
148
+ */
99
149
  private displayValue;
100
150
  private visibleSettings;
101
151
  private categorySettings;
102
152
  private categorySummaries;
153
+ /**
154
+ * The label column, sized once from the labels the pane currently shows
155
+ * rather than per row, so the value column is straight down the pane.
156
+ *
157
+ * The width comes from the current category, not from every setting the
158
+ * surface holds: a column sized by a category the user is not looking at
159
+ * would squeeze the values of the one they are.
160
+ *
161
+ * The column is the longest label plus the gap, capped so the value keeps
162
+ * its reserve. Deriving it from the labels means a short-label page
163
+ * gets a short column — the old fixed 14-cell floor padded every label out and
164
+ * pushed the values far from their own text.
165
+ */
166
+ private labelColumnWidth;
167
+ /**
168
+ * One settings row: the cursor, the label in its own column, then the value.
169
+ *
170
+ * The value is flush left rather than right-aligned, because pushing it to
171
+ * the far edge is what separates a label from its own value on a wide frame.
172
+ * A frame too narrow for both columns keeps the label whole and lets the
173
+ * value clip, since the label is what identifies the row.
174
+ */
175
+ private composeRow;
176
+ /** One content column: a body row indented to where labels start. */
177
+ private indented;
103
178
  private buildSettingRows;
104
179
  private visibleSettingWindow;
105
180
  private categoryWindow;
@@ -1,7 +1,7 @@
1
1
  import { box, text } from '../core/index.js';
2
2
  import { displayWidth, padEndDisplay, sanitizeTerminalText } from '../core/text_width.js';
3
3
  import { TERMINAL_THEME } from '../views/theme.js';
4
- import { bodyBand, isInsideBand, sideBySideBands, } from './panel_frame.js';
4
+ import { bodyBand, isInsideBand, sideBySideBands, widgetFrameGeometry, } from './panel_frame.js';
5
5
  const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
6
6
  /**
7
7
  * Labels a checkbox row spells out before it counts the rest. These rows sit in
@@ -9,6 +9,44 @@ const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
9
9
  * full list is one Enter away on the page that owns it.
10
10
  */
11
11
  const CHECKBOX_SUMMARY_LABELS = 3;
12
+ /**
13
+ * Columns between the longest label and the value column.
14
+ *
15
+ * Two blank columns read as a deliberate gap: one is indistinguishable from a
16
+ * label that happens to end one cell early, which is what made the value column
17
+ * look ragged across rows of different label lengths.
18
+ */
19
+ export const SETTINGS_VALUE_COLUMN_GAP = 2;
20
+ /**
21
+ * Cells the label column always leaves for the value.
22
+ *
23
+ * The cap on the label column comes from the pane, not from a fixed share of
24
+ * it: a label may use everything except this reserve, so one long label cannot
25
+ * push every value off the pane, yet a medium pane still shows a label such as
26
+ * "Compact threshold (% of window)" whole instead of clipping it at half width.
27
+ * Six cells fit a number, a percent or on/off; a longer value clips before the
28
+ * label does, because the label is what names the row.
29
+ */
30
+ const SETTINGS_VALUE_COLUMN_MIN = 6;
31
+ /**
32
+ * Floor on the label column, in cells.
33
+ *
34
+ * Clipping a label is worse than a wide gap: the label is what identifies the
35
+ * row, and the two-column table has nothing else to name it. This is the
36
+ * smallest column that still shows a label like "Notifications" whole on the
37
+ * narrowest pane the panel supports, so it stays a fixed cell count rather than
38
+ * a share of a width that can shrink below it.
39
+ */
40
+ export const SETTINGS_LABEL_COLUMN_MIN = 14;
41
+ /**
42
+ * Cells the `› ` cursor claims before a label.
43
+ *
44
+ * Every body row in the pane indents by this — the label, its description, the
45
+ * captured-key notice, and the option list — so the pane has one content
46
+ * column. Exported so a test can assert the rows agree instead of pinning the
47
+ * number twice.
48
+ */
49
+ export const SETTINGS_ROW_INDENT = 2;
12
50
  /**
13
51
  * Subsequence match, so "ctx" finds "Context left" and "stln" finds
14
52
  * "Status Line". Every whitespace-separated token must land somewhere in one of
@@ -165,12 +203,29 @@ export class SettingsPanelWidget {
165
203
  categoryBand = null;
166
204
  settingsBand = null;
167
205
  lastBodyRows = 1;
206
+ /**
207
+ * Column the painted frame starts on, in surface coordinates.
208
+ *
209
+ * Zero when the frame fills the width. On a terminal wide enough to trigger
210
+ * the cap it is the centring margin, and the wheel bands have to add it back:
211
+ * a click lands in terminal columns, so without it a capped page would scroll
212
+ * from a pane that is no longer under the pointer.
213
+ */
214
+ frameInset = 0;
168
215
  constructor(host) {
169
216
  this.host = host;
170
217
  }
218
+ isPage() {
219
+ return this.active?.layout === 'page';
220
+ }
171
221
  show(data) {
222
+ if (data.layout === 'page' && data.categories.length !== 1) {
223
+ throw new Error('A settings interaction page needs exactly one category.');
224
+ }
172
225
  this.active = data;
173
- this.filterText = data.initialFilter || '';
226
+ // Category deep links keep the full rail navigable. The filter still goes
227
+ // to the web mirror, where it selects that Settings section.
228
+ this.filterText = data.initialCategory ? '' : data.initialFilter || '';
174
229
  this.filterMode = false;
175
230
  this.cursorIndex = 0;
176
231
  this.expandedSetting = null;
@@ -182,7 +237,12 @@ export class SettingsPanelWidget {
182
237
  this.captureNotice = null;
183
238
  this.scrollOffset = 0;
184
239
  this.buildFlatList();
185
- this.applyFilter();
240
+ this.applyFilter(data.initialSetting);
241
+ if (data.initialCategory && !data.initialSetting) {
242
+ const categoryIndex = this.visibleRows.findIndex(row => (row.type === 'setting' && row.categoryName.toLowerCase() === data.initialCategory?.toLowerCase()));
243
+ if (categoryIndex >= 0)
244
+ this.cursorIndex = categoryIndex;
245
+ }
186
246
  this.host.render();
187
247
  }
188
248
  cancel() {
@@ -272,7 +332,7 @@ export class SettingsPanelWidget {
272
332
  const fields = [
273
333
  row.setting.label,
274
334
  row.setting.name,
275
- row.setting.value,
335
+ row.setting.secret ? '' : row.setting.value,
276
336
  row.categoryName,
277
337
  optionLabels,
278
338
  ];
@@ -489,21 +549,27 @@ export class SettingsPanelWidget {
489
549
  commitSettingValue(setting, value) {
490
550
  if (!this.active)
491
551
  return;
552
+ const page = this.active;
492
553
  const previousValue = setting.value;
493
554
  setting.value = value;
494
555
  try {
495
- void Promise.resolve(this.active.onSettingChange(setting.name, value)).then(() => this.refreshRows(setting.name), (error) => {
556
+ void Promise.resolve(page.onSettingChange(setting.name, value)).then(() => {
557
+ if (this.active === page)
558
+ this.refreshRows(setting.name);
559
+ }, (error) => {
496
560
  if (setting.value === value)
497
561
  setting.value = previousValue;
498
562
  this.host.addSystemMessage(`Setting failed: ${error.message}`);
499
- this.refreshRows(setting.name);
563
+ if (this.active === page)
564
+ this.refreshRows(setting.name);
500
565
  });
501
566
  }
502
567
  catch (error) {
503
568
  setting.value = previousValue;
504
569
  const message = error instanceof Error ? error.message : String(error);
505
570
  this.host.addSystemMessage(`Setting failed: ${message}`);
506
- this.refreshRows(setting.name);
571
+ if (this.active === page)
572
+ this.refreshRows(setting.name);
507
573
  }
508
574
  }
509
575
  handleInput(input, key) {
@@ -590,7 +656,7 @@ export class SettingsPanelWidget {
590
656
  this.host.render();
591
657
  return;
592
658
  }
593
- if (input === '/' && !key.ctrl && !key.meta) {
659
+ if (input === '/' && !this.isPage() && !key.ctrl && !key.meta) {
594
660
  this.filterMode = true;
595
661
  this.host.render();
596
662
  return;
@@ -625,7 +691,7 @@ export class SettingsPanelWidget {
625
691
  this.host.render();
626
692
  return;
627
693
  }
628
- if (input === '/') {
694
+ if (input === '/' && !this.isPage()) {
629
695
  this.filterMode = true;
630
696
  this.host.render();
631
697
  return;
@@ -679,6 +745,15 @@ export class SettingsPanelWidget {
679
745
  }
680
746
  this.host.render();
681
747
  }
748
+ /**
749
+ * A value that always reads the same for a given setting, so a row can show
750
+ * it without consulting live state.
751
+ *
752
+ * The rows the settings panel paints come from `buildSettingRows`, which
753
+ * needs a value before it has decided anything else. A row that overrides
754
+ * `formatValue` is the exception: it renders from state the panel holds (the
755
+ * caret in an edit field, a recorded chord), so those rows still ask.
756
+ */
682
757
  displayValue(setting) {
683
758
  if (setting.secret)
684
759
  return setting.value ? '••••' : '(not set)';
@@ -722,6 +797,49 @@ export class SettingsPanelWidget {
722
797
  .map(category => ({ name: category.name, count: counts.get(category.name) || 0 }))
723
798
  .filter(category => category.count > 0);
724
799
  }
800
+ /**
801
+ * The label column, sized once from the labels the pane currently shows
802
+ * rather than per row, so the value column is straight down the pane.
803
+ *
804
+ * The width comes from the current category, not from every setting the
805
+ * surface holds: a column sized by a category the user is not looking at
806
+ * would squeeze the values of the one they are.
807
+ *
808
+ * The column is the longest label plus the gap, capped so the value keeps
809
+ * its reserve. Deriving it from the labels means a short-label page
810
+ * gets a short column — the old fixed 14-cell floor padded every label out and
811
+ * pushed the values far from their own text.
812
+ */
813
+ labelColumnWidth(innerWidth) {
814
+ const labels = this.categorySettings(this.currentCategoryName())
815
+ .map(row => row.setting.label);
816
+ const longest = labels.reduce((widest, label) => Math.max(widest, displayWidth(label)), 0);
817
+ const room = Math.max(1, innerWidth - SETTINGS_ROW_INDENT);
818
+ const ceiling = room - SETTINGS_VALUE_COLUMN_GAP - SETTINGS_VALUE_COLUMN_MIN;
819
+ // The column is the longest label, kept between the floor and the ceiling.
820
+ // The floor wins over the ceiling: clipping a label costs more than a wide
821
+ // gap, because the label is the only thing naming the row.
822
+ return Math.min(Math.max(longest, SETTINGS_LABEL_COLUMN_MIN), Math.max(ceiling, SETTINGS_LABEL_COLUMN_MIN));
823
+ }
824
+ /**
825
+ * One settings row: the cursor, the label in its own column, then the value.
826
+ *
827
+ * The value is flush left rather than right-aligned, because pushing it to
828
+ * the far edge is what separates a label from its own value on a wide frame.
829
+ * A frame too narrow for both columns keeps the label whole and lets the
830
+ * value clip, since the label is what identifies the row.
831
+ */
832
+ composeRow(label, value, width, focused) {
833
+ const cursor = `${focused ? '›' : ' '} `.padEnd(SETTINGS_ROW_INDENT);
834
+ const labelWidth = Math.min(this.labelColumnWidth(width), Math.max(1, width - SETTINGS_ROW_INDENT));
835
+ const padded = padEndDisplay(clipText(label, labelWidth), labelWidth);
836
+ const gap = ' '.repeat(SETTINGS_VALUE_COLUMN_GAP);
837
+ return clipText(`${cursor}${padded}${gap}${value}`, width);
838
+ }
839
+ /** One content column: a body row indented to where labels start. */
840
+ indented(text, width) {
841
+ return clipText(`${' '.repeat(SETTINGS_ROW_INDENT)}${text}`, width);
842
+ }
725
843
  buildSettingRows(innerWidth) {
726
844
  const categoryName = this.currentCategoryName();
727
845
  const rows = categoryName ? this.categorySettings(categoryName) : [];
@@ -735,7 +853,7 @@ export class SettingsPanelWidget {
735
853
  for (const row of rows) {
736
854
  const current = row.setting.name === this.currentSetting()?.name;
737
855
  rendered.push({
738
- text: joinColumns(`${current ? '›' : ' '} ${row.setting.label}`, this.displayValue(row.setting), innerWidth),
856
+ text: this.composeRow(row.setting.label, this.displayValue(row.setting), innerWidth, current),
739
857
  focus: current && !this.expandedSetting && !this.editMode,
740
858
  style: current
741
859
  ? { bold: true, inverse: true }
@@ -743,16 +861,16 @@ export class SettingsPanelWidget {
743
861
  });
744
862
  // A typed row has nowhere else to explain itself, so the focused row
745
863
  // carries its own one-line note. The option list explains itself instead.
746
- if (current && row.setting.description && this.expandedSetting !== row.setting.name && !this.editMode) {
864
+ if (!this.isPage() && current && row.setting.description && this.expandedSetting !== row.setting.name && !this.editMode) {
747
865
  rendered.push({
748
- text: clipText(` ${row.setting.description}`, innerWidth),
866
+ text: this.indented(row.setting.description, innerWidth),
749
867
  focus: true,
750
868
  style: { color: TERMINAL_THEME.muted, dimColor: true },
751
869
  });
752
870
  }
753
871
  if (current && this.captureSetting === row.setting.name) {
754
872
  rendered.push({
755
- text: clipText(` ${this.captureNotice ?? 'Press a key…'}`, innerWidth),
873
+ text: this.indented(this.captureNotice ?? 'Press a key…', innerWidth),
756
874
  focus: true,
757
875
  style: this.captureNotice
758
876
  ? { color: TERMINAL_THEME.attention }
@@ -764,8 +882,13 @@ export class SettingsPanelWidget {
764
882
  const option = row.setting.options[optionIndex];
765
883
  const highlighted = optionIndex === this.expandedOptionIndex;
766
884
  const selected = option.value === row.setting.value || option.label === row.setting.value;
885
+ // The marker row and its description share one column: the option
886
+ // label sits after `› ● `, and the description lines up under it
887
+ // rather than two cells to its left.
888
+ const optionGutter = ' '.repeat(SETTINGS_ROW_INDENT);
889
+ const optionMarker = `${highlighted ? '›' : ' '} ${selected ? '●' : '○'} `;
767
890
  rendered.push({
768
- text: clipText(` ${highlighted ? '›' : ' '} ${selected ? '●' : '○'} ${option.label}`, innerWidth),
891
+ text: this.indented(`${optionGutter}${optionMarker}${option.label}`, innerWidth),
769
892
  focus: highlighted && !option.description,
770
893
  style: highlighted
771
894
  ? { bold: true, inverse: true }
@@ -773,7 +896,7 @@ export class SettingsPanelWidget {
773
896
  });
774
897
  if (highlighted && option.description) {
775
898
  rendered.push({
776
- text: clipText(` ${option.description}`, innerWidth),
899
+ text: this.indented(`${optionGutter}${' '.repeat(displayWidth(optionMarker))}${option.description}`, innerWidth),
777
900
  focus: true,
778
901
  style: { color: TERMINAL_THEME.muted, dimColor: true },
779
902
  });
@@ -784,7 +907,7 @@ export class SettingsPanelWidget {
784
907
  const value = row.setting.secret ? '•'.repeat(this.editValue.length) : this.editValue;
785
908
  const fallback = !value && row.setting.placeholder ? row.setting.placeholder : '';
786
909
  rendered.push({
787
- text: clipText(` › ${value || fallback}█`, innerWidth),
910
+ text: this.indented(`› ${value || fallback}█`, innerWidth),
788
911
  focus: true,
789
912
  style: { color: TERMINAL_THEME.info, bold: true },
790
913
  });
@@ -839,9 +962,15 @@ export class SettingsPanelWidget {
839
962
  if (this.filterMode)
840
963
  return 'Type to search · ↵ done · Esc keep · Ctrl+U clear';
841
964
  if (this.editMode)
842
- return 'Type a value · ↵ save · Esc cancel · Ctrl+U clear';
965
+ return this.isPage()
966
+ ? 'Type · ↵ save · Esc cancel'
967
+ : 'Type a value · ↵ save · Esc cancel · Ctrl+U clear';
843
968
  if (this.expandedSetting)
844
- return '↑↓ choose · PgUp/PgDn page · ↵ apply · Esc back';
969
+ return this.isPage()
970
+ ? '↑↓ choose · ↵ apply · Esc back'
971
+ : '↑↓ choose · PgUp/PgDn page · ↵ apply · Esc back';
972
+ if (this.isPage())
973
+ return '↑↓ move · ↵ edit/select · Esc back';
845
974
  return '↑↓ navigate · ←→ category · PgUp/PgDn page · ↵ change · / search · Esc close';
846
975
  }
847
976
  contextLabel(hiddenAbove, hiddenBelow) {
@@ -868,8 +997,18 @@ export class SettingsPanelWidget {
868
997
  // The host embeds the surface in a `paddingX: 1` column, so `cols - 2` is
869
998
  // the full usable width. No cap: wide terminals get a wider settings pane
870
999
  // rather than dead space beside the frame.
871
- const width = Math.max(1, termCols - 2);
872
- const desiredRows = Math.max(10, Math.min(22, this.visibleRows.length + 4));
1000
+ const page = this.isPage();
1001
+ // One frame width for every full-page surface, so the settings page lines up
1002
+ // with the pickers: they are opened and dismissed constantly and the user
1003
+ // sees one at a time, so a page drawn at a different width from its
1004
+ // neighbour would shift the frame sideways on every switch. The frame is
1005
+ // flush left, so the spare columns all sit to its right. The cap also keeps
1006
+ // this page's label and value columns close enough to read as one row.
1007
+ const { width, inset } = widgetFrameGeometry(termCols);
1008
+ this.frameInset = inset;
1009
+ const desiredRows = page
1010
+ ? Math.min(22, this.settingIndices().length + 4 + (this.active.notice ? 1 : 0))
1011
+ : Math.max(10, Math.min(22, this.visibleRows.length + 4));
873
1012
  const height = Math.max(1, Math.floor(maxRows ?? desiredRows));
874
1013
  const innerWidth = Math.max(0, width - 4);
875
1014
  const settings = this.settingIndices();
@@ -880,12 +1019,14 @@ export class SettingsPanelWidget {
880
1019
  return lines;
881
1020
  if (height === 2)
882
1021
  return [...lines, bottomBorder(width)];
883
- const showUtility = height >= 5;
884
- const fixedRows = showUtility ? 4 : 3;
1022
+ const showUtility = !page && height >= 5;
1023
+ const showNotice = page && Boolean(this.active.notice) && height >= 5;
1024
+ const showDetail = page && height >= (showNotice ? 6 : 5);
1025
+ const fixedRows = page ? 3 + Number(showNotice) + Number(showDetail) : showUtility ? 4 : 3;
885
1026
  const bodyRows = Math.max(0, height - fixedRows);
886
- const settingCapacity = Math.max(0, bodyRows - 1);
1027
+ const settingCapacity = Math.max(0, bodyRows - (page ? 0 : 1));
887
1028
  this.lastBodyRows = Math.max(1, bodyRows);
888
- const wide = width >= 70 && bodyRows >= 4;
1029
+ const wide = !page && width >= 70 && bodyRows >= 4;
889
1030
  const categoryWidth = wide
890
1031
  ? Math.max(20, Math.min(28, Math.floor(innerWidth * 0.3)))
891
1032
  : 0;
@@ -906,13 +1047,17 @@ export class SettingsPanelWidget {
906
1047
  : { color: TERMINAL_THEME.muted },
907
1048
  }], width, TERMINAL_THEME.bgElevated));
908
1049
  }
1050
+ if (showNotice) {
1051
+ lines.push(framedLine([{ text: clipText(this.active.notice || '', innerWidth),
1052
+ style: { color: TERMINAL_THEME.error, bold: true } }], width, TERMINAL_THEME.bgElevated));
1053
+ }
909
1054
  if (bodyRows > 0) {
910
1055
  const categoryName = this.currentCategoryName();
911
1056
  const categoryCount = categoryName ? this.categorySettings(categoryName).length : 0;
912
1057
  const heading = categoryName
913
1058
  ? `${categoryName.toUpperCase()} · ${categoryCount} setting${categoryCount === 1 ? '' : 's'}`
914
1059
  : (this.filterText ? 'NO MATCHES' : 'SETTINGS');
915
- const bodyTop = lines.length - 1;
1060
+ const bodyTop = page ? lines.length : lines.length - 1;
916
1061
  if (wide) {
917
1062
  const categoryOverflow = `${categoryWindow.hiddenAbove > 0 ? `↑${categoryWindow.hiddenAbove}` : ''}${categoryWindow.hiddenBelow > 0 ? `↓${categoryWindow.hiddenBelow}` : ''}`;
918
1063
  lines.push(framedLine([
@@ -921,7 +1066,7 @@ export class SettingsPanelWidget {
921
1066
  { text: padEndDisplay(clipText(heading, settingsWidth), settingsWidth), style: { color: TERMINAL_THEME.text, bold: true } },
922
1067
  ], width, TERMINAL_THEME.bgPanel));
923
1068
  }
924
- else {
1069
+ else if (!page) {
925
1070
  lines.push(framedLine([{ text: heading, style: { color: TERMINAL_THEME.text, bold: true } }], width, TERMINAL_THEME.bgPanel));
926
1071
  }
927
1072
  for (let index = 0; index < settingCapacity; index++) {
@@ -944,8 +1089,8 @@ export class SettingsPanelWidget {
944
1089
  // Wide: two columns sharing the body's rows. Narrow: one list, so only
945
1090
  // the settings band exists.
946
1091
  const bands = wide
947
- ? sideBySideBands(bodyTop, lines.length - 1, categoryWidth, settingsWidth)
948
- : { list: null, preview: bodyBand(bodyTop, lines.length - 1, settingsWidth) };
1092
+ ? sideBySideBands(bodyTop, lines.length - 1, categoryWidth, settingsWidth, inset)
1093
+ : { list: null, preview: bodyBand(bodyTop, lines.length - 1, settingsWidth, inset) };
949
1094
  this.categoryBand = bands.list;
950
1095
  this.settingsBand = bands.preview;
951
1096
  }
@@ -954,6 +1099,12 @@ export class SettingsPanelWidget {
954
1099
  this.categoryBand = null;
955
1100
  this.settingsBand = null;
956
1101
  }
1102
+ if (showDetail) {
1103
+ lines.push(framedLine([{
1104
+ text: clipText(this.currentSetting()?.description || '', innerWidth),
1105
+ style: { color: TERMINAL_THEME.muted, dimColor: true },
1106
+ }], width, TERMINAL_THEME.bgElevated));
1107
+ }
957
1108
  while (lines.length < height - 2)
958
1109
  lines.push(framedLine([], width));
959
1110
  lines.push(framedLine([{
@@ -996,19 +1147,26 @@ export class SettingsPanelWidget {
996
1147
  }
997
1148
  buildSurface(cols, maxRows) {
998
1149
  const lines = this.buildSurfaceLines(cols, maxRows);
1150
+ // `buildSurfaceLines` records the centring margin as a side effect; the
1151
+ // view re-emits it as a real margin so the painted frame and the wheel
1152
+ // bands agree on which column the surface starts at.
1153
+ const inset = this.frameInset;
1154
+ const frame = box(lines.map(line => box(line.segments.map(segment => text(segment.text, segment.style)), {
1155
+ flexDirection: 'row',
1156
+ height: 1,
1157
+ flexShrink: 0,
1158
+ backgroundColor: line.backgroundColor,
1159
+ })), {
1160
+ flexDirection: 'column',
1161
+ height: lines.length,
1162
+ flexShrink: 0,
1163
+ backgroundColor: TERMINAL_THEME.bg,
1164
+ });
999
1165
  return {
1000
1166
  content: lines.map(lineText).join('\n'),
1001
- view: box(lines.map(line => box(line.segments.map(segment => text(segment.text, segment.style)), {
1002
- flexDirection: 'row',
1003
- height: 1,
1004
- flexShrink: 0,
1005
- backgroundColor: line.backgroundColor,
1006
- })), {
1007
- flexDirection: 'column',
1008
- height: lines.length,
1009
- flexShrink: 0,
1010
- backgroundColor: TERMINAL_THEME.bg,
1011
- }),
1167
+ view: inset > 0
1168
+ ? box([frame], { flexDirection: 'column', marginLeft: inset, flexShrink: 0 })
1169
+ : frame,
1012
1170
  lineCount: lines.length,
1013
1171
  };
1014
1172
  }
@@ -51,6 +51,15 @@ export interface StatusLinePanelSurface {
51
51
  export declare class StatusLinePanelWidget {
52
52
  private host;
53
53
  private active;
54
+ /**
55
+ * Column the painted frame starts on, in surface coordinates.
56
+ *
57
+ * Zero unless the terminal is wide enough to trigger the shared cap, in which
58
+ * case it is that centring margin. The wheel bands add it back: a notch lands
59
+ * in terminal columns, so without it a capped page would scroll from a pane
60
+ * that is no longer under the pointer.
61
+ */
62
+ private frameInset;
54
63
  private draft;
55
64
  private side;
56
65
  private focus;