@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.
- package/README.md +43 -17
- package/bin/zmux-darwin-arm64 +0 -0
- package/bin/ztc-tui-darwin-arm64 +0 -0
- package/bin/zterm-darwin-arm64 +0 -0
- package/dist/App.js +19 -5
- package/dist/agent/agent.d.ts +5 -1
- package/dist/agent/agent.js +51 -8
- package/dist/agent/backends/account_pool.d.ts +5 -6
- package/dist/agent/backends/account_pool.js +39 -14
- package/dist/agent/backends/account_rotation.d.ts +3 -0
- package/dist/agent/backends/account_rotation.js +14 -3
- package/dist/agent/backends/codex_account_status.d.ts +42 -0
- package/dist/agent/backends/codex_account_status.js +196 -0
- package/dist/agent/backends/codex_app_server.d.ts +12 -2
- package/dist/agent/backends/codex_app_server.js +66 -6
- package/dist/agent/backends/index.d.ts +2 -0
- package/dist/agent/backends/index.js +13 -5
- package/dist/agent/backends/openai.d.ts +2 -2
- package/dist/agent/backends/openai.js +4 -3
- package/dist/agent/backends/openai_compatible.js +10 -10
- package/dist/agent/backends/openai_responses.d.ts +17 -5
- package/dist/agent/backends/openai_responses.js +2 -4
- package/dist/agent/backends/token_usage.js +1 -1
- package/dist/agent/backends/types.d.ts +3 -0
- package/dist/agent/commands/accounts.d.ts +7 -1
- package/dist/agent/commands/accounts.js +299 -101
- package/dist/agent/commands/config.js +11 -3
- package/dist/agent/commands/config_profile_switch.d.ts +2 -0
- package/dist/agent/commands/config_profile_switch.js +22 -0
- package/dist/agent/commands/context.js +4 -3
- package/dist/agent/commands/index.js +1 -2
- package/dist/agent/commands/input_mode.js +1 -1
- package/dist/agent/commands/model.js +7 -2
- package/dist/agent/commands/plan.d.ts +12 -0
- package/dist/agent/commands/plan.js +39 -28
- package/dist/agent/commands/profile.d.ts +1 -1
- package/dist/agent/commands/profile.js +34 -257
- package/dist/agent/commands/provider_choices.d.ts +12 -1
- package/dist/agent/commands/provider_choices.js +29 -2
- package/dist/agent/commands/provider_selection.d.ts +3 -0
- package/dist/agent/commands/provider_selection.js +63 -0
- package/dist/agent/commands/settings.d.ts +2 -0
- package/dist/agent/commands/settings.js +655 -50
- package/dist/agent/commands/types.d.ts +19 -2
- package/dist/agent/tools/ask_user.js +2 -2
- package/dist/app/agent_runner.d.ts +1 -1
- package/dist/app/agent_runner.js +2 -2
- package/dist/app/ask_user_response.d.ts +3 -0
- package/dist/app/ask_user_response.js +16 -0
- package/dist/app/command_context.js +29 -13
- package/dist/app/controller.d.ts +17 -2
- package/dist/app/controller.js +24 -1
- package/dist/app/selection_completion.d.ts +9 -3
- package/dist/app/selection_completion.js +54 -22
- package/dist/cli_args.js +10 -1
- package/dist/cli_main.js +85 -2
- package/dist/cli_ratatui.d.ts +22 -4
- package/dist/cli_ratatui.js +421 -146
- package/dist/config/defaults.d.ts +6 -0
- package/dist/config/defaults.js +20 -0
- package/dist/config/profiles.d.ts +9 -0
- package/dist/config/profiles.js +87 -0
- package/dist/config/types.d.ts +2 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +283 -40
- package/dist/headless.js +14 -0
- package/dist/panes/model_catalog.d.ts +1 -1
- package/dist/panes/model_catalog.js +24 -2
- package/dist/panes/ztc_launch.js +6 -0
- package/dist/router_auth.js +3 -2
- package/dist/session.d.ts +2 -0
- package/dist/session.js +3 -0
- package/dist/types.d.ts +23 -2
- package/dist/ui/views/app.d.ts +3 -1
- package/dist/ui/views/app.js +3 -2
- package/dist/ui/views/completion_menu.js +6 -6
- package/dist/ui/views/input_area.d.ts +2 -1
- package/dist/ui/views/input_area.js +9 -8
- package/dist/ui/views/status_bar.d.ts +2 -0
- package/dist/ui/views/status_bar.js +15 -4
- package/dist/ui/widgets/color_panel.d.ts +9 -0
- package/dist/ui/widgets/color_panel.js +41 -26
- package/dist/ui/widgets/navigator_panel.d.ts +9 -0
- package/dist/ui/widgets/navigator_panel.js +17 -16
- package/dist/ui/widgets/panel_frame.d.ts +83 -3
- package/dist/ui/widgets/panel_frame.js +107 -9
- package/dist/ui/widgets/questionnaire.d.ts +9 -0
- package/dist/ui/widgets/questionnaire.js +214 -16
- package/dist/ui/widgets/resume_panel.d.ts +9 -0
- package/dist/ui/widgets/resume_panel.js +18 -17
- package/dist/ui/widgets/rewind_panel.d.ts +9 -0
- package/dist/ui/widgets/rewind_panel.js +15 -16
- package/dist/ui/widgets/select.d.ts +6 -0
- package/dist/ui/widgets/select.js +47 -12
- package/dist/ui/widgets/settings_panel.d.ts +75 -0
- package/dist/ui/widgets/settings_panel.js +197 -39
- package/dist/ui/widgets/status_line_panel.d.ts +9 -0
- package/dist/ui/widgets/status_line_panel.js +18 -19
- package/dist/ui/widgets/transcript_preview.d.ts +8 -0
- package/dist/ui/widgets/transcript_preview.js +10 -0
- package/dist/utils/context.js +2 -0
- package/dist/utils/fireworks.d.ts +20 -0
- package/dist/utils/fireworks.js +23 -0
- package/dist/utils/models.d.ts +2 -1
- package/dist/utils/models.js +19 -0
- package/dist/web/accounts_mirror.d.ts +7 -0
- package/dist/web/accounts_mirror.js +59 -7
- package/dist/web/mirror_stream.js +19 -5
- package/dist/zlm-embed.mjs +5734 -5362
- package/package.json +2 -2
- package/dist/agent/commands/provider.d.ts +0 -2
- 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
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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)
|
|
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 -
|
|
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('
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
872
|
-
|
|
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
|
|
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:
|
|
1002
|
-
flexDirection: '
|
|
1003
|
-
|
|
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;
|