In-session Client TUI for statusline configuration

English | 简体中文
A Claude Code status line for Linux, WSL, Windows, and macOS. See model and reasoning effort, directory, Git, context, usage limits, tokens, and task timing at a glance. Configure the main line and individual subagent rows through an in-session editor, a terminal UI, a wizard, or the CLI.
Features · Screenshots · Quick installation · Common configuration · User guide · Troubleshooting
<a id="phase-4-in-stable-v150"></a> <a id="phase-5-in-stable-v160"></a> <a id="v161-clearer-external-tui-sections"></a>
Rendering uses Claude Code input and local state without making network requests or using model tokens. The question-and-answer wizard uses Claude model turns. See field definitions for data sources and availability.
<a id="界面预览"></a>
Component instances, copies and left/right layout captures.
Search, categories, source guidance and import review captures.
The main status line at the bottom of session screenshots shows actual data; configuration Preview regions use fixed samples. Fonts, colors and widths depend on terminal settings. Image sources and archive.
Linux terminal captures show the shared language selector and Chinese interface. These are reconstructions of actual PTY cells with sample previews; sources and all pages.



Run /statusline-configure-native inside the current Claude Code session, then click the Client region once before using the keyboard. The editor follows the Claude theme; choose the preview background to match the terminal and compare the selected palette. Compare terminal/theme combinations.
Linux: Main and the status line

Subagents: choose items and ordering for individual agent rows.

Settings: adjust appearance, refresh behavior and formatting.

Layout: set rows, item priorities and maximum widths.

Earlier theme captures used a dark preview surface; the current editor offers a separate light/dark preview choice. These images reconstruct real terminal cells. Capture provenance.


Main: select and reorder main status line items.

Subagents: choose items and ordering for individual agent rows.

Settings: adjust appearance, refresh behavior and formatting.

Layout: set rows, item priorities and maximum widths.

Only Main was supplied for this batch. The existing interaction limitation and checks are documented in macOS mouse reporting and Client focus.
Main: select and reorder main status line items.

Run /statusline-configure in Claude Code, or claude-statusline configure in a standalone terminal.
Previews use the current terminal background and selected palette. Compare ansi in Settings when default looks faint. These are terminal-output reconstructions from dedicated GNOME profiles; capture sources.
Light background, Palette: default

Light background, Palette: ansi

Dark background, Palette: default

Main: select and reorder main status line items.

Subagents: choose items and ordering for individual agent rows.

Settings: adjust appearance, refresh behavior and formatting.

Layout: set rows, item priorities and maximum widths.

Main: select and reorder main status line items.

Subagents: choose items and ordering for individual agent rows.

Settings: adjust appearance, refresh behavior and formatting.

Layout: set rows, item priorities and maximum widths.

Main: select and reorder main status line items.

Subagents: choose items and ordering for individual agent rows.

Settings: adjust appearance, refresh behavior and formatting.

Layout: set rows, item priorities and maximum widths.

Earlier screenshots and terminal reconstructions remain in the archive.
| Platform | Supported environment |
|---|---|
| Linux / WSL | Python 3.10+ |
| Windows 10/11 | CPython 3.10–3.14, x86/x64; windows-curses>=2.4.2 installs automatically |
| macOS 14+ | CPython 3.10–3.14, Intel / Apple Silicon |
Windows ARM devices can use x64 Python emulation; native ARM64 Python is outside the current support contract. Git information requires git.
Claude Code feature requirements: subagent rows 2.1.205+; local argument-based configuration and external TUI entry 2.1.258+; in-session Client 2.1.287+; native timing and advanced live-metrics collection 2.1.289+. Unsupported or unknown host versions suspend the corresponding integration. See requirements.
<a id="install-current-source"></a> <a id="install-from-a-release-recommended"></a> <a id="install-source-at-a-fixed-tag"></a> <a id="从-release-安装推荐"></a> <a id="从固定标签源码安装"></a> <a id="从当前源码安装"></a> <a id="升级与卸载"></a> <a id="常用配置"></a> <a id="快速安装"></a> <a id="接入-claude-code"></a> <a id="支持范围"></a> <a id="文档与帮助"></a> <a id="许可证"></a>
Install Python, Claude Code CLI, and pipx. All supported platforms use the same wheel.
Bash, Zsh, and PowerShell:
pipx install fbincon-claude-code-statusline
pipx ensurepath
Reopen the terminal so PATH changes take effect, then run:
claude-statusline --version
claude-statusline install --dry-run
claude-statusline install
claude-statusline doctor
On Windows, use claude-statusline.exe. Restart Claude Code in a trusted terminal after integration.
Both editors default on for compatible hosts, respecting saved disablement preferences. Native timing metadata defaults on for Claude Code 2.1.289+; advanced live metrics remain opt-in. Package installation and Claude integration are separate steps; install does not open an editor. Existing conflicting resources require explicit handling.
Source development on main uses Node and TypeScript; follow the source development guide. Fixed-tag and Release wheel installation for the published Python product remains documented in the installation guide.
Download checksums and platform-specific instructions are in the installation guide; source builds are in the development guide.
Find items, understand their sources, and review imports.
| Entry point | Use |
|---|---|
/statusline-configure-native | Client TUI in the current session; see native editor |
/statusline-configure | TUI in a supported external terminal; see external entry |
claude-statusline configure | Full TUI in the current standalone terminal |
/statusline-config | Claude question-and-answer wizard; supported argument-based commands execute locally on compatible hosts |
claude-statusline config ... | Inspect settings, set exact ordering, or configure from scripts |
<a id="v130a2-external-tui-and-in-session-client"></a>
Both editors have Interface language (saved immediately) in Settings, with choices English / 简体中文 / 繁體中文 / 日本語 / 한국어. The default is English. Switching keeps your page, selection, search and unsaved display edits; cancelling display changes keeps the language choice. Other open windows read it when reopened or reloaded.
claude-statusline config language set ja
claude-statusline config language show
claude-statusline config language reset
claude-statusline --language en --help
--language en|zh-CN|zh-Hant|ja|ko precedes the command and affects only that invocation; for configure it sets the initial language. Commands, IDs, configuration values and custom text retain their values. Interface language does not change output language. See language settings.
Choose Statusline language (save with display settings) → English / 简体中文 / 繁體中文 / 日本語 / 한국어 in either editor. Preview switches immediately; Save changes the actual main and subagent output on the next refresh, while Cancel discards the draft. English is the default.
claude-statusline config set statusline-language zh-Hant
claude-statusline config set statusline-language en


