SLOPSHOPPER

statusline-native

In-session Client TUI for statusline configuration

newpanecommandtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusline-native
│ ┃ Statusline configuration ✕ › fix the failing auth test and add an audit log call │ ┃ Could not open the Client editor. │ ┃ invalid_backend_json: The backend did not re ⏺ Read(src/auth.ts) │ ┃ [ Retry Client ] ⎿ Read 6 lines │ ┃ [ Discard pending / Close ] ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /statusline-configure-native │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Statusline configuration
Could not open the Client editor. invalid_backend_json: The backend did not return comple… [ Retry Client ] [ Discard pending / Close ]
README

Claude Code Statusline

English | 简体中文

CI Native Mod PyPI MIT License

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>

Features

  • Compose your statusline: add, copy and arrange independent components, literal text and left/right alignment in either editor. Composition guide.
  • Control visibility and appearance: hide confirmed empty/clean states, set usage thresholds, choose per-item foreground/background colors and themes, and opt into basic Powerline.
  • Choose your languages: English, 简体中文, 繁體中文, 日本語 and 한국어 for interfaces and actual statusline output, independently; interface changes save immediately, output language saves with display settings. See language examples.
  • Find what to show: 60 main metrics and 14 subagent metrics, plus text and flexible spacers, with ranked multilingual search, category filters and source/requirement guidance.
  • Track the right scope: session token totals, per-task subagent rows, and total task time covering queueing, agents and main-agent wrap-up; optional execution time excludes verified user waits.
  • Adjust presentation: model and number formats, labels, built-in icons, colors, directory styles, and automatic or explicit rows with priorities and width limits.
  • Keep Unicode intact: joined emoji, combining marks and CJK text stay together when rows wrap, clip or appear in previews.
  • Review configuration changes: six editable presets and portable JSON, with import differences and candidate previews before accepting a draft and saving.
  • Choose an editor: Main, Subagents, Settings, and Layout pages share the same configuration. Claude appearance and behavior preferences use a separate Apply action.
  • Follow Claude themes: the in-session editor adapts text, keys and selection to the applied theme; sample previews retain production colors on a separately chosen light/dark background.
  • Follow terminal colors: external TUI text and bold keys use terminal defaults; previews show the selected palette on the current terminal background.
  • Enable optional live metrics: runtime state, agent count, tool progress, request timing, and per-task usage are opt-in. Missing or partial observations stay distinguishable.

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>

Screenshots

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.

English native language selector

简体中文 native language selector

简体中文 external language selector

In-session TUI

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

Linux in-session TUI Main page and actual main status line

Subagents: choose items and ordering for individual agent rows.

Linux in-session TUI Subagents page with sample preview

Settings: adjust appearance, refresh behavior and formatting.

Linux in-session TUI Settings page with sample preview

Layout: set rows, item priorities and maximum widths.

Linux in-session TUI Layout page with sample preview

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.

Linux light theme native editor

Linux dark theme native editor

Main: select and reorder main status line items.

Windows in-session TUI Main page with sample preview

Subagents: choose items and ordering for individual agent rows.

Windows in-session TUI Subagents page with sample preview

Settings: adjust appearance, refresh behavior and formatting.

Windows in-session TUI Settings page with sample preview

Layout: set rows, item priorities and maximum widths.

Windows in-session TUI Layout page with sample preview

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.

macOS in-session TUI Main page with sample preview

External TUI

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

Linux light terminal with default palette

Light background, Palette: ansi

Linux light terminal with ANSI palette

Dark background, Palette: default

Linux dark terminal with default palette

Main: select and reorder main status line items.

Linux external TUI Main page with sample preview

Subagents: choose items and ordering for individual agent rows.

Linux external TUI Subagents page with sample preview

Settings: adjust appearance, refresh behavior and formatting.

Linux external TUI Settings page with sample preview

Layout: set rows, item priorities and maximum widths.

Linux external TUI Layout page with sample preview

Main: select and reorder main status line items.

Windows external TUI Main page with sample preview

Subagents: choose items and ordering for individual agent rows.

Windows external TUI Subagents page with sample preview

Settings: adjust appearance, refresh behavior and formatting.

Windows external TUI Settings page with sample preview

Layout: set rows, item priorities and maximum widths.

Windows external TUI Layout page with sample preview

Main: select and reorder main status line items.

macOS external TUI Main page with sample preview

Subagents: choose items and ordering for individual agent rows.

macOS external TUI Subagents page with sample preview

Settings: adjust appearance, refresh behavior and formatting.

macOS external TUI Settings page with sample preview

Layout: set rows, item priorities and maximum widths.

macOS external TUI Layout page with sample preview

Earlier screenshots and terminal reconstructions remain in the archive.

Supported platforms

PlatformSupported environment
Linux / WSLPython 3.10+
Windows 10/11CPython 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>

Quick installation

Install Python, Claude Code CLI, and pipx. All supported platforms use the same wheel.

Install the package

Bash, Zsh, and PowerShell:

pipx install fbincon-claude-code-statusline
pipx ensurepath

Integrate with Claude Code

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.

Common configuration

Find items, understand their sources, and review imports.

Entry pointUse
/statusline-configure-nativeClient TUI in the current session; see native editor
/statusline-configureTUI in a supported external terminal; see external entry
claude-statusline configureFull TUI in the current standalone terminal
/statusline-configClaude 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>

Interface language

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.

Statusline language

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

English statusline output

Simplified Chinese statusline output

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.

Native configuration editor

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.

External and standalone terminal TUI

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>

Upgrading and uninstalling

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.

Project structure

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

Documentation and help

License

MIT License. Copyright (c) 2026 fbincon.

Bundled Unicode data retains the Unicode License v3 and the wcwidth MIT notice.

Source 37 files
hooks/register.ts 821 lines
1import 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};
821
lib/backend.ts 504 lines
1// 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}
504
lib/editor/draft.ts 255 lines
1// 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}
255
lib/editor/import-review.ts 56 lines
1// 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}
56
lib/preferences.ts 62 lines
1import 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}
62
lib/generated-contracts.ts 277 lines
1// 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 };
277
ui/layout.ts 50 lines
1export 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}
50
ui/theme.ts 28 lines
1import 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}
28
lib/session.ts 125 lines
1import { 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}
125
lib/client/messages.ts 102 lines
1import 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}
102
lib/client/keys.ts 199 lines
1import { 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}
199
lib/preview-preferences.ts 9 lines
1/** 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