These are real CLI terminal captures with fixed input.
Built-in labels and known states are translated. Technical units, model names, branches, paths and custom text are preserved. See output language.
Click the Client region once. Use Tab to change pages, Space to toggle, arrows to select or reorder, Ctrl+E for item formatting and source guidance, / to search, and Ctrl+F for categories. Clear filters before reordering. S saves and stays, F saves and closes, and Q discards unsaved changes; lowercase letters work too. Footer controls follow the current page or input mode. Ctrl+G cancels input. Claude preferences apply separately.
Use Tab to change pages, Space to toggle, and arrows to select or reorder. Ctrl+S saves after field input is finished; Enter saves on item and original settings rows, or edits/confirms advanced fields. Esc cancels input first, then cancels the editor; Ctrl+C interrupts without saving. The minimum terminal size is 64×18.
The external entry uses tmux or GNOME Terminal on Linux, tmux or Terminal.app on macOS, and the system's new-console launcher on Windows. For SSH or unavailable launchers, run claude-statusline configure in the current terminal.
Define a compact main line:
claude-statusline config set-items model-with-effort current-dir git context-remaining task-timer
claude-statusline config set directory-style home
claude-statusline config show
Configuration is per user. set-items replaces the enabled set; enable and disable make incremental changes. See recipes, formatting and layouts, and the CLI reference.
<a id="upgrade-to-v161"></a>
Upgrade the package, synchronize integration, then restart Claude Code:
pipx upgrade fbincon-claude-code-statusline
claude-statusline install
claude-statusline doctor
Existing wheel installations from this repository should first follow the package-name migration. Saved display settings, runtime state, and integration preferences remain. For older display schemas or package downgrade, follow version compatibility.
Remove Claude integration before uninstalling the package:
claude-statusline uninstall --dry-run
claude-statusline uninstall
pipx uninstall fbincon-claude-code-statusline
Display settings and backups remain available; see uninstalling.
claude-code-statusline/
├── .github/workflows/
│ ├── ci.yml # Candidate build, eight installations and thirteen hosts
│ └── reference.yml # Manual historical Python verification
├── packages/ # Four business packages
│ ├── core/src/ # Configuration, protocols, pure state, rendering and Unicode
│ │ └── i18n/ # Five generated languages; sources in core/resources
│ ├── platform/src/ # Files, locks, processes, clocks and terminal adapters
│ ├── cli/ # Commands, integration, runtime and recovery
│ │ ├── src/ # Installation and editor protocols
│ │ └── resources/ # Two configuration skill templates
│ └── tui/src/ # Standalone terminal editor
├── mods/
│ ├── statusline-native/ # In-session editor: hooks, lib, ui, tests
│ └── statusline-runtime/ # Timing and optional live metrics
├── tests/fixtures/ # Shared contracts, Unicode and frozen reference data
├── tools/
│ ├── generation/ # Shared resource generation
│ ├── checks/ # Documentation, archive and aggregate checks
│ ├── build/ # Fixed sources, universal offline packaging and digests
│ ├── acceptance/ # Installation, Mods, terminals, power and budgeted acceptance
│ ├── benchmarks/ # Functional fixtures and optional descriptive measurement
│ ├── release/ # Candidate evidence and release-note preparation
│ ├── migration/ # Stage ledgers, frozen references and compatibility tests
│ ├── testing/ # Development-only test support package
│ ├── typescript/ # Runtime constraints and test entry points
│ └── unicode/ # Pinned data, provenance and licenses
├── archive/python/ # Frozen Python implementation, tests, tools and publishing workflows
├── docs/ # Usage, development, migration and historical releases
│ ├── reference/ # CLI and configuration reference
│ ├── development/ # Architecture, testing and stage records
│ ├── images/ # UI screenshots and historical captures
│ └── releases/ # Historical release notes
├── package.json / package-lock.json
├── tsconfig*.json / .node-version
├── README.md / README.zh-CN.md
├── CHANGELOG.md / CHANGELOG.zh-CN.md
└── LICENSE
MIT License. Copyright (c) 2026 fbincon.
Bundled Unicode data retains the Unicode License v3 and the wcwidth MIT notice.
hooks/register.ts 821 lines1import type {
2 Register,
3 EngineInterface,
4 PluginOptions,
5 Timer,
6} from 'claude-code';
7import {
8 BackendError,
9 parseResponse,
10 processFailure,
11 requestText,
12} from '../lib/backend.ts';
13import { Editor, copyDraft, sameDraft } from '../lib/editor/draft.ts';
14import { createReview } from '../lib/editor/import-review.ts';
15import { canEdit, validPreferenceValue, preferences, preferenceChanged } from '../lib/preferences.ts';
16import type {
17 Draft,
18 Operation,
19 ResultFor,
20} from '../lib/generated-contracts.ts';
21import { MIN_COLUMNS, MIN_ROWS, contentColumns } from '../ui/layout.ts';
22import { recoveryButton, styles } from '../ui/theme.ts';
23import { clientProps } from '../lib/session.ts';
24import type { View } from '../lib/session.ts';
25import { parseBatch } from '../lib/client/messages.ts';
26import { handleKey } from '../lib/client/keys.ts';
27import { DEFAULT_PREVIEW_BACKGROUND, PREVIEW_BACKGROUND_KEY, previewBackground } from '../lib/preview-preferences.ts';
28import type { PreviewBackground } from '../lib/preview-preferences.ts';
29import { text as localizedText, t } from '../lib/i18n/index.ts';
30import type { Language } from '../lib/i18n/index.ts';
31import { setMessage, failureMessage, setResult, viewMessage } from '../lib/i18n/messages.ts';
32import { binding } from '../lib/launch.ts';
33
34const PANE = 'statusline-native';
35const COMMAND = 'statusline-configure-native';
36
37
38async function callBackend<O extends Operation>(
39 $: EngineInterface,
40 options: PluginOptions,
41 operation: O,
42 payload: unknown = {},
43): Promise<ResultFor<O>> {
44 try {
45 const launch = binding(options, await $.env.get('CLAUDE_STATUSLINE_NATIVE_EXECUTABLE') || undefined);
46 const directory =
47 (await $.env.get('CLAUDE_CONFIG_DIR')) ||
48 (typeof options.configDir === 'string' ? options.configDir : undefined);
49 const argv = directory
50 ? [launch.executable, ...launch.args, 'ui', '--config-dir', directory]
51 : [launch.executable, ...launch.args, 'ui'];
52 const output = await $.process.run(argv, {
53 stdin: requestText(operation, payload),
54 timeoutMs: 30000,
55 });
56 const result = parseResponse(operation, output);
57 if (
58 'backend_version' in result &&
59 typeof options.backendVersion === 'string' &&
60 options.backendVersion &&
61 result.backend_version !== options.backendVersion
62 ) {
63 throw new BackendError(
64 'backend_version_mismatch',
65 localizedText("native.hooks.register.the_installed_mod_and_backend_versions_differ_reinstall"),
66 );
67 }
68 return result;
69 } catch (error) {
70 throw processFailure(error);
71 }
72}
73
74function emptyView(): View {
75 return {
76 language: 'en',
77 previewBackground: DEFAULT_PREVIEW_BACKGROUND,
78 editor: null,
79 input: null,
80 preview: null,
81 preferences: [],
82 preferencesError: '',
83 error: '',
84 previewError: '',
85 previewBusy: false,
86 message: '',
87 busy: '',
88 uncertain: false,
89 };
90}
91
92function failureText(failure: unknown): string {
93 const error = processFailure(failure);
94 return `${error.code}: ${error.message}`.replace(
95 /[\x00-\x08\x0b-\x1f\x7f]/g,
96 '',
97 );
98}
99
100interface Session {
101 uiLanguage: Language;
102 previewBackground: PreviewBackground;
103 ownsCommand: boolean;
104 view: View;
105 ack: number;
106 generation: number;
107 columns: number;
108 rows: number;
109 placed: boolean;
110 clientFault: string;
111 inputChain: Promise<void>;
112 epoch: number;
113 previewKey: string;
114 previewTimer: Timer | null;
115 writing: boolean;
116 pendingSave: { draft: Draft; revision: string } | null;
117}
118
119function discard(state: Session): void {
120 state.previewTimer?.cancel();
121 state.previewTimer = null;
122 state.epoch += 1;
123 state.view = emptyView();
124 state.previewKey = '';
125 state.writing = false;
126 state.pendingSave = null;
127 state.ack = 0;
128 state.placed = false;
129 state.clientFault = '';
130}
131
132function props(state: Session) {
133 return clientProps(
134 state.view,
135 state.epoch,
136 state.ack,
137 ++state.generation,
138 state.columns,
139 state.rows,
140 );
141}
142
143async function load(
144 $: EngineInterface,
145 state: Session,
146 options: PluginOptions,
147) {
148 discard(state);
149 const opening = state.epoch;
150 setMessage(state.view, "busy", localizedText("native.hooks.register.loading_configuration"));
151 $.ui.invalidate('ui.render');
152 try {
153 const description = await callBackend($, options, 'describe');
154 const current = await callBackend($, options, 'read');
155 if (state.epoch !== opening) return;
156 if (description.backend_version !== current.backend_version) {
157 throw new BackendError(
158 'backend_version_mismatch',
159 localizedText("native.hooks.register.the_backend_changed_while_opening_reopen_the_editor"),
160 );
161 }
162 state.view.editor = new Editor(description, current);
163 try {
164 const preference = await callBackend($, options, 'read_ui_preferences');
165 if (state.epoch !== opening) return;
166 state.uiLanguage = preference.ui_language;
167 state.view.language = preference.ui_language;
168 if (preference.warning) setMessage(state.view, 'message', preference.warning);
169 } catch (failure) {
170 if (state.epoch === opening) {
171 state.view.language = state.uiLanguage;
172 setMessage(state.view, 'message', localizedText('native.language_read_failed', { detail: failureMessage(failure) }));
173 }
174 }
175 try {
176 const background = previewBackground(await $.store.get(PREVIEW_BACKGROUND_KEY));
177 if (state.epoch === opening) {
178 state.previewBackground = background;
179 state.view.previewBackground = background;
180 }
181 } catch {
182 if (state.epoch === opening) {
183 state.view.previewBackground = state.previewBackground;
184 setMessage(state.view, "message", localizedText("native.hooks.register.preview_background_preference_unavailable_using_the_last_choice"));
185 }
186 }
187 if (!current.installed)
188 setMessage(state.view, "message", localizedText("native.hooks.register.the_renderer_is_not_installed_run_claude_statusline"));
189 try {
190 const rows = await $.config.list();
191 if (state.epoch === opening) state.view.preferences = preferences(rows);
192 } catch (failure) {
193 if (state.epoch === opening)
194 setMessage(state.view, "preferencesError", localizedText("native.hooks.register.host_preferences_unavailable", {value0: failureMessage(failure)}));
195 }
196 } catch (failure) {
197 if (state.epoch === opening) setMessage(state.view, "error", failureMessage(failure));
198 } finally {
199 if (state.epoch === opening) {
200 state.view.busy = '';
201 $.ui.invalidate('ui.render');
202 }
203 }
204}
205
206async function reconcile(
207 $: EngineInterface,
208 state: Session,
209 options: PluginOptions,
210) {
211 const editor = state.view.editor;
212 const submitted = state.pendingSave;
213 if (!editor || !submitted || state.writing) return;
214 const saving = state.epoch;
215 state.writing = true;
216 setMessage(state.view, "busy", localizedText("native.hooks.register.checking_saved_configuration"));
217 $.ui.invalidate('ui.render');
218 try {
219 const current = await callBackend($, options, 'read');
220 if (state.epoch !== saving) return;
221 if (
222 sameDraft(current.draft, submitted.draft) &&
223 current.installed &&
224 JSON.stringify(current.installation) ===
225 JSON.stringify(editor.baseline.installation)
226 ) {
227 editor.committed(current);
228 setMessage(state.view, "message", localizedText("native.hooks.register.the_submitted_tool_configuration_is_saved"));
229 state.view.error = '';
230 } else if (current.revision === submitted.revision) {
231 setMessage(state.view, "message", localizedText("native.hooks.register.no_tool_changes_were_committed_review_the_draft"));
232 state.view.error = '';
233 } else {
234 setMessage(state.view, "error", localizedText("native.hooks.register.configuration_conflict_saved_configuration_differs_discard_the_draft"));
235 }
236 state.pendingSave = null;
237 state.view.uncertain = false;
238 } catch (failure) {
239 if (state.epoch === saving)
240 setMessage(state.view, "error", localizedText("native.hooks.register.save_outcome_remains_unknown", {value0: failureMessage(failure)}));
241 } finally {
242 if (state.epoch === saving) {
243 state.writing = false;
244 state.view.busy = '';
245 $.ui.invalidate('ui.render');
246 }
247 }
248}
249
250async function save(
251 $: EngineInterface,
252 state: Session,
253 options: PluginOptions,
254 finish = false,
255) {
256 const editor = state.view.editor;
257 if (!editor || state.writing || state.view.uncertain) return;
258 if (!editor.acceptNumeric()) {
259 setMessage(state.view, "error", localizedText("native.hooks.register.correct_the_numeric_fields_before_saving"));
260 editor.page = 'settings';
261 editor.setting = Object.keys(editor.fieldErrors)[0] || 'padding';
262 $.ui.invalidate('ui.render');
263 return;
264 }
265 const saving = state.epoch;
266 const submitted = {
267 draft: copyDraft(editor.draft),
268 revision: editor.baseline.revision,
269 };
270 state.pendingSave = submitted;
271 state.writing = true;
272 setMessage(state.view, "busy", localizedText("native.hooks.register.saving_tool_configuration"));
273 state.view.error = '';
274 let saved = false;
275 $.ui.invalidate('ui.render');
276 try {
277 const result = await callBackend($, options, 'apply', {
278 draft: submitted.draft,
279 expected_revision: submitted.revision,
280 });
281 if (state.epoch !== saving) return;
282 editor.committed(result);
283 state.pendingSave = null;
284 saved = true;
285 setMessage(state.view, "message", (result.changed ? localizedText("native.hooks.register.tool_configuration_saved_later_statusline_refreshes_use_these") : localizedText("native.hooks.register.tool_configuration_is_already_current")));
286 } catch (failure) {
287 if (state.epoch !== saving) return;
288 const error = processFailure(failure);
289 setMessage(state.view, "error", failureMessage(error));
290 const refused = [
291 'invalid_request',
292 'invalid_configuration',
293 'configuration_conflict',
294 'not_installed',
295 'ownership_mismatch',
296 'unsupported_protocol',
297 'unsupported_operation',
298 ].includes(error.code);
299 state.view.uncertain = !refused;
300 if (refused) state.pendingSave = null;
301 } finally {
302 if (state.epoch === saving) {
303 state.writing = false;
304 state.view.busy = '';
305 $.ui.invalidate('ui.render');
306 }
307 }
308 if (finish && saved && state.epoch === saving) {
309 if (state.view.preferences.some(preferenceChanged)) {
310 editor.page = 'settings';
311 editor.advanced = true;
312 editor.setting =
313 'host-' + state.view.preferences.find(preferenceChanged)!.row.key;
314 setMessage(state.view, "message", localizedText("native.hooks.register.tool_configuration_saved_host_changes_pending_a_apply"));
315 $.ui.invalidate('ui.render');
316 } else await close($, state);
317 }
318}
319
320async function transferDraft($: EngineInterface, state: Session, options: PluginOptions) {
321 const e = state.view.editor;
322 const action = e?.pendingTransfer;
323 if (!e || !action || state.writing || state.view.uncertain) return;
324 const epoch = state.epoch;
325 e.pendingTransfer = null;
326 state.writing = true;
327 setMessage(state.view, "busy", (action === 'export' ? localizedText("native.hooks.register.exporting_current_draft_may_be_unsaved") : localizedText("native.hooks.register.loading_draft")));
328 $.ui.invalidate('ui.render');
329 try {
330 if (action === 'import') {
331 const original = copyDraft(e.draft);
332 const candidate = new Editor(e.description, {...e.baseline, draft:copyDraft(e.draft)});
333 candidate.buffers = {...e.buffers};
334 if (!candidate.acceptNumeric()) { setMessage(state.view, 'message', localizedText('native.hooks.register.correct_numeric_fields_first')); return; }
335 const result = await callBackend($, options, 'review_import', {draft:copyDraft(candidate.draft), path:e.path});
336 if (state.epoch === epoch && state.view.editor === e) {
337 if (!sameDraft(e.draft, original)) { setMessage(state.view, 'message', localizedText('review.stale')); return; }
338 e.review = createReview(result);
339 state.view.preview = null;
340 state.previewKey = '';
341 state.view.message = '';
342 }
343 return;
344 }
345 if (!e.acceptNumeric()) { setMessage(state.view, "message", localizedText("native.hooks.register.correct_numeric_fields_first")); return; }
346 const payload = action === 'preset' ? { draft: copyDraft(e.draft), preset: e.preset } : { draft: copyDraft(e.draft), path: e.path, overwrite: false };
347 if (action === 'export') {
348 const result = await callBackend($, options, action, payload);
349 if (state.epoch === epoch) setMessage(state.view, "message", localizedText("native.hooks.register.exported_current_draft_may_be_unsaved", {path: result.path}));
350 } else {
351 const result = await callBackend($, options, action, payload);
352 if (state.epoch === epoch) {
353 e.replaceDraft(result.draft);
354 setMessage(state.view, "message", localizedText("native.hooks.register.draft_replaced_review_preview_and_save_or_discard"));
355 state.previewKey = '';
356 }
357 }
358 } catch (failure) {
359 if (state.epoch === epoch) setMessage(state.view, "message", failureMessage(failure));
360 } finally {
361 if (state.epoch === epoch) {
362 state.writing = false;
363 state.view.busy = '';
364 }
365 }
366}
367
368async function applyPreferences(
369 $: EngineInterface,
370 state: Session,
371 options: PluginOptions,
372) {
373 if (state.writing || state.view.uncertain) return;
374 const applying = state.epoch;
375 state.writing = true;
376 setMessage(state.view, "busy", localizedText("native.hooks.register.applying_host_preferences"));
377 state.view.message = '';
378 state.view.preferencesError = '';
379 $.ui.invalidate('ui.render');
380 try {
381 for (const preference of state.view.preferences.filter(preferenceChanged)) {
382 const rows = await $.config.list();
383 if (state.epoch !== applying) return;
384 const latest = rows.find((row) => row.key === preference.row.key);
385 if (!latest) {
386 setResult(preference, localizedText("native.hooks.register.unavailable_not_applied"));
387 continue;
388 }
389 if (
390 JSON.stringify(latest.value) !== JSON.stringify(preference.baseline) ||
391 latest.kind !== preference.row.kind ||
392 JSON.stringify(latest.provider) !==
393 JSON.stringify(preference.row.provider)
394 ) {
395 setResult(preference, localizedText("native.hooks.register.changed_elsewhere_discard_and_reload_before_applying"));
396 continue;
397 }
398 preference.row = latest;
399 if (!canEdit(preference)) {
400 setResult(preference, (latest.isLocked ? localizedText("native.hooks.register.locked_by_host_policy_not_applied") : localizedText("native.hooks.register.unsupported_control_not_applied")));
401 continue;
402 }
403 if (!validPreferenceValue(preference)) {
404 setResult(preference, localizedText("native.hooks.register.value_no_longer_fits_this_row_discard_and"));
405 continue;
406 }
407 try {
408 const result = await $.config.set({
409 key: latest.key,
410 value: preference.value,
411 });
412 if (state.epoch !== applying) return;
413 if (result.deny !== undefined) {
414 setResult(preference, localizedText("native.hooks.register.refused", {deny: result.deny}));
415 } else {
416 preference.baseline = result.value;
417 preference.value = result.value;
418 preference.row = { ...latest, value: result.value };
419 setResult(preference, localizedText("native.hooks.register.applied"));
420 }
421 } catch (failure) {
422 if (state.epoch !== applying) return;
423 setResult(preference, localizedText("native.hooks.register.could_not_confirm_application_discard_and_reload_to", {value0: failureMessage(failure)}));
424 }
425 }
426 } catch (failure) {
427 if (state.epoch === applying)
428 setMessage(state.view, "preferencesError", failureMessage(failure));
429 } finally {
430 if (state.epoch === applying) {
431 state.writing = false;
432 state.view.busy = '';
433 $.ui.invalidate('ui.render');
434 }
435 }
436}
437
438async function close($: EngineInterface, state: Session) {
439 // A plugin's own API calls skip its hooks; guard programmatic close here too.
440 if (state.writing || state.view.uncertain) {
441 setMessage(state.view, "error", (state.writing ? localizedText("native.hooks.register.wait_for_the_apply_result_before_closing") : localizedText("native.hooks.register.check_the_saved_state_before_closing_the_save")));
442 $.ui.invalidate('ui.render');
443 return;
444 }
445 try {
446 await $.ui.close({ id: PANE });
447 const open = await $.ui.panes();
448 if (!open.some((p) => p.id === PANE)) discard(state);
449 } catch (failure) {
450 setMessage(state.view, "error", failureMessage(failure));
451 $.ui.invalidate('ui.render');
452 }
453}
454
455async function openEditor(
456 $: EngineInterface,
457 state: Session,
458 options: PluginOptions,
459) {
460 const tr = (key: string, params: Record<string, unknown> = {}) => t(key, state.view.language ?? 'en', params);
461
462 if (state.writing || state.view.uncertain)
463 return {
464 text: tr("native.hooks.register.finish_checking_the_pending_save_before_reopening_the"),
465 };
466 if (!state.placed) {
467 const opening = state.epoch + 1;
468 await load($, state, options);
469 if (state.epoch !== opening) return {};
470 }
471 const opened = await $.ui.open({
472 id: PANE,
473 title: tr("native.hooks.register.statusline_configuration"),
474 focus: true,
475 closeOnEscape: true,
476 rows: 24,
477 columns: 72,
478 });
479 state.placed = opened.isPlaced;
480 if (!opened.isPlaced) return { text: opened.reason };
481 return {};
482}
483
484export const register: Register = (on, options) => {
485 const tr = (key: string, params: Record<string, unknown> = {}) => t(key, state.view.language ?? 'en', params);
486
487 const state: Session = {
488 uiLanguage: 'en',
489 previewBackground: DEFAULT_PREVIEW_BACKGROUND,
490 ownsCommand: false,
491 view: emptyView(),
492 epoch: 0,
493 ack: 0,
494 generation: 0,
495 columns: 72,
496 rows: 24,
497 placed: false,
498 clientFault: '',
499 inputChain: Promise.resolve(),
500 previewKey: '',
501 previewTimer: null,
502 writing: false,
503 pendingSave: null,
504 };
505
506 on('session.start', async ($, e, next) => {
507 state.ownsCommand = false;
508 discard(state);
509 const open = await $.ui.panes();
510 if (open.some((p) => p.id === PANE)) {
511 await $.ui.close({ id: PANE });
512 $.ui.toast('Statusline editor reloaded. Unsaved drafts were discarded.');
513 }
514 const commands = await $.command.list();
515 const collision = commands.find((command) => command.name === COMMAND);
516 if (collision && collision.plugin !== 'statusline-native') {
517 $.ui.log(
518 '/statusline-configure-native belongs to another command; the Client entry is inactive.',
519 );
520 } else {
521 await $.command.register({
522 name: 'statusline-configure-native',
523 description: tr("native.hooks.register.open_the_experimental_in_session_client_tui"),
524 });
525 state.ownsCommand = true;
526 }
527 return next(e);
528 });
529
530 on(
531 'command.run',
532 { command: 'statusline-configure-native' },
533 async ($, e, next) => {
534 if (!state.ownsCommand) return next(e);
535 return openEditor($, state, options);
536 },
537 );
538
539 on('ui.close', { id: 'statusline-native' }, async ($, e, next) => {
540 if (e.origin.kind !== 'unload' && (state.writing || state.view.uncertain)) {
541 setMessage(state.view, "error", (state.writing ? localizedText("native.hooks.register.wait_for_the_apply_result_before_closing") : localizedText("native.hooks.register.check_the_saved_state_before_closing_the_save")));
542 $.ui.invalidate('ui.render');
543 return { deny: state.view.error };
544 }
545 const result = await next(e);
546 const open = await $.ui.panes();
547 if (!open.some((p) => p.id === PANE)) discard(state);
548 return result;
549 });
550
551 on('ui.message', { component: 'Pane' }, async ($, e, next) => {
552 if (
553 e.requestId !== PANE ||
554 e.element !== 'statusline-client' ||
555 e.module !== 'ui/client/surface.ts' ||
556 e.surface !== 'terminal' ||
557 !state.ownsCommand
558 )
559 return next(e);
560 const fault = e.data as { epoch?: unknown; fault?: unknown } | null;
561 if (
562 fault &&
563 typeof fault === 'object' &&
564 Object.keys(fault).sort().join(',') === 'epoch,fault' &&
565 fault.epoch === state.epoch &&
566 typeof fault.fault === 'string' &&
567 fault.fault.length > 0 &&
568 fault.fault.length <= 200
569 ) {
570 state.clientFault = failureText(fault.fault);
571 $.ui.invalidate('ui.render');
572 return {};
573 }
574 const batch = parseBatch(e.data);
575 if (!batch) {
576 setMessage(state.view, "error", localizedText("native.hooks.register.invalid_client_input_retry_the_client_region"));
577 return { props: props(state) };
578 }
579 const run = state.inputChain.then(async () => {
580 if (batch.epoch !== state.epoch || !state.placed) return {};
581 const currentEpoch = state.epoch;
582 for (const message of batch.events) {
583 if (message.seq <= state.ack) continue;
584 if (message.seq !== state.ack + 1) {
585 setMessage(state.view, "error", localizedText("native.hooks.register.client_input_sequence_is_incomplete_retry_the_client"));
586 break;
587 }
588 state.ack = message.seq;
589 state.view.message = '';
590 const effect = handleKey(
591 state.view,
592 message.event,
593 batch.columns,
594 batch.rows,
595 );
596 if (effect === 'save' || effect === 'finish') {
597 await save($, state, options, effect === 'finish');
598 } else if (effect === 'close') await close($, state);
599 else if (effect === 'reload') {
600 await load($, state, options);
601 state.placed = true;
602 } else if (effect === 'reconcile') await reconcile($, state, options);
603 else if (effect === 'transfer') await transferDraft($, state, options);
604 else if (effect === 'applyPreferences')
605 await applyPreferences($, state, options);
606 else if (effect === 'retry') state.previewKey = '';
607 else if (effect === 'reviewPreview') {
608 state.previewTimer?.cancel();
609 state.previewKey = '';
610 state.view.preview = null;
611 state.view.previewError = '';
612 }
613 else if (effect === 'uiLanguage') {
614 const requested = state.view.language ?? 'en';
615 try {
616 const preference = await callBackend($, options, 'set_ui_language', {ui_language: requested});
617 if (state.epoch === currentEpoch) {
618 state.uiLanguage = preference.ui_language;
619 state.view.language = preference.ui_language;
620 if (state.view.localized?.error?.key === 'native.language_save_failed') state.view.error = '';
621 setMessage(state.view, 'message', localizedText('native.language_saved'));
622 }
623 } catch (failure) {
624 if (state.epoch === currentEpoch) {
625 state.view.language = state.uiLanguage;
626 setMessage(state.view, 'error', localizedText('native.language_save_failed', {detail: failureMessage(failure)}));
627 }
628 }
629 }
630 else if (effect === 'previewBackground') {
631 const background = previewBackground(state.view.previewBackground);
632 try {
633 await $.store.set(PREVIEW_BACKGROUND_KEY, background);
634 if (state.epoch === currentEpoch) {
635 state.previewBackground = background;
636 if (state.view.localized?.error?.key === 'native.remember_preview_failed') state.view.error = '';
637 setMessage(state.view, "message", localizedText("native.hooks.register.preview_background_remembered_status_line_colors_unchanged"));
638 }
639 } catch {
640 if (state.epoch === currentEpoch) {
641 state.view.previewBackground = state.previewBackground;
642 setMessage(state.view, 'error', localizedText('native.remember_preview_failed'));
643 }
644 }
645 }
646 if (state.epoch !== currentEpoch) break;
647 }
648 $.ui.invalidate('ui.render');
649 if (state.epoch !== currentEpoch) return {};
650 return { props: props(state) };
651 });
652 state.inputChain = run.then(
653 () => {},
654 () => {},
655 );
656 return run;
657 });
658
659 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
660 if (e.requestId !== PANE || !state.ownsCommand) return next(e);
661 const ui = $.ui.resolve(e);
662 if (e.surface !== 'terminal')
663 return ui.Text({
664 ...styles.text,
665 children: [tr("native.hooks.register.open_this_editor_in_the_claude_code_terminal")],
666 });
667 const { Box, Text, Button, Client } = $.ui.resolve(e);
668 const width = Math.max(2, Math.min(10000, e.props.bodyColumns));
669 const height = Math.max(0, Math.min(10000, e.props.scroll.bodyRows));
670 state.columns = width;
671 state.rows = height;
672 const editor = state.view.editor;
673 const previewDraft = editor?.review?.result.draft ?? editor?.draft;
674 const previewColumns = contentColumns(width, height);
675 const key = `${state.epoch}:${previewColumns}:${previewDraft ? JSON.stringify(previewDraft) : ''}`;
676 if (
677 editor &&
678 width >= MIN_COLUMNS &&
679 height >= MIN_ROWS &&
680 !state.writing &&
681 state.previewKey !== key
682 ) {
683 state.previewKey = key;
684 const requestedEpoch = state.epoch;
685 const draft = copyDraft(previewDraft!);
686 state.previewTimer?.cancel();
687 state.view.previewBusy = true;
688 state.previewTimer = $.clock.after(0, async () => {
689 try {
690 const preview = await callBackend($, options, 'preview', {
691 draft,
692 width: previewColumns,
693 });
694 if (state.epoch === requestedEpoch && state.previewKey === key) {
695 state.view.preview = preview;
696 state.view.previewError = '';
697 }
698 } catch (failure) {
699 if (state.epoch === requestedEpoch && state.previewKey === key) {
700 state.view.preview = null;
701 setMessage(state.view, "previewError", failureMessage(failure));
702 }
703 } finally {
704 if (state.epoch === requestedEpoch && state.previewKey === key) {
705 state.view.previewBusy = false;
706 state.previewTimer = null;
707 $.ui.invalidate('ui.render');
708 }
709 }
710 });
711 }
712 const retry = async () => {
713 if (state.writing || state.view.uncertain) return;
714 if (!state.view.editor) await load($, state, options);
715 else {
716 state.epoch++;
717 state.ack = 0;
718 state.clientFault = '';
719 state.view.error = '';
720 }
721 state.placed = true;
722 $.ui.invalidate('ui.render');
723 };
724 if (
725 editor &&
726 !state.clientFault &&
727 width >= MIN_COLUMNS &&
728 height >= MIN_ROWS
729 ) {
730 return Box({
731 width,
732 height,
733 flexDirection: 'column',
734 children: [
735 Client({
736 key: 'statusline-client',
737 module: '../ui/client/surface.ts',
738 width,
739 height,
740 props: props(state),
741 }),
742 Box({
743 position: 'absolute',
744 top: 0,
745 right: 0,
746 width: 20,
747 height: 1,
748 flexDirection: 'row',
749 columnGap: 1,
750 children: [
751 Button({ ...recoveryButton, key: 'retry-client', label: tr("native.hooks.register.retry"), onPress: retry }),
752 Button({
753 ...recoveryButton,
754 key: 'close',
755 label: tr("native.hooks.register.close"),
756 onPress: () => close($, state),
757 }),
758 ],
759 }),
760 ],
761 });
762 }
763 return Box({
764 backgroundColor: styles.text.backgroundColor,
765 flexDirection: 'column',
766 width,
767 // Keep the requested height while undersized; otherwise an inline host
768 // measures the short error tree and never grows it after a terminal resize.
769 height: Math.max(24, height),
770 children: [
771 Text({
772 ...styles.text,
773 bold: true,
774 wrap: 'truncate',
775 children: [
776 width < MIN_COLUMNS || height < MIN_ROWS
777 ? tr("native.hooks.register.resize_pane_to_32x12_draft_kept")
778 : state.clientFault
779 ? tr("native.hooks.register.client_failed_received_draft_kept")
780 : viewMessage(state.view, 'busy') || tr("native.hooks.register.could_not_open_the_client_editor"),
781 ],
782 }),
783 Text({
784 ...styles.error,
785 wrap: 'truncate',
786 children: [state.clientFault || viewMessage(state.view, 'error') || ' '],
787 }),
788 ...(state.view.uncertain
789 ? [
790 Button({
791 ...recoveryButton,
792 key: 'reconcile',
793 label: tr("native.hooks.register.check_saved_state"),
794 hotkey: 'k',
795 onPress: () => reconcile($, state, options),
796 }),
797 ]
798 : []),
799 ...(!state.view.busy && !state.view.uncertain
800 ? [
801 Button({
802 ...recoveryButton,
803 key: 'retry-client',
804 label: tr("native.hooks.register.retry_client"),
805 hotkey: 'r',
806 onPress: retry,
807 }),
808 ]
809 : []),
810 Button({
811 ...recoveryButton,
812 key: 'close',
813 label: tr("native.hooks.register.discard_pending_close"),
814 hotkey: 'q',
815 onPress: () => close($, state),
816 }),
817 ],
818 });
819 });
820};
821lib/backend.ts 504 lines1// Generated by tools/generation/resources.ts; do not edit.
2import { text as localizedText } from './i18n/index.ts';
3/** Protocol parsing only. Host calls stay in the hooks entry for static analysis. */
4import {
5 PROTOCOL_VERSION,
6 MAIN_ITEM_IDS,
7 SUBAGENT_ITEM_IDS,
8 PALETTE_VALUES, THEME_VALUES, POWERLINEGLYPH_VALUES,
9 DIRECTORYSTYLE_VALUES,
10 SEPARATORSTYLE_VALUES,
11 SCOPELABELS_VALUES,
12 STATUSLINELANGUAGE_VALUES,
13 UNAVAILABLEREASON_VALUES,
14 FORMAT_CHOICES, PRESETS, EDITOR_FIELDS, GUIDANCE_KEYS, ITEM_DEFAULTS,
15} from './generated-contracts.ts';
16import type {
17 Capabilities,
18 CatalogItem,
19 ConfigurationOptions,
20 Draft,
21 Operation,
22 ResultFor,
23 Span,
24} from './generated-contracts.ts';
25import { validColor, visibilityChoices } from './editor/appearance.ts';
26import { isLocalizedText } from './i18n/index.ts';
27import type { LocalizedText } from './i18n/index.ts';
28
29export class BackendError extends Error {
30 constructor(
31 public readonly code: string,
32 message: string | LocalizedText,
33 public readonly localization: LocalizedText | null = typeof message === 'string' ? null : message,
34 ) {
35 super(typeof message === 'string' ? message : message.fallback);
36 }
37}
38
39export function processFailure(error: unknown): BackendError {
40 if (error instanceof BackendError) return error;
41 const message =
42 error instanceof Error
43 ? error.message
44 : 'The backend process could not run.';
45 const timedOut =
46 error instanceof Error &&
47 (error.name === 'TimeoutError' || /timed?\s*out|timeout/i.test(message));
48 return new BackendError(
49 timedOut ? 'backend_timeout' : 'backend_process',
50 message,
51 );
52}
53
54function object(value: unknown): value is Record<string, unknown> {
55 return typeof value === 'object' && value !== null && !Array.isArray(value);
56}
57
58function fail(): never {
59 throw new BackendError(
60 'invalid_backend_result',
61 localizedText("native.lib.backend.the_backend_returned_an_invalid_configuration_result"),
62 );
63}
64
65function exact(
66 value: Record<string, unknown>,
67 keys: readonly string[],
68): boolean {
69 return (
70 Object.keys(value).length === keys.length &&
71 keys.every((key) => key in value)
72 );
73}
74
75function text(value: unknown): value is string {
76 return typeof value === 'string' && !/[\p{Cc}\p{Cs}]/u.test(value);
77}
78
79function strings(value: unknown): value is string[] {
80 return Array.isArray(value) && value.every(text);
81}
82
83function selection(value: unknown, ids: readonly string[]): value is string[] {
84 return (
85 strings(value) &&
86 value.every((id) => ids.includes(id)) &&
87 new Set(value).size === value.length
88 );
89}
90
91function range(value: unknown, min: number, max: number, nullable = false): boolean {
92 return (nullable && value === null) ||
93 (typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max);
94}
95
96function formatOverrides(value: unknown): boolean {
97 if (!object(value)) return false;
98 return Object.entries(value).every(([key, v]) =>
99 key in FORMAT_CHOICES && typeof v === 'string' &&
100 (FORMAT_CHOICES[key as keyof typeof FORMAT_CHOICES] as readonly string[]).includes(v));
101}
102
103function itemOptions(value: unknown, ids: readonly string[], scope: "main" | "subagent"): boolean {
104 if (!object(value)) return false;
105 return Object.entries(value).every(([id, option]) => ids.includes(id) && object(option) &&
106 exact(option, ['label', 'icon', 'priority', 'max_width', 'formatting', 'foreground', 'background', 'visibility', 'visibility_threshold']) &&
107 (option.label === null || (text(option.label) && [...option.label].length <= 256)) &&
108 (option.icon === null || (text(option.icon) && [...option.icon].length <= 256)) &&
109 range(option.priority, 0, 100) && range(option.max_width, 2, 10000, true) &&
110 formatOverrides(option.formatting) && validColor(option.foreground) && validColor(option.background) &&
111 typeof option.visibility === "string" && visibilityChoices(scope, id).includes(option.visibility) &&
112 range(option.visibility_threshold, 0, 100));
113}
114
115function formatting(value: unknown): boolean {
116 if (!object(value) || !exact(value, [...Object.keys(FORMAT_CHOICES), 'thresholds'])) return false;
117 const { thresholds, ...choices } = value;
118 return formatOverrides(choices) && object(thresholds) &&
119 exact(thresholds, ['enabled', 'warning', 'critical']) &&
120 typeof thresholds.enabled === 'boolean' && range(thresholds.warning, 0, 100) &&
121 range(thresholds.critical, 0, 100) && Number(thresholds.warning) < Number(thresholds.critical);
122}
123
124function instanceList(value: unknown, ids: readonly string[], scope: "main" | "subagent"): value is import('./generated-contracts.ts').InstanceDraft[] {
125 if (!Array.isArray(value)) return false;
126 const seen = new Set<string>();
127 return value.every(node => {
128 if (!object(node) || !exact(node,['id','type_id','enabled','options','text']) ||
129 typeof node.id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(node.id) || seen.has(node.id) ||
130 typeof node.type_id !== 'string' || !ids.includes(node.type_id) || typeof node.enabled !== 'boolean' ||
131 !itemOptions({[node.type_id]: node.options},ids,scope)) return false;
132 seen.add(node.id);
133 const option = node.options as Record<string,unknown>;
134 if (node.type_id === 'spacer') {
135 if (JSON.stringify(option) !== JSON.stringify(ITEM_DEFAULTS)) {
136 if (Object.entries(ITEM_DEFAULTS).some(([key,v]) => JSON.stringify(option[key]) !== JSON.stringify(v))) return false;
137 }
138 }
139 if (['text','spacer'].includes(node.type_id) && (option.visibility !== 'always' || Object.keys(option.formatting as object).length)) return false;
140 return node.type_id === 'text' ? text(node.text) && [...node.text].length <= 256 : node.text === null;
141 });
142}
143
144export function isDraft(value: unknown): value is Draft {
145 if (
146 !object(value) ||
147 !exact(value, ['display', 'host']) ||
148 !object(value.display) ||
149 !object(value.host)
150 )
151 return false;
152 const d = value.display;
153 const h = value.host;
154 if (
155 !exact(d, [
156 'schema_version',
157 'statusline_language',
158 'instances',
159 'use_colors',
160 'palette', 'theme', 'powerline_glyph',
161 'directory_style',
162 'separator_style',
163 'scope_labels',
164 'subagents',
165 'formatting', 'layout', 'metrics',
166 ]) ||
167 !exact(h, ['padding', 'refresh_interval', 'hide_vim_mode_indicator'])
168 )
169 return false;
170 if (
171 d.schema_version !== 8 ||
172 !THEME_VALUES.some(x => x === d.theme) || !POWERLINEGLYPH_VALUES.some(x => x === d.powerline_glyph) ||
173 !STATUSLINELANGUAGE_VALUES.some((x) => x === d.statusline_language) ||
174 !object(d.metrics) || !exact(d.metrics, ['branch_diff_base_ref']) ||
175 !(d.metrics.branch_diff_base_ref === null || (text(d.metrics.branch_diff_base_ref) && [...d.metrics.branch_diff_base_ref].length <= 256 &&
176 d.metrics.branch_diff_base_ref.length > 0 && !/[\s]|^-/.test(d.metrics.branch_diff_base_ref))) ||
177 !formatting(d.formatting) ||
178 typeof d.use_colors !== 'boolean' ||
179 !instanceList(d.instances, MAIN_ITEM_IDS, "main") ||
180 !PALETTE_VALUES.some((x) => x === d.palette) ||
181 !DIRECTORYSTYLE_VALUES.some((x) => x === d.directory_style) ||
182 !SEPARATORSTYLE_VALUES.some((x) => x === d.separator_style) ||
183 !SCOPELABELS_VALUES.some((x) => x === d.scope_labels)
184 )
185 return false;
186 const sub = d.subagents;
187 if (
188 !object(sub) ||
189 !exact(sub, ['enabled', 'instances', 'visibility', 'hide_completed', 'row_limit', 'task_max_width']) ||
190 !['all', 'running'].includes(String(sub.visibility)) ||
191 typeof sub.hide_completed !== 'boolean' || !range(sub.row_limit, 0, 10000, true) ||
192 !range(sub.task_max_width, 2, 10000, true) ||
193 typeof sub.enabled !== 'boolean' ||
194 !instanceList(sub.instances, SUBAGENT_ITEM_IDS, "subagent")
195 )
196 return false;
197 if (sub.instances.filter(i => i.type_id === 'spacer').length > 1) return false;
198 const types = new Set(sub.instances.filter(i=>i.enabled).map(i=>i.type_id));
199 if (types.has('status-elapsed') && (types.has('status') || types.has('elapsed'))) return false;
200 const ids = d.instances.map(i=>i.id);
201 const spacers = new Set(d.instances.filter(i=>i.type_id==='spacer').map(i=>i.id));
202 const layout = d.layout;
203 if (!object(layout) || !exact(layout, ['mode', 'rows']) ||
204 !['auto', 'explicit'].includes(String(layout.mode)) || !Array.isArray(layout.rows) ||
205 !layout.rows.every((r) => selection(r, ids) && r.length > 0 && r.filter(id => spacers.has(id)).length <= 1) ||
206 (layout.mode === 'auto' ? layout.rows.length !== 0 || spacers.size > 0 :
207 JSON.stringify(layout.rows.flat()) !== JSON.stringify(ids))) return false;
208 // Exclusions come from describe; the backend remains the final draft validator.
209 return (
210 typeof h.padding === 'number' &&
211 Number.isInteger(h.padding) &&
212 h.padding >= 0 &&
213 h.padding <= 32 &&
214 typeof h.hide_vim_mode_indicator === 'boolean' &&
215 (h.refresh_interval === 'event' ||
216 (typeof h.refresh_interval === 'number' &&
217 Number.isInteger(h.refresh_interval) &&
218 h.refresh_interval >= 1 &&
219 h.refresh_interval <= 3600))
220 );
221}
222
223export function isSpan(value: unknown): value is Span {
224 if (!object(value) || !text(value.text) || typeof value.bold !== 'boolean')
225 return false;
226 return [value.foreground, value.background].every(color =>
227 color === null ||
228 (object(color) &&
229 ((color.kind === 'rgb' && typeof color.value === 'string' && /^#[0-9a-f]{6}$/i.test(color.value)) ||
230 (color.kind === 'ansi' && typeof color.value === 'number' && Number.isInteger(color.value) &&
231 color.value >= 0 && color.value <= 255))));
232}
233
234function isCapabilities(value: unknown): value is Capabilities {
235 return (
236 object(value) &&
237 (value.host_version === null || text(value.host_version)) &&
238 ['unknown', 'supported', 'unsupported'].includes(
239 String(value.subagent_rows),
240 ) &&
241 ['unknown', 'unverified', 'unsupported'].includes(
242 String(value.native_mod),
243 ) &&
244 value.native_mod_loaded === null &&
245 value.data_observation === 'not_observed'
246 );
247}
248
249function isGuidance(value: unknown): boolean {
250 return object(value) && selection(value.source_kinds, GUIDANCE_KEYS.source_kinds) && value.source_kinds.length > 0 &&
251 GUIDANCE_KEYS.scopes.some(scope => scope === value.measurement_scope) &&
252 selection(value.requirements, GUIDANCE_KEYS.requirements) && strings(value.setup);
253}
254
255function isImportChange(value: unknown): boolean {
256 if (!object(value) || !exact(value, ['section', 'kind', 'scope', 'item_id', 'path', 'before', 'after', 'label']) ||
257 !['main','subagent','formatting','layout','language','host'].includes(String(value.section)) ||
258 !['add','remove','enable','disable','reorder','change'].includes(String(value.kind)) ||
259 !(value.scope === null || value.scope === 'main' || value.scope === 'subagent') ||
260 !strings(value.path) || !value.path.length || value.path.length > 8 ||
261 !['display','host'].includes(value.path[0]!) || !isLocalizedText(value.label)) return false;
262 if (value.item_id !== null && (typeof value.item_id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(value.item_id))) return false;
263 if (value.kind === 'enable' || value.kind === 'disable')
264 return value.item_id !== null && value.before === (value.kind === 'disable') && value.after === (value.kind === 'enable');
265 if (value.kind === 'add' || value.kind === 'remove')
266 return value.item_id !== null && (value.kind === 'add' ? value.before === null && object(value.after) : object(value.before) && value.after === null);
267 if (value.kind === 'reorder')
268 return value.item_id === null && strings(value.before) && strings(value.after) &&
269 new Set(value.before).size === value.before.length && new Set(value.after).size === value.after.length &&
270 value.before.length === value.after.length && value.before.every(id => (value.after as string[]).includes(id));
271 return true;
272}
273
274function isCatalog(value: unknown): value is CatalogItem[] {
275 if (
276 !Array.isArray(value) ||
277 value.length !== MAIN_ITEM_IDS.length + SUBAGENT_ITEM_IDS.length
278 )
279 return false;
280 const seen = new Set<string>();
281 return value.every((item) => {
282 if (
283 !object(item) ||
284 !text(item.id) ||
285 !text(item.label) ||
286 !text(item.description) ||
287 !text(item.group) ||
288 !strings(item.sources) ||
289 !strings(item.examples) ||
290 !strings(item.format_options) ||
291 !strings(item.unavailable_reasons) ||
292 !isGuidance(item.guidance) ||
293 typeof item.default_enabled !== 'boolean' ||
294 !(item.minimum_version === null || text(item.minimum_version)) ||
295 !(
296 item.default_position === null ||
297 (typeof item.default_position === 'number' &&
298 Number.isInteger(item.default_position) &&
299 item.default_position >= 0)
300 ) ||
301 item.default_enabled !== (item.default_position !== null) ||
302 item.minimum_version_status !==
303 (item.minimum_version ? 'verified' : 'unknown')
304 )
305 return false;
306 const ids =
307 item.scope === 'main'
308 ? MAIN_ITEM_IDS
309 : item.scope === 'subagent'
310 ? SUBAGENT_ITEM_IDS
311 : [];
312 const key = `${item.scope}:${item.id}`;
313 if (
314 !ids.some((id) => id === item.id) ||
315 !selection(item.excludes, ids) ||
316 item.excludes.includes(item.id) ||
317 seen.has(key)
318 )
319 return false;
320 seen.add(key);
321 return item.unavailable_reasons.every((reason) =>
322 UNAVAILABLEREASON_VALUES.some((known) => known === reason),
323 );
324 });
325}
326
327function isOptions(value: unknown): value is ConfigurationOptions {
328 if (!object(value)) return false;
329 const choices: Record<string, readonly (string | boolean)[]> = {
330 colors: [true, false],
331 palette: PALETTE_VALUES,
332 theme: THEME_VALUES,
333 "powerline-glyph": POWERLINEGLYPH_VALUES,
334 'directory-style': DIRECTORYSTYLE_VALUES,
335 'separator-style': SEPARATORSTYLE_VALUES,
336 'scope-labels': SCOPELABELS_VALUES,
337 'statusline-language': STATUSLINELANGUAGE_VALUES,
338 'subagent-statusline': [true, false],
339 'hide-vim-mode-indicator': [true, false],
340 };
341 for (const [key, allowed] of Object.entries(choices)) {
342 const option = value[key];
343 if (
344 !object(option) ||
345 !Array.isArray(option.choices) ||
346 option.choices.length !== allowed.length ||
347 new Set(option.choices).size !== option.choices.length ||
348 !option.choices.every((choice) => allowed.includes(choice))
349 )
350 return false;
351 }
352 for (const [key, minimum, maximum] of [
353 ['padding', 0, 32],
354 ['refresh-interval', 1, 3600],
355 ] as const) {
356 const option = value[key];
357 if (
358 !object(option) ||
359 option.minimum !== minimum ||
360 option.maximum !== maximum
361 )
362 return false;
363 if (key === 'refresh-interval' && option.special !== 'event') return false;
364 }
365 return true;
366}
367
368export function requestText(
369 operation: Operation,
370 payload: unknown = {},
371): string {
372 return JSON.stringify({
373 protocol_version: PROTOCOL_VERSION,
374 operation,
375 payload,
376 });
377}
378
379export function parseResponse<O extends Operation>(
380 operation: O,
381 process: {
382 exitCode: number;
383 stdout: string;
384 stderr: string;
385 isStdoutTruncated?: boolean;
386 },
387): ResultFor<O> {
388 if (process.isStdoutTruncated) {
389 throw new BackendError(
390 'backend_output_truncated',
391 localizedText("native.lib.backend.the_backend_json_was_truncated_by_the_host"),
392 );
393 }
394 let response: unknown;
395 try {
396 response = JSON.parse(process.stdout);
397 } catch {
398 const message =
399 process.exitCode !== 0
400 ? `The backend exited with status ${process.exitCode}.`
401 : 'The backend did not return complete JSON.';
402 throw new BackendError(
403 process.exitCode !== 0 ? 'backend_exit' : 'invalid_backend_json',
404 localizedText("native.lib.backend.check_the_bound_backend_executable_and_reinstall_matching", {message: message}),
405 );
406 }
407 if (!object(response) || response.protocol_version !== PROTOCOL_VERSION) {
408 throw new BackendError(
409 'protocol_mismatch',
410 localizedText("native.lib.backend.the_frontend_and_backend_protocol_versions_do_not"),
411 );
412 }
413 if (
414 'error' in response === 'result' in response ||
415 !exact(response, [
416 'protocol_version',
417 'error' in response ? 'error' : 'result',
418 ])
419 )
420 fail();
421 if ('error' in response) {
422 if (
423 !object(response.error) ||
424 !exact(response.error, ['code', 'message', ...('localization' in response.error ? ['localization'] : [])]) ||
425 !text(response.error.code) ||
426 typeof response.error.message !== 'string' ||
427 !(!('localization' in response.error) || response.error.localization === null || isLocalizedText(response.error.localization))
428 )
429 fail();
430 throw new BackendError(response.error.code, response.error.message, isLocalizedText(response.error.localization) ? response.error.localization : null);
431 }
432 if (process.exitCode !== 0) {
433 throw new BackendError(
434 'backend_exit',
435 localizedText("native.lib.backend.the_backend_exited_with_status", {exitCode: process.exitCode}),
436 );
437 }
438 const result = response.result;
439 if (!object(result)) fail();
440 if (operation === 'read_ui_preferences' || operation === 'set_ui_language') {
441 if (!exact(result, ['schema_version', 'ui_language', 'warning']) || result.schema_version !== 1 ||
442 !STATUSLINELANGUAGE_VALUES.some(language => language === result.ui_language) ||
443 !(result.warning === null || isLocalizedText(result.warning))) fail();
444 } else if (operation === 'read' || operation === 'apply') {
445 if (
446 !isDraft(result.draft) ||
447 typeof result.revision !== 'string' ||
448 !/^[0-9a-f]{64}$/.test(result.revision) ||
449 typeof result.installed !== 'boolean' ||
450 !object(result.installation) ||
451 !isCapabilities(result.capabilities) ||
452 !text(result.backend_version) ||
453 !result.backend_version
454 )
455 fail();
456 if (
457 operation === 'apply' &&
458 (typeof result.changed !== 'boolean' ||
459 !(result.backup_dir === null || typeof result.backup_dir === 'string'))
460 )
461 fail();
462 } else if (operation === 'review_import') {
463 if (!exact(result, ['draft','changes']) || !isDraft(result.draft) || !Array.isArray(result.changes) || !result.changes.every(isImportChange)) fail();
464 } else if (operation === 'import' || operation === 'preset') {
465 if (!isDraft(result.draft)) fail();
466 } else if (operation === 'export') {
467 if (!text(result.path) || !result.path) fail();
468 } else if (operation === 'preview') {
469 if (
470 result.sample !== true ||
471 !Array.isArray(result.main) ||
472 !Array.isArray(result.subagents) ||
473 ![...result.main, ...result.subagents].every(
474 (row) => Array.isArray(row) && row.every(isSpan),
475 )
476 )
477 fail();
478 } else if (operation === 'describe') {
479 if (
480 !text(result.backend_version) ||
481 !result.backend_version ||
482 !isOptions(result.options) ||
483 !isCapabilities(result.capabilities) ||
484 !selection(result.operations, ['describe', 'read', 'preview', 'apply', 'import', 'export', 'preset', 'read_ui_preferences', 'set_ui_language', 'review_import']) ||
485 result.operations.length !== 10 ||
486 JSON.stringify(result.editor_fields) !== JSON.stringify(EDITOR_FIELDS) ||
487 JSON.stringify(result.presets) !== JSON.stringify(PRESETS) ||
488 !object(result.formatting_options) ||
489 JSON.stringify(result.formatting_options) !== JSON.stringify(FORMAT_CHOICES) ||
490 !isCatalog(result.catalog)
491 )
492 fail();
493 }
494 return result as unknown as ResultFor<O>;
495}
496
497export function spanColor(span: Span, channel: 'foreground' | 'background' = 'foreground'): string | undefined {
498 const color = span[channel];
499 if (color?.kind === 'rgb') return String(color.value);
500 if (color?.kind === 'ansi')
501 return `ansi256(${Number(color.value)})`;
502 return undefined;
503}
504lib/editor/draft.ts 255 lines1// Generated by tools/generation/resources.ts; do not edit.
2import { t, type Language } from '../i18n/index.ts';
3import { nodes, newInstance, reconcileRows } from './instances.ts';
4import { isDraft } from '../backend.ts';
5import type {
6 CatalogItem,
7 DescribeResult,
8 Draft,
9 ReadResult,
10 Scope,
11} from '../generated-contracts.ts';
12import { NUMERIC_FIELDS, numericValue, numericDiagnostic } from './numeric.ts';
13import { bestMatch, fields, normalize } from './search.ts';
14import type { Match } from './search.ts';
15import type { ReviewState } from './import-review.ts';
16import type { LocalizedText } from '../i18n/index.ts';
17import type { NumericField } from './numeric.ts';
18export type { NumericField } from './numeric.ts';
19
20export type Page = 'main' | 'subagents' | 'settings' | 'layout';
21
22/** Copy wire data without sharing editable arrays with the opening snapshot. */
23export function copyDraft(draft: Draft): Draft {
24 return JSON.parse(JSON.stringify(draft)) as Draft;
25}
26
27export function sameDraft(left: Draft, right: Draft): boolean {
28 return JSON.stringify(left) === JSON.stringify(right);
29}
30
31/** Pure tool draft. Persistence and host configuration calls live in the hook. */
32export class Editor {
33 draft: Draft;
34 baseline: ReadResult;
35 page: Page = 'main';
36 advanced = false;
37 adding = false;
38 pickerSaved: {search:string;category:string;selected:string} | null = null;
39 detail: { scope: Scope; id: string } | null = null;
40 guidanceScroll: number | null = null;
41 review: ReviewState | null = null;
42 preset = 'minimal';
43 path = 'statusline.json';
44 pendingTransfer: 'import' | 'export' | 'preset' | null = null;
45 setting = 'colors';
46 activeNumeric: NumericField | null = null;
47 selected: Record<Scope, string>;
48 order: Record<Scope, string[]>;
49 search: Record<Scope, string> = { main: '', subagent: '' };
50 category: Record<Scope, string> = { main: 'all', subagent: 'all' };
51 buffers: Record<NumericField, string>;
52 fieldErrors: Partial<Record<NumericField, string>> = {};
53 fieldErrorMessages: Partial<Record<NumericField, LocalizedText>> = {};
54
55 constructor(
56 public readonly description: DescribeResult,
57 current: ReadResult,
58 ) {
59 description.catalog.forEach(item => fields(item));
60 this.baseline = { ...current, draft: copyDraft(current.draft) };
61 this.draft = copyDraft(current.draft);
62 this.order = { main: [], subagent: [] };
63 this.selected = { main: '', subagent: '' };
64 for (const scope of ['main', 'subagent'] as const) {
65 this.order[scope] = nodes(this.draft,scope).map(i=>i.id);
66 this.selected[scope] = this.order[scope][0] || '';
67 }
68 this.buffers = {
69 padding: String(this.draft.host.padding),
70 refresh_interval: String(this.draft.host.refresh_interval),
71 };
72 }
73
74 replaceDraft(draft: Draft): void {
75 this.draft = copyDraft(draft);
76 for (const scope of ['main', 'subagent'] as const) {
77 this.order[scope] = nodes(this.draft,scope).map(i=>i.id);
78 if (!this.order[scope].includes(this.selected[scope])) this.selected[scope] = this.order[scope][0] || '';
79 }
80 this.buffers = { padding: String(draft.host.padding), refresh_interval: String(draft.host.refresh_interval) };
81 this.fieldErrors = {};
82 this.fieldErrorMessages = {};
83 this.activeNumeric = null;
84 this.detail = null;
85 this.guidanceScroll = null;
86 this.review = null;
87 }
88
89 get modified(): boolean {
90 return (
91 !sameDraft(this.draft, this.baseline.draft) ||
92 this.buffers.padding !== String(this.draft.host.padding) ||
93 this.buffers.refresh_interval !== String(this.draft.host.refresh_interval)
94 );
95 }
96
97 catalog(scope: Scope): CatalogItem[] {
98 return this.description.catalog.filter((item) => item.scope === scope);
99 }
100
101 items(scope: Scope): string[] {
102 return nodes(this.draft,scope).filter(i=>i.enabled).map(i=>i.id);
103 }
104
105 instance(scope: Scope, id: string) { return nodes(this.draft,scope).find(i=>i.id===id); }
106 definition(scope: Scope, id: string): CatalogItem | undefined {
107 return this.catalog(scope).find(i=>i.id===(this.instance(scope,id)?.type_id ?? id));
108 }
109
110 instanceLabel(scope:Scope,id:string,language:Language='en'):string {
111 const node=this.instance(scope,id),type=node?.type_id??id;
112 const peers=nodes(this.draft,scope).filter(i=>i.type_id===type);
113 return (node?.options.label || (type==='text'?node?.text:null) || t(`items.${scope}.${type}.label`,language))+
114 (node&&peers.length>1?` (${peers.indexOf(node)+1})`:'');
115 }
116
117 visible(scope: Scope): CatalogItem[] { return this.matches(scope).map(result => result.item); }
118
119 matches(scope: Scope): {item: CatalogItem & {type_id?:string;custom_label?:string|null;ordinal?:number}; match: Match}[] {
120 const catalog = new Map(this.catalog(scope).map(item=>[item.id as string,item]));
121 const order = this.adding ? [...catalog.keys()] : this.order[scope];
122 return order.flatMap(id=> {
123 const node = this.adding ? undefined : this.instance(scope,id);
124 const definition = catalog.get(node?.type_id ?? id);
125 if (!definition || (this.category[scope] !== 'all' && definition.group !== this.category[scope])) return [];
126 const peers = node ? nodes(this.draft,scope).filter(i=>i.type_id===node.type_id) : [];
127 const item = {...definition,id,type_id:definition.id,custom_label:node?.options.label || (node?.type_id==='text'?node.text:null),ordinal:peers.length>1?peers.indexOf(node!)+1:undefined};
128 const match = normalize(this.search[scope]).characters.length
129 ? bestMatch(fields(definition,item.custom_label),this.search[scope])
130 : {rank:5,positions:[],text:id,field:'id'};
131 return match ? [{item,match}] : [];
132 }).sort((a,b)=>a.match.rank-b.match.rank);
133 }
134
135 openPicker(scope: Scope): void {
136 this.pickerSaved={search:this.search[scope],category:this.category[scope],selected:this.selected[scope]};
137 this.adding=true; this.search[scope]='';this.category[scope]='all';this.selected[scope]=this.catalog(scope)[0]?.id ?? '';
138 }
139 closePicker(scope: Scope): void {
140 if(this.pickerSaved){this.search[scope]=this.pickerSaved.search;this.category[scope]=this.pickerSaved.category;this.selected[scope]=this.pickerSaved.selected;}
141 this.adding=false;this.pickerSaved=null;
142 }
143 instanceAction(scope: Scope, action:'add'|'copy'|'remove', typeId?:string): boolean {
144 const draft=copyDraft(this.draft), list=nodes(draft,scope);
145 const selected=this.adding ? this.pickerSaved?.selected : this.selected[scope];
146 const index=list.findIndex(i=>i.id===selected);
147 let next=selected ?? '';
148 if(action==='remove'){
149 if(index<0)return false;
150 list.splice(index,1);next=list[Math.min(index,list.length-1)]?.id??'';
151 }else{
152 if(action==='copy'&&index<0)return false;
153 const node=newInstance(typeId??list[index]!.type_id,list,action==='copy'?list[index]:undefined);
154 list.splice(index<0?list.length:index+1,0,node);next=node.id;
155 }
156 if(scope==='main')reconcileRows(draft);
157 if(!isDraft(draft))return false;
158 if(this.adding){this.closePicker(scope);this.search[scope]='';this.category[scope]='all';}
159 this.draft=draft;this.order[scope]=list.map(i=>i.id);this.selected[scope]=next;
160 return true;
161 }
162
163 categories(scope: Scope): string[] {
164 return ['all', ...new Set(this.catalog(scope).map(item => item.group))];
165 }
166
167 filtered(scope: Scope): boolean {
168 return this.category[scope] !== 'all' || normalize(this.search[scope]).characters.length > 0;
169 }
170
171 chooseCategory(scope: Scope, category: string): void {
172 if (!this.categories(scope).includes(category)) return;
173 this.category[scope] = category;
174 this.filter(scope, this.search[scope]);
175 }
176
177 filter(scope: Scope, value: string): void {
178 this.search[scope] = value;
179 const matches = this.matches(scope), selected = matches.find(result => result.item.id === this.selected[scope]);
180 if (!selected || selected.match.rank > matches[0]!.match.rank) {
181 this.selected[scope] = matches[0]?.item.id || '';
182 }
183 }
184
185 toggle(scope: Scope, id: string): boolean {
186 if(this.adding)return this.instanceAction(scope,'add',id);
187 const draft=copyDraft(this.draft),node=nodes(draft,scope).find(i=>i.id===id);
188 if(!node)return false;
189 node.enabled=!node.enabled;
190 if(!isDraft(draft))return false;
191 this.draft=draft;return true;
192 }
193
194 move(scope: Scope, delta: -1 | 1): boolean {
195 if (this.adding || this.filtered(scope)) return false;
196 const visible = this.visible(scope).map((item) => item.id as string);
197 const id = this.selected[scope];
198 const index = visible.indexOf(id);
199 const target = visible[index + delta];
200 if (index < 0 || !target) return false;
201 const draft = copyDraft(this.draft);
202 const ordered = nodes(draft, scope);
203 const sourceIndex = ordered.findIndex(node => node.id === id);
204 const [moving] = ordered.splice(sourceIndex, 1);
205 ordered.splice(ordered.findIndex(node => node.id === target) + (delta > 0 ? 1 : 0), 0, moving!);
206 if (scope === 'main') reconcileRows(draft);
207 if (!isDraft(draft)) return false;
208 this.draft = draft;
209 this.order[scope] = ordered.map(node => node.id);
210 return true;
211 }
212
213 setBuffer(field: NumericField, value: string): void {
214 this.activeNumeric = field;
215 this.buffers[field] = value;
216 delete this.fieldErrors[field];
217 delete this.fieldErrorMessages[field];
218 }
219
220 cancelNumeric(field?: NumericField): void {
221 for (const key of field ? [field] : NUMERIC_FIELDS) {
222 this.buffers[key] = String(this.draft.host[key]);
223 delete this.fieldErrors[key];
224 delete this.fieldErrorMessages[key];
225 }
226 if (!field || this.activeNumeric === field) this.activeNumeric = null;
227 }
228
229 acceptNumeric(field?: NumericField): boolean {
230 const values: Partial<Record<NumericField, number | 'event'>> = {};
231 const fields = field ? [field] : NUMERIC_FIELDS;
232 for (const key of fields) {
233 delete this.fieldErrors[key];
234 delete this.fieldErrorMessages[key];
235 const parsed = numericValue(this.description, key, this.buffers[key]);
236 if (parsed.error !== undefined) { this.fieldErrors[key] = parsed.error; this.fieldErrorMessages[key] = numericDiagnostic(this.description, key); }
237 else values[key] = parsed.value;
238 }
239 if (fields.some((key) => this.fieldErrors[key])) return false;
240 if (values.padding !== undefined)
241 this.draft.host.padding = values.padding as number;
242 if (values.refresh_interval !== undefined)
243 this.draft.host.refresh_interval = values.refresh_interval;
244 this.cancelNumeric(field);
245 return true;
246 }
247
248 committed(current: ReadResult): void {
249 this.baseline = { ...current, draft: copyDraft(current.draft) };
250 this.draft = copyDraft(current.draft);
251 this.cancelNumeric();
252 }
253
254}
255lib/editor/import-review.ts 56 lines1// Generated by tools/generation/resources.ts; do not edit.
2import type { ImportReviewResult, Scope } from '../generated-contracts.ts';
3import type { Language } from '../i18n/index.ts';
4import { renderMessage, t } from '../i18n/index.ts';
5import { wrapText } from '../../ui/text.ts';
6
7export const REVIEW_SECTIONS = ['main','subagent','formatting','layout','language','host'] as const;
8export interface ReviewState {
9 result: ImportReviewResult;
10 selected: string;
11 expanded: string[];
12 scroll: number;
13 previewScope: Scope;
14}
15export interface ReviewRow { text: string; section: string; heading: boolean }
16export function sections(review: ReviewState): string[] {
17 return REVIEW_SECTIONS.filter(section => review.result.changes.some(change => change.section === section));
18}
19export function createReview(result: ImportReviewResult): ReviewState {
20 const review: ReviewState = {result, selected:'', expanded:[], scroll:0, previewScope:'main'};
21 review.selected = sections(review)[0] ?? '';
22 return review;
23}
24export function reviewRows(review: ReviewState, language: Language, width: number): ReviewRow[] {
25 const rows: ReviewRow[] = [];
26 for (const section of sections(review)) {
27 const changes = review.result.changes.filter(change => change.section === section);
28 rows.push({section, heading:true, text:`${review.selected === section ? '› ' : ' '}${review.expanded.includes(section) ? '[-]' : '[+]'} ${t('review.sections.' + section, language)} (${changes.length})`});
29 if (review.expanded.includes(section)) for (const change of changes) {
30 const paragraphs = [renderMessage(change.label, language),
31 t('review.before', language, {value:JSON.stringify(change.before)}),
32 t('review.after', language, {value:JSON.stringify(change.after)})];
33 rows.push(...wrapText(paragraphs, width).map(text => ({text, section, heading:false})));
34 }
35 }
36 return rows.length ? rows : [{text:t('review.empty', language),section:'',heading:false}];
37}
38export function navigateReview(review: ReviewState, key: string, language: Language, width: number, capacity: number): void {
39 const groups = sections(review);
40 let follow = false;
41 if (key === 'up' || key === 'down') {
42 review.selected = groups[Math.max(0, Math.min(groups.length - 1, groups.indexOf(review.selected) + (key === 'up' ? -1 : 1)))] ?? '';
43 follow = true;
44 } else if (key === 'return' && review.selected) {
45 review.expanded = review.expanded.includes(review.selected) ? review.expanded.filter(group => group !== review.selected) : [...review.expanded, review.selected];
46 follow = true;
47 } else if (key === 'tab') review.previewScope = review.previewScope === 'main' ? 'subagent' : 'main';
48 else if (key === 'pageup') review.scroll -= capacity;
49 else if (key === 'pagedown') review.scroll += capacity;
50 else if (key === 'home') review.scroll = 0;
51 else if (key === 'end') review.scroll = Number.MAX_SAFE_INTEGER;
52 const rows = reviewRows(review, language, width);
53 if (follow) review.scroll = Math.max(0, rows.findIndex(row => row.heading && row.section === review.selected));
54 review.scroll = Math.max(0, Math.min(Math.max(0, rows.length - capacity), review.scroll));
55}
56lib/preferences.ts 62 lines1import type { ConfigRow, ConfigValue } from 'claude-code';
2import type { LocalizedText } from './i18n/index.ts';
3
4/** Actual config-menu row IDs differ from persisted settings keys. */
5export const PREFERENCE_SPECS = [
6 { id: 'theme', keys: ['theme'], label: 'Theme', group: 'Claude appearance', entry: '/config theme' },
7 { id: 'verbose', keys: ['verbose'], label: 'Verbose output', group: 'Claude appearance', entry: '/config verbose' },
8 { id: 'showTurnDuration', keys: ['turnDuration', 'showTurnDuration'], label: 'Show turn duration', group: 'Claude appearance', entry: '/config' },
9 { id: 'prefersReducedMotion', keys: ['reduceMotion', 'prefersReducedMotion'], label: 'Reduce motion', group: 'Claude appearance', entry: '/config' },
10 { id: 'spinnerTipsEnabled', keys: ['tips', 'spinnerTipsEnabled'], label: 'Spinner tips', group: 'Claude appearance', entry: '/config' },
11 { id: 'terminalProgressBarEnabled', keys: ['progressBar', 'terminalProgressBarEnabled'], label: 'Terminal progress bar', group: 'Claude appearance', entry: '/config' },
12 { id: 'preferredNotifChannel', keys: ['notifChannel', 'preferredNotifChannel'], label: 'Notification channel', group: 'Claude appearance', entry: '/config' },
13 { id: 'timeFormat', keys: ['timeFormat'], label: 'Time format', group: 'Claude time / title', entry: '/config timeFormat (24-hour-utc selects UTC)' },
14 { id: 'timeZone', keys: ['timeZone'], label: 'Time zone', group: 'Claude time / title', entry: '/config timeFormat or official settings reference' },
15 { id: 'title', keys: ['terminalTitle', 'terminalTitleEnabled'], label: 'Terminal title', group: 'Claude time / title', entry: 'Official settings reference: terminal title preferences' },
16 { id: 'model', keys: ['model'], label: 'Model behavior', group: 'Claude behavior', entry: '/model' },
17 { id: 'effort', keys: ['effort', 'effortLevel'], label: 'Reasoning effort', group: 'Claude behavior', entry: '/effort' },
18 { id: 'thinking', keys: ['thinking'], label: 'Thinking behavior', group: 'Claude behavior', entry: '/config' },
19 { id: 'fast', keys: ['fast', 'fastMode'], label: 'Fast mode behavior', group: 'Claude behavior', entry: '/fast' },
20] as const;
21
22export interface Preference {
23 row: ConfigRow;
24 baseline: ConfigValue;
25 value: ConfigValue;
26 result: string;
27 localizedResult?: LocalizedText;
28}
29
30export function specFor(preference: Preference) {
31 return PREFERENCE_SPECS.find((spec) => (spec.keys as readonly string[]).includes(preference.row.key));
32}
33
34export function preferences(rows: readonly ConfigRow[]): Preference[] {
35 return PREFERENCE_SPECS.flatMap((spec) => {
36 const row = rows.find((row) => (spec.keys as readonly string[]).includes(row.key));
37 return row ? [{ row: { ...row }, baseline: row.value, value: row.value, result: '' }] : [];
38 });
39}
40
41export function canEdit(preference: Preference): boolean {
42 const row = preference.row;
43 return !row.isLocked && (
44 (row.kind === 'choice' && typeof row.value === 'string' && !!row.options?.length && row.options.every((s) => typeof s === 'string')) ||
45 (row.kind === 'boolean' && typeof row.value === 'boolean') ||
46 (row.kind === 'text' && typeof row.value === 'string') ||
47 (row.kind === 'number' && typeof row.value === 'number' && Number.isFinite(row.value)));
48}
49
50export function validPreferenceValue(preference: Preference): boolean {
51 const row = preference.row, value = preference.value;
52 return canEdit(preference) && (
53 row.kind === 'boolean' ? typeof value === 'boolean' :
54 row.kind === 'choice' ? typeof value === 'string' && !!row.options?.includes(value) :
55 row.kind === 'number' ? typeof value === 'number' && Number.isFinite(value) :
56 typeof value === 'string' && value.length <= 256 && !/[\x00-\x1f\x7f]/.test(value));
57}
58
59export function preferenceChanged(preference: Preference): boolean {
60 return JSON.stringify(preference.value) !== JSON.stringify(preference.baseline);
61}
62lib/generated-contracts.ts 277 lines1// Generated by tools/generation/resources.ts; do not edit.
2export const PROTOCOL_VERSION = 11 as const;
3export const ITEM_DEFAULTS = {"label": null, "icon": null, "priority": 50, "max_width": null, "formatting": {}, "foreground": null, "background": null, "visibility": "always", "visibility_threshold": 70};
4export const VISIBILITY_ITEMS = {"main": {"git": "git-dirty", "git-changes": "git-dirty", "active-agents": "nonzero", "task-progress": "nonzero", "context-used": "used-at-least", "context-remaining": "used-at-least", "five-hour-limit": "used-at-least", "weekly-limit": "used-at-least", "spend-limit": "used-at-least"}, "subagent": {"context-used": "used-at-least", "context-remaining": "used-at-least"}} as const;
5export const GUIDANCE_KEYS = {"source_kinds": ["configuration", "official_input", "local_git", "local_system", "transcript", "local_state", "lifecycle", "native_timing", "live_metrics", "telemetry"], "scopes": ["main", "main_context", "session", "all_sessions", "task", "subagent", "subagent_context", "repository", "local", "latest_request"], "requirements": ["observed_field", "subagent_rows", "agent_context", "configured_effort", "agent_effort_optional", "git_repository", "git_upstream", "git_base", "pull_request", "worktree_session", "usage_records", "task_observation", "native_timing", "complete_waits", "live_metrics", "owned_agents", "checklist_events", "tool_events", "complete_stream", "request_coverage", "existing_telemetry", "gateway", "gateway_284", "valid_window", "warm_cache", "fast_mode", "thinking", "agent_mode", "vim_mode", "context_observation"]} as const;
6export const EDITOR_FIELDS = {"global": [{"key": "theme", "label": "Statusline theme", "group": "Appearance", "kind": "choice", "choices": ["classic", "dark", "light", "terminal"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "powerline_glyph", "label": "Powerline separator", "group": "Appearance", "kind": "choice", "choices": ["ascii", "powerline"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "statusline_language", "label": "Statusline language (save with display settings)", "group": "Appearance", "kind": "choice", "choices": ["en", "zh-CN", "zh-Hant", "ja", "ko"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "metrics.branch_diff_base_ref", "label": "Branch diff base (inherit = auto)", "group": "Git metrics", "kind": "text", "choices": [], "minimum": 0, "maximum": 10000, "nullable": true}, {"key": "formatting.model_name", "label": "Model Name", "group": "Formatting", "kind": "choice", "choices": ["original", "short"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "formatting.number_format", "label": "Number Format", "group": "Formatting", "kind": "choice", "choices": ["legacy", "compact", "full", "grouped"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "formatting.labels", "label": "Labels", "group": "Formatting", "kind": "choice", "choices": ["legacy", "short", "off"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "formatting.icons", "label": "Icons", "group": "Formatting", "kind": "choice", "choices": ["legacy", "unicode", "ascii", "off"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "formatting.allowance", "label": "Allowance", "group": "Formatting", "kind": "choice", "choices": ["remaining", "used"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "formatting.reset_format", "label": "Reset Format", "group": "Formatting", "kind": "choice", "choices": ["countdown", "time", "datetime"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "formatting.reset_timezone", "label": "Reset Timezone", "group": "Formatting", "kind": "choice", "choices": ["local", "UTC"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "formatting.thresholds.enabled", "label": "Threshold colors", "group": "Risk colors", "kind": "boolean", "choices": [], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "formatting.thresholds.warning", "label": "Warning (%)", "group": "Risk colors", "kind": "integer", "choices": [], "minimum": 0, "maximum": 100, "nullable": false}, {"key": "formatting.thresholds.critical", "label": "Critical (%)", "group": "Risk colors", "kind": "integer", "choices": [], "minimum": 0, "maximum": 100, "nullable": false}, {"key": "subagents.visibility", "label": "Visible agents", "group": "Subagent visibility", "kind": "choice", "choices": ["all", "running"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "subagents.hide_completed", "label": "Hide completed", "group": "Subagent visibility", "kind": "boolean", "choices": [], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "subagents.row_limit", "label": "Agent row limit", "group": "Subagent visibility", "kind": "integer", "choices": [], "minimum": 0, "maximum": 10000, "nullable": true}, {"key": "subagents.task_max_width", "label": "Task text width", "group": "Subagent visibility", "kind": "integer", "choices": [], "minimum": 2, "maximum": 10000, "nullable": true}], "item": [{"key": "foreground", "label": "Foreground (inherit/default/ansi:N/#RRGGBB)", "group": "Item colors", "kind": "text", "choices": [], "minimum": 0, "maximum": 10000, "nullable": true}, {"key": "background", "label": "Background (inherit/default/ansi:N/#RRGGBB)", "group": "Item colors", "kind": "text", "choices": [], "minimum": 0, "maximum": 10000, "nullable": true}, {"key": "visibility", "label": "Visibility rule", "group": "Conditional visibility", "kind": "choice", "choices": ["always", "git-dirty", "nonzero", "used-at-least"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "visibility_threshold", "label": "Minimum used (%)", "group": "Conditional visibility", "kind": "integer", "choices": [], "minimum": 0, "maximum": 100, "nullable": false}, {"key": "label", "label": "Label (inherit = default)", "group": "Item format", "kind": "text", "choices": [], "minimum": 0, "maximum": 10000, "nullable": true}, {"key": "icon", "label": "Icon (inherit = default)", "group": "Item format", "kind": "text", "choices": [], "minimum": 0, "maximum": 10000, "nullable": true}, {"key": "priority", "label": "Priority", "group": "Item fitting", "kind": "integer", "choices": [], "minimum": 0, "maximum": 100, "nullable": false}, {"key": "max_width", "label": "Maximum width", "group": "Item fitting", "kind": "integer", "choices": [], "minimum": 2, "maximum": 10000, "nullable": true}, {"key": "model_name", "label": "Model Name", "group": "Item format", "kind": "choice", "choices": ["inherit", "original", "short"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "number_format", "label": "Number Format", "group": "Item format", "kind": "choice", "choices": ["inherit", "legacy", "compact", "full", "grouped"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "labels", "label": "Labels", "group": "Item format", "kind": "choice", "choices": ["inherit", "legacy", "short", "off"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "icons", "label": "Icons", "group": "Item format", "kind": "choice", "choices": ["inherit", "legacy", "unicode", "ascii", "off"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "allowance", "label": "Allowance", "group": "Item format", "kind": "choice", "choices": ["inherit", "remaining", "used"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "reset_format", "label": "Reset Format", "group": "Item format", "kind": "choice", "choices": ["inherit", "countdown", "time", "datetime"], "minimum": 0, "maximum": 10000, "nullable": false}, {"key": "reset_timezone", "label": "Reset Timezone", "group": "Item format", "kind": "choice", "choices": ["inherit", "local", "UTC"], "minimum": 0, "maximum": 10000, "nullable": false}]} as const;
7export const PRESETS = [{"id": "left-right", "label": "Left Right", "rows": [["model-with-effort", "git", "spacer", "context-remaining"]], "layout_mode": "explicit"}, {"id": "repeated-metrics", "label": "Repeated Metrics", "rows": [["input-tokens", "input-tokens"]], "layout_mode": "explicit"}, {"id": "minimal", "label": "Minimal", "rows": [["model-with-effort", "current-dir", "context-remaining", "task-timer"]], "layout_mode": "auto"}, {"id": "developer", "label": "Developer", "rows": [["model-with-effort", "current-dir", "git"], ["context-remaining", "tokens", "task-timer", "session-cost"]], "layout_mode": "explicit"}, {"id": "monitoring", "label": "Monitoring", "rows": [["context-used", "five-hour-limit", "weekly-limit", "spend-limit"], ["five-hour-reset", "weekly-reset", "cache-state", "cache-expires"], ["session-cost", "session-duration", "api-duration", "api-requests", "cache-misses"]], "layout_mode": "explicit"}, {"id": "multi-agent", "label": "Multi Agent", "rows": [["model-with-effort", "current-dir", "git"], ["context-remaining", "tokens", "task-timer"]], "layout_mode": "explicit"}] as const;
8export const FORMAT_CHOICES = {"model_name": ["original", "short"], "number_format": ["legacy", "compact", "full", "grouped"], "labels": ["legacy", "short", "off"], "icons": ["legacy", "unicode", "ascii", "off"], "allowance": ["remaining", "used"], "reset_format": ["countdown", "time", "datetime"], "reset_timezone": ["local", "UTC"]} as const;
9export const FORMAT_DEFAULTS = {"model_name": "original", "number_format": "legacy", "labels": "legacy", "icons": "legacy", "allowance": "remaining", "reset_format": "countdown", "reset_timezone": "local", "thresholds": {"enabled": false, "warning": 70, "critical": 90}} as const;
10export type MainItemId = "model-with-effort" | "model" | "effort" | "fast-mode" | "thinking" | "current-dir" | "project-name" | "hostname" | "git" | "git-branch" | "git-changes" | "git-ahead-behind" | "branch-diff" | "pr" | "repo" | "worktree" | "context-remaining" | "context-used" | "context-window-size" | "context-tokens" | "five-hour-limit" | "five-hour-reset" | "weekly-limit" | "weekly-reset" | "spend-limit" | "spend-amount" | "spend-period" | "tokens" | "input-tokens" | "output-tokens" | "prompt-cache" | "cache-state" | "cache-expires" | "cache-misses" | "api-requests" | "task-timer" | "task-active-timer" | "version" | "session" | "session-name" | "session-id" | "session-id-short" | "output-style" | "cost" | "session-cost" | "session-duration" | "api-duration" | "lines-changed" | "agent" | "vim-mode" | "run-state" | "permission-mode" | "active-agents" | "task-progress" | "last-tool" | "ttft" | "output-rate" | "prompt-input-tokens" | "prompt-output-tokens" | "prompt-cost" | "text" | "spacer";
11export type SubagentItemId = "status-elapsed" | "status" | "name" | "model-with-effort" | "model" | "effort" | "context-remaining" | "context-used" | "context-tokens" | "context-window-size" | "elapsed" | "task" | "tokens" | "current-dir" | "text" | "spacer";
12export type Palette = "default" | "ansi";
13export type Theme = "classic" | "dark" | "light" | "terminal";
14export type PowerlineGlyph = "ascii" | "powerline";
15export type VisibilityRule = "always" | "git-dirty" | "nonzero" | "used-at-least";
16export type DirectoryStyle = "full" | "home" | "project-relative" | "basename";
17export type SeparatorStyle = "classic" | "compact" | "powerline";
18export type ScopeLabels = "off" | "when-subagents" | "always";
19export type StatuslineLanguage = "en" | "zh-CN" | "zh-Hant" | "ja" | "ko";
20export type Scope = "main" | "subagent";
21export type UnavailableReason = "not_observed" | "unsupported_host" | "unknown_host_version" | "source_unavailable" | "condition_not_met" | "runtime_disabled" | "native_timing_disabled" | "wait_coverage_missing" | "stale" | "incomplete" | "ambiguous_owner" | "observed_only" | "missing_stream" | "synthetic_response" | "abnormal_clock" | "inconsistent_request" | "request_join_unavailable";
22export const MAIN_ITEM_IDS = ["model-with-effort", "model", "effort", "fast-mode", "thinking", "current-dir", "project-name", "hostname", "git", "git-branch", "git-changes", "git-ahead-behind", "branch-diff", "pr", "repo", "worktree", "context-remaining", "context-used", "context-window-size", "context-tokens", "five-hour-limit", "five-hour-reset", "weekly-limit", "weekly-reset", "spend-limit", "spend-amount", "spend-period", "tokens", "input-tokens", "output-tokens", "prompt-cache", "cache-state", "cache-expires", "cache-misses", "api-requests", "task-timer", "task-active-timer", "version", "session", "session-name", "session-id", "session-id-short", "output-style", "cost", "session-cost", "session-duration", "api-duration", "lines-changed", "agent", "vim-mode", "run-state", "permission-mode", "active-agents", "task-progress", "last-tool", "ttft", "output-rate", "prompt-input-tokens", "prompt-output-tokens", "prompt-cost", "text", "spacer"] as const;
23export const SUBAGENT_ITEM_IDS = ["status-elapsed", "status", "name", "model-with-effort", "model", "effort", "context-remaining", "context-used", "context-tokens", "context-window-size", "elapsed", "task", "tokens", "current-dir", "text", "spacer"] as const;
24export const PALETTE_VALUES = ["default", "ansi"] as const;
25export const THEME_VALUES = ["classic", "dark", "light", "terminal"] as const;
26export const POWERLINEGLYPH_VALUES = ["ascii", "powerline"] as const;
27export const VISIBILITYRULE_VALUES = ["always", "git-dirty", "nonzero", "used-at-least"] as const;
28export const DIRECTORYSTYLE_VALUES = ["full", "home", "project-relative", "basename"] as const;
29export const SEPARATORSTYLE_VALUES = ["classic", "compact", "powerline"] as const;
30export const SCOPELABELS_VALUES = ["off", "when-subagents", "always"] as const;
31export const STATUSLINELANGUAGE_VALUES = ["en", "zh-CN", "zh-Hant", "ja", "ko"] as const;
32export const UNAVAILABLEREASON_VALUES = ["not_observed", "unsupported_host", "unknown_host_version", "source_unavailable", "condition_not_met", "runtime_disabled", "native_timing_disabled", "wait_coverage_missing", "stale", "incomplete", "ambiguous_owner", "observed_only", "missing_stream", "synthetic_response", "abnormal_clock", "inconsistent_request", "request_join_unavailable"] as const;
33export type Operation = "describe" | "read" | "preview" | "apply" | "import" | "export" | "preset" | "read_ui_preferences" | "set_ui_language" | "review_import";
34export interface ItemGuidance {
35 source_kinds: string[];
36 measurement_scope: string;
37 requirements: string[];
38 setup: string[];
39}
40export interface CatalogItem {
41 scope: Scope;
42 id: string;
43 label: string;
44 description: string;
45 group: string;
46 sources: string[];
47 examples: string[];
48 default_position: number | null;
49 minimum_version: string | null;
50 format_options: string[];
51 excludes: string[];
52 unavailable_reasons: UnavailableReason[];
53 default_enabled: boolean;
54 minimum_version_status: "verified" | "unknown";
55 guidance: ItemGuidance;
56}
57export interface ChoiceOptions {
58 choices: (string | boolean)[];
59}
60export interface RangeOptions {
61 minimum: number;
62 maximum: number;
63}
64export interface RefreshOptions {
65 minimum: number;
66 maximum: number;
67 special: "event";
68}
69export interface ConfigurationOptions {
70 colors: ChoiceOptions;
71 palette: ChoiceOptions;
72 theme: ChoiceOptions;
73 "powerline-glyph": ChoiceOptions;
74 "directory-style": ChoiceOptions;
75 "separator-style": ChoiceOptions;
76 "scope-labels": ChoiceOptions;
77 "statusline-language": ChoiceOptions;
78 "subagent-statusline": ChoiceOptions;
79 padding: RangeOptions;
80 "refresh-interval": RefreshOptions;
81 "hide-vim-mode-indicator": ChoiceOptions;
82}
83export interface Capabilities {
84 host_version: string | null;
85 subagent_rows: "unknown" | "supported" | "unsupported";
86 native_mod: "unknown" | "unverified" | "unsupported";
87 native_mod_loaded: null;
88 data_observation: "not_observed";
89}
90export interface ThresholdDraft {
91 enabled: boolean;
92 warning: number;
93 critical: number;
94}
95export interface FormattingDraft {
96 model_name: "original" | "short";
97 number_format: "legacy" | "compact" | "full" | "grouped";
98 labels: "legacy" | "short" | "off";
99 icons: "legacy" | "unicode" | "ascii" | "off";
100 allowance: "remaining" | "used";
101 reset_format: "countdown" | "time" | "datetime";
102 reset_timezone: "local" | "UTC";
103 thresholds: ThresholdDraft;
104}
105export interface ItemOptionsDraft {
106 label: string | null;
107 icon: string | null;
108 priority: number;
109 max_width: number | null;
110 formatting: Record<string, string>;
111 foreground: string | null;
112 background: string | null;
113 visibility: VisibilityRule;
114 visibility_threshold: number;
115}
116export interface InstanceDraft {
117 id: string;
118 type_id: string;
119 enabled: boolean;
120 options: ItemOptionsDraft;
121 text: string | null;
122}
123export interface LayoutDraft {
124 mode: "auto" | "explicit";
125 rows: string[][];
126}
127export interface SubagentDraft {
128 enabled: boolean;
129 instances: InstanceDraft[];
130 visibility: "all" | "running";
131 hide_completed: boolean;
132 row_limit: number | null;
133 task_max_width: number | null;
134}
135export interface MetricsDraft {
136 branch_diff_base_ref: string | null;
137}
138export interface DisplayDraft {
139 schema_version: 8;
140 statusline_language: StatuslineLanguage;
141 instances: InstanceDraft[];
142 use_colors: boolean;
143 palette: Palette;
144 theme: Theme;
145 powerline_glyph: PowerlineGlyph;
146 directory_style: DirectoryStyle;
147 separator_style: SeparatorStyle;
148 scope_labels: ScopeLabels;
149 subagents: SubagentDraft;
150 formatting: FormattingDraft;
151 layout: LayoutDraft;
152 metrics: MetricsDraft;
153}
154export interface HostDraft {
155 padding: number;
156 refresh_interval: number | "event";
157 hide_vim_mode_indicator: boolean;
158}
159export interface Draft {
160 display: DisplayDraft;
161 host: HostDraft;
162}
163export interface Foreground {
164 kind: "rgb" | "ansi";
165 value: string | number;
166}
167export interface Span {
168 text: string;
169 bold: boolean;
170 foreground: Foreground | null;
171 background: Foreground | null;
172}
173export interface PreviewResult {
174 sample: true;
175 main: Span[][];
176 subagents: Span[][];
177}
178export interface LocalizedMessage {
179 key: string;
180 params: Record<string, unknown>;
181 fallback: string;
182}
183export interface UiPreferencesResult {
184 schema_version: 1;
185 ui_language: StatuslineLanguage;
186 warning: LocalizedMessage | null;
187}
188export interface ProtocolError {
189 code: string;
190 message: string;
191 localization: LocalizedMessage | null;
192}
193export interface ReadResult {
194 draft: Draft;
195 revision: string;
196 installed: boolean;
197 installation: Record<string, unknown>;
198 capabilities: Capabilities;
199 backend_version: string;
200}
201export interface EditorField {
202 key: string;
203 label: string;
204 group: string;
205 kind: "choice" | "boolean" | "integer" | "text";
206 choices: string[];
207 minimum: number;
208 maximum: number;
209 nullable: boolean;
210}
211export interface PresetDescription {
212 id: string;
213 label: string;
214 rows: string[][];
215 layout_mode: "auto" | "explicit";
216}
217export interface TransferResult {
218 draft: Draft;
219}
220export interface ImportChange {
221 section: "main" | "subagent" | "formatting" | "layout" | "language" | "host";
222 kind: "add" | "remove" | "enable" | "disable" | "reorder" | "change";
223 scope: Scope | null;
224 item_id: string | null;
225 path: string[];
226 before: unknown;
227 after: unknown;
228 label: LocalizedMessage;
229}
230export interface ImportReviewResult {
231 draft: Draft;
232 changes: ImportChange[];
233}
234export interface ExportResult {
235 path: string;
236}
237export interface DescribeResult {
238 catalog: CatalogItem[];
239 options: ConfigurationOptions;
240 capabilities: Capabilities;
241 backend_version: string;
242 operations: string[];
243 formatting_options: Record<string, string[]>;
244 presets: PresetDescription[];
245 editor_fields: Record<string, EditorField[]>;
246}
247export interface ApplyResult {
248 draft: Draft;
249 revision: string;
250 installed: boolean;
251 installation: Record<string, unknown>;
252 capabilities: Capabilities;
253 backend_version: string;
254 changed: boolean;
255 backup_dir: string | null;
256}
257export interface ResultMap {
258 describe: DescribeResult;
259 read: ReadResult;
260 preview: PreviewResult;
261 apply: ApplyResult;
262 import: TransferResult;
263 review_import: ImportReviewResult;
264 export: ExportResult;
265 preset: TransferResult;
266 read_ui_preferences: UiPreferencesResult;
267 set_ui_language: UiPreferencesResult;
268}
269export type ResultFor<O extends Operation> = ResultMap[O];
270export interface Request {
271 protocol_version: typeof PROTOCOL_VERSION;
272 operation: Operation;
273 payload: unknown;
274}
275export type Result = PreviewResult | UiPreferencesResult | ReadResult | TransferResult | ImportReviewResult | ExportResult | DescribeResult | ApplyResult;
276export type Response = { protocol_version: typeof PROTOCOL_VERSION; result: Result } | { protocol_version: typeof PROTOCOL_VERSION; error: ProtocolError };
277ui/layout.ts 50 lines1export const MIN_COLUMNS = 32;
2export const MIN_ROWS = 12;
3
4/** Columns available inside the same frame used by editor drawing. */
5export function contentColumns(columns: number, rows: number): number {
6 return Math.max(1, columns - (columns >= 64 && rows >= 20 ? 2 : 0));
7}
8
9/** Budget a paged body between the header, preview and action rows. */
10export function dimensions(columns: number, rows: number, footerRows = 2) {
11 const framed = columns >= 64 && rows >= 20;
12 const previewRows = Math.min(3, Math.max(1, Math.floor((rows - 8 - footerRows) / 4)));
13 const previewHeight = previewRows + (framed ? 2 : 1);
14 const bodyHeight = Math.max(1, rows - 3 - footerRows - previewHeight);
15 const bodyRows = Math.max(1, bodyHeight - (framed ? 2 : 1));
16 const tableHeading = columns >= 64 && bodyRows >= 4;
17 const showSummary = bodyRows >= (tableHeading ? 4 : 3);
18 const showDetails = bodyRows >= (tableHeading ? 5 : 4);
19 return {
20 columns,
21 rows,
22 framed,
23 previewRows,
24 previewHeight,
25 bodyHeight,
26 bodyRows,
27 tableHeading,
28 showSummary,
29 showDetails,
30 footerRows,
31 itemCapacity: Math.max(1, bodyRows - 1 - Number(tableHeading) - Number(showSummary) - Number(showDetails)),
32 formHeight: Math.max(1, bodyRows - Number(tableHeading) - Number(showSummary)),
33 available: columns >= MIN_COLUMNS && rows >= MIN_ROWS,
34 };
35}
36
37export { cellWidth, displayWidth } from '../lib/unicode.ts';
38import { displayWidth, clip as clipGraphemes } from '../lib/unicode.ts';
39
40/** Clip at complete graphemes after replacing single-line control input. */
41export function clip(text: string, width: number): string {
42 return clipGraphemes(text.replace(/[\x00-\x1f\x7f]/g, ' '), width);
43}
44
45/** Pad clipped labels by terminal cells, including wide and combining text. */
46export function column(text: string, width: number): string {
47 const label = clip(text, width);
48 return label + ' '.repeat(Math.max(0, width - displayWidth(label)));
49}
50ui/theme.ts 28 lines1import type { ClientElements, RenderElement, TextProps } from 'claude-code';
2
3/** Host theme tokens follow the applied theme, including auto/custom themes. */
4export const styles = {
5 text: { color: 'text', backgroundColor: 'inverseText', dimColor: false },
6 muted: { color: 'inactive', dimColor: false },
7 accent: { color: 'suggestion', dimColor: false },
8 error: { color: 'error', dimColor: false },
9 selected: { color: 'inverseText', backgroundColor: 'text', dimColor: false },
10 key: { color: 'text', bold: true, dimColor: false },
11} satisfies Record<string, TextProps>;
12
13/** Button has no base color prop; primary uses the host's suggestion token. */
14export const recoveryButton = { variant: 'primary' as const, dimColor: false };
15
16export function rowStyle(selected: boolean, editable = true): TextProps {
17 return selected ? styles.selected : editable ? styles.text : styles.muted;
18}
19
20/** Paint chrome locally so no themed ancestor covers the sample preview. */
21export function chromeRow(ui: ClientElements, child: RenderElement, width: number): RenderElement {
22 return ui.Box({
23 width, height: 1, flexShrink: 0, overflow: 'hidden',
24 backgroundColor: styles.text.backgroundColor,
25 children: [child],
26 });
27}
28lib/session.ts 125 lines1import { Editor } from './editor/draft.ts';
2import type { Scope, PreviewResult } from './generated-contracts.ts';
3import type { Preference } from './preferences.ts';
4import type { PreviewBackground } from './preview-preferences.ts';
5import type { Language, LocalizedText } from './i18n/index.ts';
6import type { MessageSlot } from './i18n/messages.ts';
7
8export type InputMode =
9 | { kind: 'search'; scope: Scope; original: string; selected: string }
10 | { kind: 'category'; scope: Scope; selected: string }
11 | { kind: 'field'; key: string; buffer: string }
12 | { kind: 'path'; action: 'import' | 'export'; buffer: string }
13 | { kind: 'numeric'; field: 'padding' | 'refresh_interval' }
14 | null;
15
16export interface View {
17 language?: Language;
18 localized?: Partial<Record<MessageSlot, LocalizedText>>;
19 previewBackground?: PreviewBackground;
20 editor: Editor | null;
21 input: InputMode;
22 preview: PreviewResult | null;
23 preferences: Preference[];
24 preferencesError: string;
25 error: string;
26 previewError: string;
27 previewBusy: boolean;
28 message: string;
29 busy: string;
30 uncertain: boolean;
31}
32
33type EditorData = Pick<
34 Editor,
35 | 'description'
36 | 'baseline'
37 | 'draft'
38 | 'page'
39 | 'advanced'
40 | 'adding'
41 | 'pickerSaved'
42 | 'detail'
43 | 'guidanceScroll'
44 | 'review'
45 | 'preset'
46 | 'path'
47 | 'pendingTransfer'
48 | 'setting'
49 | 'activeNumeric'
50 | 'selected'
51 | 'order'
52 | 'search'
53 | 'category'
54 | 'buffers'
55 | 'fieldErrors'
56 | 'fieldErrorMessages'
57>;
58export interface ClientProps {
59 epoch: number;
60 ack: number;
61 generation: number;
62 columns: number;
63 rows: number;
64 view: Omit<View, 'editor'> & { editor: EditorData | null };
65}
66
67export function clientProps(
68 view: View,
69 epoch: number,
70 ack: number,
71 generation: number,
72 columns: number,
73 rows: number,
74): ClientProps {
75 const e = view.editor;
76 // The host freezes port data recursively. Never expose live editor arrays or
77 // preference rows: subsequent keys must still mutate our own draft.
78 return JSON.parse(
79 JSON.stringify({
80 epoch,
81 ack,
82 generation,
83 columns,
84 rows,
85 view: {
86 ...view,
87 editor: e
88 ? {
89 description: e.description,
90 baseline: e.baseline,
91 draft: e.draft,
92 page: e.page,
93 advanced: e.advanced,
94 adding: e.adding,
95 pickerSaved: e.pickerSaved,
96 detail: e.detail, preset: e.preset, path: e.path, pendingTransfer: e.pendingTransfer,
97 guidanceScroll: e.guidanceScroll,
98 review: e.review,
99 setting: e.setting,
100 activeNumeric: e.activeNumeric,
101 selected: e.selected,
102 order: e.order,
103 search: e.search,
104 category: e.category,
105 buffers: e.buffers,
106 fieldErrors: e.fieldErrors,
107 fieldErrorMessages: e.fieldErrorMessages,
108 }
109 : null,
110 },
111 }),
112 ) as ClientProps;
113}
114
115/** Rendering uses the same editor model, with only plain data crossing ports. */
116export function clientView(props: ClientProps): View {
117 const data = props.view.editor;
118 return {
119 ...props.view,
120 editor: data
121 ? Object.assign(new Editor(data.description, data.baseline), data)
122 : null,
123 };
124}
125lib/client/messages.ts 102 lines1import type { ClientKeyEvent } from 'claude-code';
2
3export const MAX_BATCH = 128;
4export const MAX_PENDING = 512;
5export interface KeyMessage {
6 seq: number;
7 event: ClientKeyEvent;
8}
9export interface Batch {
10 epoch: number;
11 columns: number;
12 rows: number;
13 events: KeyMessage[];
14}
15
16const SPECIAL_KEYS = new Set([
17 'up',
18 'down',
19 'left',
20 'right',
21 'return',
22 'tab',
23 'backspace',
24 'delete',
25 'pageup',
26 'pagedown',
27 'home',
28 'end',
29 'insert',
30 'escape',
31 'esc',
32 'space',
33 'clear',
34 'capslock',
35 'numlock',
36 'scrolllock',
37 'pause',
38 'printscreen',
39]);
40
41/** The terminal may deliver a fast printable run as a single input chunk. */
42export function keyEvents(event: ClientKeyEvent): ClientKeyEvent[] {
43 if (
44 event.ctrl ||
45 event.meta ||
46 [...event.key].length <= 1 ||
47 SPECIAL_KEYS.has(event.key) ||
48 /^f\d{1,2}$/i.test(event.key) ||
49 /[\x00-\x1f\x7f]/.test(event.key)
50 )
51 return [event];
52 return [...event.key].map((key) => ({ ...event, key }));
53}
54
55/** Posts are input, including when our own module is their usual sender. */
56export function parseBatch(value: unknown): Batch | null {
57 if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
58 const b = value as Record<string, unknown>;
59 if (Object.keys(b).sort().join(',') !== 'columns,epoch,events,rows')
60 return null;
61 for (const field of ['epoch', 'columns', 'rows']) {
62 if (!Number.isSafeInteger(b[field]) || Number(b[field]) < 0) return null;
63 }
64 if (
65 Number(b.columns) > 10000 ||
66 Number(b.rows) > 10000 ||
67 !Array.isArray(b.events) ||
68 b.events.length > MAX_BATCH
69 )
70 return null;
71 let previous = 0;
72 for (const message of b.events) {
73 if (
74 !message ||
75 typeof message !== 'object' ||
76 Array.isArray(message) ||
77 Object.keys(message).sort().join(',') !== 'event,seq' ||
78 !Number.isSafeInteger(message.seq) ||
79 message.seq <= previous
80 )
81 return null;
82 previous = message.seq;
83 const e = message.event;
84 if (
85 !e ||
86 typeof e !== 'object' ||
87 Array.isArray(e) ||
88 typeof e.key !== 'string' ||
89 !e.key.length ||
90 e.key.length > 64 ||
91 /[\x00-\x1f\x7f]/.test(e.key) ||
92 Object.keys(e).some(
93 (key) =>
94 !['key', 'ctrl', 'shift', 'meta'].includes(key) ||
95 (key !== 'key' && e[key] !== true),
96 )
97 )
98 return null;
99 }
100 return value as Batch;
101}
102lib/client/keys.ts 199 lines1import { graphemes } from '../unicode.ts';
2import { setMessage, failureMessage } from '../i18n/messages.ts';
3import { text as localizedText } from '../i18n/index.ts';
4import type { ClientKeyEvent } from 'claude-code';
5import type { View } from '../session.ts';
6import { settingRows, adjustSetting } from './settings.ts';
7import { formRows, setFormValue } from './forms.ts';
8import { canEdit, validPreferenceValue } from '../preferences.ts';
9import { editorLayout } from '../../ui/client/help.ts';
10import { formSelection, pageSelection } from '../editor/navigation.ts';
11import { guidanceLines } from '../editor/guidance.ts';
12import { navigateReview } from '../editor/import-review.ts';
13
14export type Effect = 'save' | 'finish' | 'close' | 'reload' | 'reconcile' | 'retry' | 'applyPreferences' | 'transfer' | 'previewBackground' | 'uiLanguage' | 'reviewPreview' | null;
15
16export function cancelInput(view: View): void {
17 const input = view.input, e = view.editor;
18 if (!e) return;
19 if (e.review) { e.review = null; setMessage(view, 'message', localizedText('review.cancelled')); return; }
20 if (e.guidanceScroll !== null) { e.guidanceScroll = null; if(e.adding)e.detail=null; return; }
21 if (!input) { if(e.adding)e.closePicker(e.page==='main'?'main':'subagent'); else e.detail = null; return; }
22 if (input.kind === 'search') {
23 e.filter(input.scope, input.original);
24 e.selected[input.scope] = input.selected;
25 } else if (input.kind === 'numeric') e.cancelNumeric(input.field);
26 view.input = null;
27}
28
29/** All transitions are pure; persistence remains in the host hooks module. */
30export function handleKey(view: View, event: ClientKeyEvent, columns: number, rows: number): Effect {
31 const e = view.editor, key = event.key === 'space' ? ' ' : event.key;
32 const shortcut = /^[A-Z]$/.test(key) ? key.toLowerCase() : key;
33 if (!e || view.busy) return null;
34 if (event.ctrl && key.toLowerCase() === 'g') { const reviewing = !!e.review; cancelInput(view); return reviewing ? 'reviewPreview' : null; }
35 if (view.uncertain) return event.ctrl || event.meta ? null : shortcut === 'k' ? 'reconcile' : shortcut === 'q' ? 'close' : null;
36 if (e.review) {
37 if (event.ctrl || event.meta) return null;
38 if (shortcut === 'a') {
39 e.replaceDraft(e.review.result.draft);
40 setMessage(view, 'message', localizedText('review.accepted'));
41 return 'reviewPreview';
42 }
43 if (shortcut === 'q') { cancelInput(view); return 'reviewPreview'; }
44 const layout = editorLayout(view, columns, rows);
45 navigateReview(e.review, key, view.language ?? 'en', Math.max(2, columns - (layout.framed ? 2 : 0)), Math.max(1, layout.bodyRows - 1));
46 return null;
47 }
48 if (e.guidanceScroll !== null && e.detail) {
49 const layout = editorLayout(view, columns, rows);
50 const width = Math.max(2, columns - (layout.framed ? 2 : 0));
51 const item = e.definition(e.detail.scope,e.detail.id)!;
52 const capacity = Math.max(1, layout.bodyRows - 1), maximum = Math.max(0, guidanceLines(item, view.language ?? 'en', width).length - capacity);
53 if (key === 'up') e.guidanceScroll--;
54 else if (key === 'down') e.guidanceScroll++;
55 else if (key === 'pageup') e.guidanceScroll -= capacity;
56 else if (key === 'pagedown') e.guidanceScroll += capacity;
57 else if (key === 'home') e.guidanceScroll = 0;
58 else if (key === 'end') e.guidanceScroll = maximum;
59 e.guidanceScroll = Math.max(0, Math.min(maximum, e.guidanceScroll));
60 return null;
61 }
62 const input = view.input;
63 if (input?.kind === 'category') {
64 view.message = '';
65 const categories = e.categories(input.scope);
66 const index = categories.indexOf(input.selected);
67 if (key === 'return') { e.chooseCategory(input.scope, input.selected); view.input = null; }
68 else if (key === 'up' || key === 'left') input.selected = categories[Math.max(0, index - 1)]!;
69 else if (key === 'down' || key === 'right') input.selected = categories[Math.min(categories.length - 1, index + 1)]!;
70 else if (key === 'home' || key === 'pageup') input.selected = categories[0]!;
71 else if (key === 'end' || key === 'pagedown') input.selected = categories.at(-1)!;
72 return null;
73 }
74 if (input?.kind === 'field' || input?.kind === 'path') {
75 if (event.ctrl && key.toLowerCase() === 'u') input.buffer = '';
76 else if (key === 'return') {
77 if (input.kind === 'path') {
78 if (!input.buffer.trim()) { setMessage(view, "message", localizedText("native.lib.client.keys.enter_a_file_path")); return null; }
79 e.path = input.buffer; e.pendingTransfer = input.action; view.input = null; return 'transfer';
80 }
81 const field = formRows(view).find((row) => row.key === input.key);
82 if (field && !setFormValue(view, field, input.buffer)) return null;
83 if (!field) {
84 const p = view.preferences.find((p) => 'host-' + p.row.key === input.key);
85 if (!p || !canEdit(p)) { setMessage(view, "message", localizedText("native.lib.client.keys.host_row_is_unavailable_or_locked")); return null; }
86 const previous = p.value;
87 p.value = p.row.kind === 'number' ? Number(input.buffer) : input.buffer;
88 if (!validPreferenceValue(p) || (p.row.kind === 'number' && !input.buffer.trim())) {
89 p.value = previous; setMessage(view, "message", localizedText("native.lib.client.keys.invalid_host_value")); return null;
90 }
91 p.result = '';
92 }
93 view.input = null;
94 } else if (key === 'backspace' || key === 'delete') input.buffer = graphemes(input.buffer).slice(0, -1).join('');
95 else if (!event.ctrl && !event.meta && [...key].length === 1 && [...input.buffer].length < (input.kind === 'path' ? 4096 : 256)) input.buffer += key;
96 return null;
97 }
98 if (input) {
99 if (input.kind === 'search') view.message = '';
100 if (event.ctrl && key.toLowerCase() === 'u') {
101 if (input.kind === 'search') e.filter(input.scope, ''); else e.setBuffer(input.field, '');
102 } else if (key === 'return') {
103 if (input.kind === 'search' || e.acceptNumeric(input.field)) view.input = null;
104 } else if (key === 'backspace' || key === 'delete') {
105 if (input.kind === 'search') e.filter(input.scope, graphemes(e.search[input.scope]).slice(0, -1).join(''));
106 else e.setBuffer(input.field, graphemes(e.buffers[input.field]).slice(0, -1).join(''));
107 } else if (!event.ctrl && !event.meta && [...key].length === 1) {
108 if (input.kind === 'search') { if ([...e.search[input.scope]].length < 256) e.filter(input.scope, e.search[input.scope] + key); }
109 else if (e.buffers[input.field].length < 32) e.setBuffer(input.field, e.buffers[input.field] + key);
110 }
111 return null;
112 }
113 if(!e.detail&&(e.page==='main'||e.page==='subagents')){
114 const scope=e.page==='main'?'main':'subagent';
115 if(!event.meta && key.toLowerCase()==='a'&&!e.adding){e.openPicker(scope);return null;}
116 if(!event.meta && ['d','x'].includes(key.toLowerCase())&&!e.adding){
117 if(!e.instanceAction(scope,key.toLowerCase()==='d'?'copy':'remove'))
118 setMessage(view,'message',localizedText('composition.invalid'));
119 return null;
120 }
121 if(e.adding&&!event.ctrl&&!event.meta&&(key==='return'||key===' ')){
122 if(!e.instanceAction(scope,'add',e.selected[scope]))setMessage(view,'message',localizedText('composition.invalid'));
123 return null;
124 }
125 if(e.adding&&!event.ctrl&&!event.meta&&shortcut==='q'){e.closePicker(scope);return null;}
126 if(e.adding&&event.ctrl&&key.toLowerCase()==='e'){e.detail={scope,id:e.selected[scope]};e.guidanceScroll=0;return null;}
127 if(e.adding&&['tab','1','2','3','4'].includes(key))return null;
128 }
129 if (event.ctrl && key.toLowerCase() === 'e' && (e.page === 'main' || e.page === 'subagents')) {
130 const scope = e.page === 'main' ? 'main' : 'subagent';
131 if (e.selected[scope]) { e.detail = { scope, id: e.selected[scope] }; e.setting = 'item:label'; }
132 return null;
133 }
134 if (event.ctrl && key.toLowerCase() === 'f' && !e.detail && (e.page === 'main' || e.page === 'subagents')) {
135 const scope = e.page === 'main' ? 'main' : 'subagent';
136 view.input = {kind: 'category', scope, selected: e.category[scope]};
137 return null;
138 }
139 if (event.ctrl || event.meta) return null;
140 if (shortcut === 'q') return 'close';
141 if (shortcut === 's') return 'save';
142 if (shortcut === 'f') return 'finish';
143 if (shortcut === 'r') return 'reload';
144 if (shortcut === 'v') return 'retry';
145 if (shortcut === 'k') return 'reconcile';
146 if (shortcut === 'a' && e.page === 'settings' && !e.detail && e.advanced) return 'applyPreferences';
147 if (shortcut === 'h' && e.page === 'settings' && !e.detail) { e.advanced = !e.advanced; e.setting = e.advanced ? settingRows(view).find((r) => r.key.startsWith('host-'))!.key : 'colors'; return null; }
148 const pages = ['main', 'subagents', 'settings', 'layout'] as const;
149 if (key === 'tab') { e.page = pages[(pages.indexOf(e.page) + (event.shift ? 3 : 1)) % 4]!; e.detail = null; if (e.page === 'settings' || e.page === 'layout') e.setting = settingRows(view).find((r) => r.key === e.setting)?.key ?? settingRows(view)[0]!.key; return null; }
150 if (['1', '2', '3', '4'].includes(key)) { e.page = pages[Number(key) - 1]!; e.detail = null; if (e.page === 'settings' || e.page === 'layout') e.setting = settingRows(view).find((r) => r.key === e.setting)?.key ?? settingRows(view)[0]!.key; return null; }
151 const size = editorLayout(view, columns, rows);
152 if (!size.available) return null;
153 const isSettings = !!e.detail || e.page === 'settings' || e.page === 'layout';
154 const scope = e.page === 'main' ? 'main' : 'subagent';
155 const settings = isSettings ? settingRows(view) : [];
156 const keys = isSettings ? settings.map((row) => row.key) : e.visible(scope).map((row) => row.id as string);
157 const selected = isSettings ? e.setting : e.selected[scope];
158 let index = Math.max(0, keys.indexOf(selected));
159 if (key === 'up') index--;
160 else if (key === 'down') index++;
161 else if (key === 'pageup' || key === 'pagedown') {
162 const delta = key === 'pageup' ? -1 : 1;
163 const target = isSettings ? formSelection(settings, selected, size.formHeight, delta)
164 : pageSelection(keys, selected, size.itemCapacity, delta);
165 index = keys.indexOf(target);
166 }
167 else if (key === 'home') index = 0;
168 else if (key === 'end') index = keys.length - 1;
169 else if (key === '/' && !isSettings) { view.input = {kind:'search',scope,original:e.search[scope],selected:e.selected[scope]}; return null; }
170 else if (key === 'left' || key === 'right') {
171 if (!isSettings && e.filtered(scope)) { setMessage(view, 'message', localizedText('catalog.search.order_disabled')); return null; }
172 if (isSettings) adjustSetting(view, key === 'left' ? -1 : 1);
173 else {
174 const delta = key === 'left' ? -1 : 1;
175 if (keys[index + delta] && !e.move(scope, delta)) setMessage(view, 'message', localizedText('composition.invalid'));
176 }
177 return isSettings && e.setting === 'preview-background' ? 'previewBackground' : isSettings && e.setting === 'ui-language' ? 'uiLanguage' : null;
178 } else if (key === 'return' || key === ' ' || key === 'space') {
179 if (!isSettings) { if(!e.toggle(scope, selected))setMessage(view,"message",localizedText("composition.invalid")); }
180 else if (e.setting === 'item-guidance') e.guidanceScroll = 0;
181 else if (e.setting === 'preset-apply') { e.pendingTransfer = 'preset'; return 'transfer'; }
182 else if (e.setting === 'import-file' || e.setting === 'export-file') {
183 view.input = {kind:'path',action:e.setting === 'import-file' ? 'import' : 'export',buffer:e.path};
184 } else if (e.setting === 'padding' || e.setting === 'refresh_interval') {
185 view.input = {kind:'numeric',field:e.setting}; e.activeNumeric = e.setting;
186 } else {
187 const field = formRows(view).find((row) => row.key === e.setting);
188 const p = view.preferences.find((p) => 'host-' + p.row.key === e.setting);
189 if (field && (field.spec.kind === 'integer' || field.spec.kind === 'text')) view.input = {kind:'field',key:field.key,buffer:field.value};
190 else if (p && canEdit(p) && (p.row.kind === 'text' || p.row.kind === 'number')) view.input = {kind:'field',key:e.setting,buffer:String(p.value)};
191 else adjustSetting(view, 1);
192 }
193 return isSettings && e.setting === 'preview-background' ? 'previewBackground' : isSettings && e.setting === 'ui-language' ? 'uiLanguage' : null;
194 } else return null;
195 const next = keys[Math.max(0, Math.min(keys.length - 1, index))] || '';
196 if (isSettings) e.setting = next; else e.selected[scope] = next;
197 return null;
198}
199lib/preview-preferences.ts 9 lines1/** Editor appearance only; never part of a display draft or portable export. */
2export type PreviewBackground = 'dark' | 'light';
3export const PREVIEW_BACKGROUND_KEY = 'preview-background';
4export const DEFAULT_PREVIEW_BACKGROUND: PreviewBackground = 'dark';
5
6export function previewBackground(value: unknown): PreviewBackground {
7 return value === 'light' ? 'light' : DEFAULT_PREVIEW_BACKGROUND;
8}
9