zames_pro 2.82.142 → 2.83.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.83.1] - 2026-10-27
11
+
12
+ ### Added
13
+
14
+ - **ui:** /theme pick list with live preview
15
+
16
+ ## [2.83.0] - 2026-10-27
17
+
18
+ ### Added
19
+
20
+ - **ui:** selectable terminal color themes
21
+
10
22
  ## [2.82.142] - 2026-10-27
11
23
 
12
24
  ### Fixed
package/README.md CHANGED
@@ -620,6 +620,19 @@ right away (where possible without a restart).
620
620
  (help, messages, spinner) and the language the agent answers you in. The
621
621
  locale lives in `ui.locale` in the config file.
622
622
 
623
+ ### Themes
624
+
625
+ `/theme` opens a pick list with a LIVE preview: the arrow keys repaint the
626
+ terminal as you move, Enter saves, Esc restores the previous palette.
627
+ `/theme <id>` applies a palette directly, `/theme list` prints every id.
628
+ The same setting is reachable from the settings side:
629
+ `/config set ui.theme <id>` or the `/config` menu («Terminal color theme»).
630
+ The switch is instant, no restart. `zames` is the default; the bundled palettes
631
+ are ports of well-known editor schemes:
632
+
633
+ `zames` (default), `one-dark`, `dracula`, `gruvbox`, `nord`, `monokai`,
634
+ `solarized-dark`, `tokyo-night`.
635
+
623
636
  Agent data is stored in `~/.zames`: browser profile, logs, undo history, sessions.
624
637
 
625
638
  ## FAQ
@@ -1330,6 +1343,19 @@ enum переключаются на месте; числа и строки от
1330
1343
  (справка, сообщения, спиннер), и язык, на котором агент вам отвечает. Локаль
1331
1344
  хранится в `ui.locale` в файле конфига.
1332
1345
 
1346
+ ### Темы
1347
+
1348
+ `/theme` открывает список тем с живым предпросмотром: стрелками перебираете —
1349
+ терминал сразу перекрашивается, Enter сохраняет, Esc возвращает прежнюю.
1350
+ `/theme <id>` применяет тему сразу, `/theme list` показывает все id.
1351
+ То же самое доступно и через настройки: `/config set ui.theme <id>` или меню
1352
+ `/config`, строка «Цветовая тема терминала». Переключение мгновенное, без
1353
+ перезапуска. Тема по умолчанию — `zames`; в комплекте порты известных схем
1354
+ редакторов:
1355
+
1356
+ `zames` (по умолчанию), `one-dark`, `dracula`, `gruvbox`, `nord`, `monokai`,
1357
+ `solarized-dark`, `tokyo-night`.
1358
+
1333
1359
  Данные агента хранятся в `~/.zames`: профиль браузера, логи, история undo,
1334
1360
  сессии.
1335
1361
 
package/dist/commands.js CHANGED
@@ -1245,6 +1245,7 @@ export const SLASH_COMMANDS = [
1245
1245
  { name: '/queue', key: 'help.cmd.queue' },
1246
1246
  { name: '/tasks', key: 'help.cmd.tasks' },
1247
1247
  { name: '/config', key: 'help.cmd.config' },
1248
+ { name: '/theme', key: 'help.cmd.theme' },
1248
1249
  { name: '/provider', key: 'help.cmd.provider' },
1249
1250
  { name: '/model', key: 'help.cmd.model' },
1250
1251
  { name: '/skills', key: 'help.cmd.skills' },
package/dist/config.js CHANGED
@@ -5,6 +5,7 @@ import { DEFAULT_DIAGNOSTICS } from './diagnostics.js';
5
5
  import { DEFAULT_LOCALE } from './i18n.js';
6
6
  import { writeJsonAtomic } from './fsutil.js';
7
7
  import { PROVIDER_IDS } from './providers.js';
8
+ import { DEFAULT_THEME, themeNames } from './theme.js';
8
9
  const ZAMES_HOME = path.join(os.homedir(), '.zames');
9
10
  const HOME_CONFIG = path.join(ZAMES_HOME, 'config.json');
10
11
  // The PROJECT config path is resolved LAZILY: `/cd` changes process.cwd() at
@@ -132,6 +133,7 @@ export const DEFAULTS = {
132
133
  },
133
134
  ui: {
134
135
  locale: DEFAULT_LOCALE,
136
+ theme: DEFAULT_THEME,
135
137
  contextLimit: 1_000_000,
136
138
  // 0 = auto: terminal width, capped at 100 columns (the historical width).
137
139
  answerWidth: 0,
@@ -289,6 +291,13 @@ export const CONFIG_SCHEMA = [
289
291
  labelKey: 'cfg.f.maxIterations',
290
292
  groupKey: 'cfg.group.agent',
291
293
  },
294
+ {
295
+ path: 'ui.theme',
296
+ type: 'enum',
297
+ values: themeNames(),
298
+ labelKey: 'cfg.f.ui_theme',
299
+ groupKey: 'cfg.group.ui',
300
+ },
292
301
  {
293
302
  path: 'ui.answerWidth',
294
303
  type: 'number',
@@ -55,6 +55,46 @@ export const configMessages = {
55
55
  ru: 'Язык интерфейса и ответов агента',
56
56
  en: 'Interface and agent reply language',
57
57
  },
58
+ 'cfg.f.ui_theme': {
59
+ ru: 'Цветовая тема терминала',
60
+ en: 'Terminal color theme',
61
+ },
62
+ 'theme.pick_title': {
63
+ ru: 'Выбор темы',
64
+ en: 'Select a theme',
65
+ },
66
+ 'theme.pick_move': {
67
+ ru: 'примерить тему',
68
+ en: 'preview theme',
69
+ },
70
+ 'theme.pick_cancel': {
71
+ ru: 'отмена (вернуть прежнюю)',
72
+ en: 'cancel (restore)',
73
+ },
74
+ 'theme.set': {
75
+ ru: 'Тема: {v}',
76
+ en: 'Theme: {v}',
77
+ },
78
+ 'theme.restored': {
79
+ ru: 'Тема не изменена: {v}',
80
+ en: 'Theme unchanged: {v}',
81
+ },
82
+ 'theme.unknown': {
83
+ ru: 'Нет такой темы: {v}',
84
+ en: 'No such theme: {v}',
85
+ },
86
+ 'theme.list': {
87
+ ru: 'Темы: {v}',
88
+ en: 'Themes: {v}',
89
+ },
90
+ 'theme.current': {
91
+ ru: 'Текущая тема: {v}',
92
+ en: 'Current theme: {v}',
93
+ },
94
+ 'theme.usage': {
95
+ ru: '/theme — список с выбором; /theme <id> — сразу применить; /theme list — показать id',
96
+ en: '/theme — pick from a list; /theme <id> — apply directly; /theme list — show the ids',
97
+ },
58
98
  'cfg.f.ui_answerWidth': {
59
99
  ru: 'Ширина контента в колонках: ответы, ввод, инструменты (0 — авто, до 100)',
60
100
  en: 'Content width in columns: answers, input, tools (0 = auto, capped at 100)',
package/dist/i18n/help.js CHANGED
@@ -469,6 +469,10 @@ export const helpMessages = {
469
469
  ru: '/config lang <ru|en> сменить язык интерфейса и агента',
470
470
  en: '/config lang <ru|en> switch UI and agent language',
471
471
  },
472
+ 'help.cmd.theme': {
473
+ ru: '/theme [id|list] выбор темы терминала (сразу, без перезапуска)',
474
+ en: '/theme [id|list] pick a terminal color theme (live, no restart)',
475
+ },
472
476
  'help.cmd.provider': {
473
477
  ru: '/provider <id> | status | login — сменить бэкенд (сразу), показать состояние или повторить вход',
474
478
  en: '/provider <id> | status | login — switch the backend (live), show the state or re-run the sign-in',
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import path from 'path';
4
4
  import fs from 'fs/promises';
5
5
  import { readFileSync, statSync } from 'fs';
6
6
  import { fileURLToPath } from 'url';
7
- import { theme } from './theme.js';
7
+ import { theme, applyTheme, themeColors, themeNames, currentThemeName, isThemeName, } from './theme.js';
8
8
  import { DeepSeekBrowser, headlessUACacheReady } from './browser.js';
9
9
  import { QwenBrowser } from './qwen-browser.js';
10
10
  import { GlmBrowser } from './glm-browser.js';
@@ -119,6 +119,10 @@ if (providerArg !== null) {
119
119
  }
120
120
  // Apply the configured answer width to the markdown renderer (0 = auto).
121
121
  setAnswerWidth(config.ui?.answerWidth ?? 0);
122
+ // The color palette must be selected BEFORE the first line is rendered (the
123
+ // banner, startup notices): theme.ts starts on the default and every call site
124
+ // reads the live object, so one apply here repaints the whole session.
125
+ applyTheme(config.ui?.theme);
122
126
  // Current interface/agent language. Changed by the /config lang <ru|en> command.
123
127
  let currentLocale = isLocale(config.ui?.locale)
124
128
  ? config.ui.locale
@@ -536,6 +540,7 @@ ${theme.bold(t('help.sec.context'))}
536
540
  ${t('help.cmd.init')}
537
541
  ${t('help.cmd.mcp')}
538
542
  ${t('help.cmd.config')}
543
+ ${t('help.cmd.theme')}
539
544
  ${t('help.cmd.lang')}
540
545
 
541
546
  ${theme.bold(t('help.sec.provider'))}
@@ -1934,6 +1939,18 @@ async function main() {
1934
1939
  editor.setPrompt(buildPrompt());
1935
1940
  }
1936
1941
  }
1942
+ // Repaint the WHOLE terminal in the new palette immediately. The theme
1943
+ // object is shared and mutated in place, so every later render uses it;
1944
+ // the editor is asked for a full redraw because the rows already on screen
1945
+ // (status, prompt, hints) were painted with the OLD colors.
1946
+ if (path === 'ui.theme') {
1947
+ const applied = applyTheme(value);
1948
+ config.ui.theme = applied;
1949
+ if (editor) {
1950
+ editor.setTheme?.(themeColors());
1951
+ editor.refreshStatus();
1952
+ }
1953
+ }
1937
1954
  // Apply a live change to the answer width immediately, so the next
1938
1955
  // rendered answer uses the new width without a restart.
1939
1956
  if (path === 'ui.answerWidth') {
@@ -2155,6 +2172,13 @@ async function main() {
2155
2172
  getDefault: (path) => getByPath(DEFAULTS, path),
2156
2173
  set: (field, raw) => configSetRaw(field, raw),
2157
2174
  reset: (field) => configResetField(field),
2175
+ // The menu itself must repaint when the THEME row changes: its widgets
2176
+ // read the shared UI map, and without a bump the operator would cycle
2177
+ // palette ids while the colors stayed those of the previous one.
2178
+ onAfterChange: (field) => {
2179
+ if (field.path === 'ui.theme')
2180
+ editor?.setTheme?.(themeColors());
2181
+ },
2158
2182
  locale: currentLocale,
2159
2183
  };
2160
2184
  try {
@@ -2175,6 +2199,87 @@ async function main() {
2175
2199
  editor.resume();
2176
2200
  }
2177
2201
  }
2202
+ /**
2203
+ * Apply a palette by id, persist it and repaint the live UI. Shared by
2204
+ * `/theme`, `/config set ui.theme` (through setConfigRuntime) and the picker,
2205
+ * so all three paths behave identically.
2206
+ */
2207
+ function applyThemeById(name) {
2208
+ const applied = applyTheme(name);
2209
+ config.ui.theme = applied;
2210
+ writeConfigValue('home', 'ui.theme', applied);
2211
+ if (editor) {
2212
+ editor.setTheme?.(themeColors());
2213
+ editor.refreshStatus();
2214
+ }
2215
+ return applied;
2216
+ }
2217
+ // `/theme [id|list]` — a direct entry point for the palette, with a pick list
2218
+ // in the TUI. `/config set ui.theme` stays as the scriptable path.
2219
+ async function handleThemeCommand(input) {
2220
+ const arg = input.trim().slice('/theme'.length).trim();
2221
+ const names = themeNames();
2222
+ const emit = (line) => {
2223
+ if (editor)
2224
+ editor.printAbove(line);
2225
+ else
2226
+ console.log(line);
2227
+ };
2228
+ if (arg === 'list' || arg === 'ls') {
2229
+ emit(theme.system(t('theme.list', { v: names.join(', ') }) +
2230
+ String.fromCharCode(10) +
2231
+ t('theme.current', { v: currentThemeName() })));
2232
+ return;
2233
+ }
2234
+ if (arg) {
2235
+ if (!isThemeName(arg)) {
2236
+ emit(theme.error(t('theme.unknown', { v: arg })));
2237
+ emit(theme.dim(t('theme.usage')));
2238
+ return;
2239
+ }
2240
+ emit(theme.assistant(t('theme.set', { v: applyThemeById(arg) })));
2241
+ return;
2242
+ }
2243
+ // No argument: a pick list in the TUI, the id list otherwise. The cursor
2244
+ // applies the palette as a LIVE preview; Esc restores the previous one.
2245
+ if (editor && config.ui.tui) {
2246
+ const before = currentThemeName();
2247
+ editor.pause();
2248
+ let picked;
2249
+ try {
2250
+ const { pickThemeInk } = await import('./tui/run-theme-picker.js');
2251
+ const chosen = await pickThemeInk({
2252
+ themes: names,
2253
+ initial: before,
2254
+ locale: currentLocale,
2255
+ onPreview: (name) => {
2256
+ applyTheme(name);
2257
+ editor?.setTheme?.(themeColors());
2258
+ },
2259
+ });
2260
+ picked = chosen;
2261
+ }
2262
+ catch {
2263
+ picked = null;
2264
+ }
2265
+ finally {
2266
+ editor.resume();
2267
+ }
2268
+ if (picked) {
2269
+ emit(theme.assistant(t('theme.set', { v: applyThemeById(picked) })));
2270
+ }
2271
+ else {
2272
+ // Cancelled: the previews must not stick. Restore WITHOUT persisting.
2273
+ applyTheme(before);
2274
+ editor.setTheme?.(themeColors());
2275
+ emit(theme.system(t('theme.restored', { v: before })));
2276
+ }
2277
+ return;
2278
+ }
2279
+ emit(theme.system(t('theme.list', { v: names.join(', ') }) +
2280
+ String.fromCharCode(10) +
2281
+ t('theme.current', { v: currentThemeName() })));
2282
+ }
2178
2283
  async function handleConfigCommand(input) {
2179
2284
  await runConfigCommand(input, {
2180
2285
  t,
@@ -3466,6 +3571,10 @@ async function main() {
3466
3571
  await handleConfigCommand(trimmed);
3467
3572
  continue;
3468
3573
  }
3574
+ if (lower === '/theme' || lower.startsWith('/theme ')) {
3575
+ await handleThemeCommand(trimmed);
3576
+ continue;
3577
+ }
3469
3578
  if (lower === '/transcript') {
3470
3579
  console.log(theme.system(transcript.file || t('common.off')));
3471
3580
  continue;
package/dist/theme.js CHANGED
@@ -9,44 +9,246 @@ import { contentWidth } from './width.js';
9
9
  if (process.env.NO_COLOR) {
10
10
  chalk.level = 0;
11
11
  }
12
- // A calm palette for a dark terminal.
13
- // Muted, slightly "faded" tones without high saturation
14
- // (in the spirit of Tokyo Night / Nord). The goal is comfortable reading of
15
- // long answers, not maximum contrast.
16
- //
17
- // Roles:
18
- // - user — soft lavender-gray (service messages)
19
- // - prompt — warm gold (input prompt, noticeable)
20
- // - dir — light blue (working directory name)
21
- // - assistant — warm sand (model answers)
22
- // - tool — muted amber
23
- // - toolResult — gray-blue
24
- // - system — neutral gray
25
- // - warn — calm ochre
26
- // - error — muted terracotta
27
- export const theme = {
28
- // Operator messages — a clearly DISTINCT cyan so they never blend with the
29
- // model's warm-sand answers or the neutral system text (operator request).
30
- user: chalk.hex('#8fd4f0'), // operator text — bright cyan-blue
31
- userBar: chalk.hex('#4a8fb5'), // the `▌` bar marking an operator turn
32
- // A dim rule + label separating one conversation TURN from the next.
33
- turnRule: chalk.hex('#3a3f4b'),
34
- turnLabel: chalk.hex('#5b616e'),
35
- prompt: chalk.hex('#c8b06a').bold, // input prompt — warm gold, noticeable
36
- dir: chalk.hex('#7fc4f0'), // working directory name — noticeably blue
37
- assistant: chalk.hex('#cfc9b0'), // warm sand
38
- tool: chalk.hex('#c6a97e'), // muted amber
39
- toolResult: chalk.hex('#8a9bb5'), // gray-blue
40
- system: chalk.hex('#808896'), // neutral gray
41
- dim: chalk.hex('#5b616e'), // dark gray
42
- taskSummary: chalk.hex('#8fa3c8'), // per-task summary — noticeable but calm, distinct from spinner brown and warn ochre
43
- warn: chalk.hex('#c9a86a'), // calm ochre
44
- error: chalk.hex('#c98a80'), // muted terracotta
45
- success: chalk.hex('#a9c08c'), // soft sage
46
- brown: chalk.hex('#a1723f'), // brown (status/spinner)
47
- toggleOn: chalk.hex('#6fd0b0'), // teal-green — a toggle is ON
48
- bold: chalk.bold,
12
+ export const PALETTES = {
13
+ // zames — the original calm palette (Tokyo Night / Nord spirit): muted,
14
+ // slightly faded tones, comfortable for long answers.
15
+ zames: {
16
+ bg: '#23272e',
17
+ user: '#8fd4f0',
18
+ userBar: '#4a8fb5',
19
+ turnRule: '#3a3f4b',
20
+ turnLabel: '#5b616e',
21
+ prompt: '#c8b06a',
22
+ dir: '#7fc4f0',
23
+ assistant: '#cfc9b0',
24
+ tool: '#c6a97e',
25
+ toolResult: '#8a9bb5',
26
+ system: '#808896',
27
+ dim: '#5b616e',
28
+ taskSummary: '#8fa3c8',
29
+ warn: '#c9a86a',
30
+ error: '#c98a80',
31
+ success: '#a9c08c',
32
+ brown: '#a1723f',
33
+ toggleOn: '#6fd0b0',
34
+ },
35
+ // One Dark — Atom's default theme, the most recognized "modern editor" look.
36
+ 'one-dark': {
37
+ bg: '#282c34',
38
+ user: '#61afef',
39
+ userBar: '#3e6f9e',
40
+ turnRule: '#3b4048',
41
+ turnLabel: '#5c6370',
42
+ prompt: '#e5c07b',
43
+ dir: '#61afef',
44
+ assistant: '#abb2bf',
45
+ tool: '#d19a66',
46
+ toolResult: '#7f8b9e',
47
+ system: '#828997',
48
+ dim: '#5c6370',
49
+ taskSummary: '#56b6c2',
50
+ warn: '#e5c07b',
51
+ error: '#e06c75',
52
+ success: '#98c379',
53
+ brown: '#d19a66',
54
+ toggleOn: '#56b6c2',
55
+ },
56
+ // Dracula — high-contrast purple/pink, extremely popular.
57
+ dracula: {
58
+ bg: '#282a36',
59
+ user: '#8be9fd',
60
+ userBar: '#4f8fa6',
61
+ turnRule: '#3d4152',
62
+ turnLabel: '#6272a4',
63
+ prompt: '#f1fa8c',
64
+ dir: '#8be9fd',
65
+ assistant: '#f8f8f2',
66
+ tool: '#ffb86c',
67
+ toolResult: '#b0b8d8',
68
+ system: '#9aa2c0',
69
+ dim: '#6272a4',
70
+ taskSummary: '#8be9fd',
71
+ warn: '#ffb86c',
72
+ error: '#ff5555',
73
+ success: '#50fa7b',
74
+ brown: '#bd93f9',
75
+ toggleOn: '#50fa7b',
76
+ },
77
+ // Gruvbox Dark — warm retro earth tones.
78
+ gruvbox: {
79
+ bg: '#282828',
80
+ user: '#83a598',
81
+ userBar: '#5a7a6e',
82
+ turnRule: '#3c3836',
83
+ turnLabel: '#665c54',
84
+ prompt: '#fabd2f',
85
+ dir: '#83a598',
86
+ assistant: '#ebdbb2',
87
+ tool: '#fe8019',
88
+ toolResult: '#a89984',
89
+ system: '#928374',
90
+ dim: '#665c54',
91
+ taskSummary: '#8ec07c',
92
+ warn: '#fabd2f',
93
+ error: '#fb4934',
94
+ success: '#b8bb26',
95
+ brown: '#d65d0e',
96
+ toggleOn: '#b8bb26',
97
+ },
98
+ // Nord — cool arctic blues, low contrast by design.
99
+ nord: {
100
+ bg: '#2e3440',
101
+ user: '#88c0d0',
102
+ userBar: '#5e81a8',
103
+ turnRule: '#3b4252',
104
+ turnLabel: '#616e88',
105
+ prompt: '#ebcb8b',
106
+ dir: '#88c0d0',
107
+ assistant: '#d8dee9',
108
+ tool: '#d08770',
109
+ toolResult: '#9aa7bd',
110
+ system: '#8b95a8',
111
+ dim: '#616e88',
112
+ taskSummary: '#81a1c1',
113
+ warn: '#ebcb8b',
114
+ error: '#bf616a',
115
+ success: '#a3be8c',
116
+ brown: '#d08770',
117
+ toggleOn: '#8fbcbb',
118
+ },
119
+ // Monokai — the classic Sublime Text scheme.
120
+ monokai: {
121
+ bg: '#272822',
122
+ user: '#66d9ef',
123
+ userBar: '#3f8c9e',
124
+ turnRule: '#3e3d32',
125
+ turnLabel: '#75715e',
126
+ prompt: '#e6db74',
127
+ dir: '#66d9ef',
128
+ assistant: '#f8f8f2',
129
+ tool: '#fd971f',
130
+ toolResult: '#a6a695',
131
+ system: '#a1a19a',
132
+ dim: '#75715e',
133
+ taskSummary: '#66d9ef',
134
+ warn: '#e6db74',
135
+ error: '#f92672',
136
+ success: '#a6e22e',
137
+ brown: '#fd971f',
138
+ toggleOn: '#a6e22e',
139
+ },
140
+ // Solarized Dark — Ethan Schoonover's precision palette.
141
+ 'solarized-dark': {
142
+ bg: '#002b36',
143
+ user: '#268bd2',
144
+ userBar: '#1f6a9e',
145
+ turnRule: '#073642',
146
+ turnLabel: '#586e75',
147
+ prompt: '#b58900',
148
+ dir: '#2aa198',
149
+ assistant: '#93a1a1',
150
+ tool: '#cb4b16',
151
+ toolResult: '#839496',
152
+ system: '#839496',
153
+ dim: '#586e75',
154
+ taskSummary: '#2aa198',
155
+ warn: '#b58900',
156
+ error: '#dc322f',
157
+ success: '#859900',
158
+ brown: '#cb4b16',
159
+ toggleOn: '#2aa198',
160
+ },
161
+ // Tokyo Night — the modern neon-on-midnight scheme.
162
+ 'tokyo-night': {
163
+ bg: '#1a1b26',
164
+ user: '#7dcfff',
165
+ userBar: '#3d7fa6',
166
+ turnRule: '#2a2e3f',
167
+ turnLabel: '#565f89',
168
+ prompt: '#e0af68',
169
+ dir: '#7dcfff',
170
+ assistant: '#c0caf5',
171
+ tool: '#ff9e64',
172
+ toolResult: '#9aa5ce',
173
+ system: '#787c99',
174
+ dim: '#565f89',
175
+ taskSummary: '#7aa2f7',
176
+ warn: '#e0af68',
177
+ error: '#f7768e',
178
+ success: '#9ece6a',
179
+ brown: '#ff9e64',
180
+ toggleOn: '#73daca',
181
+ },
49
182
  };
183
+ /** The palette id used when the configured theme is unknown/missing. */
184
+ export const DEFAULT_THEME = 'zames';
185
+ /**
186
+ * True for a palette id we actually ship. Used by the config layer to
187
+ * normalize `ui.theme` (a stale value from an older/newer build must not
188
+ * leave the operator with an unstyled terminal).
189
+ */
190
+ export function isThemeName(v) {
191
+ return (typeof v === 'string' && Object.prototype.hasOwnProperty.call(PALETTES, v));
192
+ }
193
+ /** All palette ids, in the order they are offered in /config. */
194
+ export function themeNames() {
195
+ return Object.keys(PALETTES);
196
+ }
197
+ /** Build the chalk formatters for one palette. */
198
+ function buildTheme(p) {
199
+ return {
200
+ user: chalk.hex(p.user),
201
+ userBar: chalk.hex(p.userBar),
202
+ turnRule: chalk.hex(p.turnRule),
203
+ turnLabel: chalk.hex(p.turnLabel),
204
+ prompt: chalk.hex(p.prompt).bold,
205
+ dir: chalk.hex(p.dir),
206
+ assistant: chalk.hex(p.assistant),
207
+ tool: chalk.hex(p.tool),
208
+ toolResult: chalk.hex(p.toolResult),
209
+ system: chalk.hex(p.system),
210
+ dim: chalk.hex(p.dim),
211
+ taskSummary: chalk.hex(p.taskSummary),
212
+ warn: chalk.hex(p.warn),
213
+ error: chalk.hex(p.error),
214
+ success: chalk.hex(p.success),
215
+ brown: chalk.hex(p.brown),
216
+ toggleOn: chalk.hex(p.toggleOn),
217
+ bold: chalk.bold,
218
+ };
219
+ }
220
+ // The LIVE theme object. It is mutated IN PLACE by applyTheme() so that every
221
+ // module that captured the import (`import { theme } from './theme.js'`) sees
222
+ // the switch without being re-imported — this is why the call sites read
223
+ // `theme.x` at RENDER time instead of destructuring a color at module load.
224
+ let activeName = DEFAULT_THEME;
225
+ export const theme = buildTheme(PALETTES[DEFAULT_THEME]);
226
+ /** The palette id currently rendered. */
227
+ export function currentThemeName() {
228
+ return activeName;
229
+ }
230
+ /**
231
+ * Switch the palette for every subsequent render. Unknown ids fall back to the
232
+ * default so a hand-edited config can never blank out the colors.
233
+ * Returns the id actually applied.
234
+ */
235
+ export function applyTheme(name) {
236
+ const next = isThemeName(name) ? name : DEFAULT_THEME;
237
+ const built = buildTheme(PALETTES[next]);
238
+ for (const key of Object.keys(built)) {
239
+ theme[key] = built[key];
240
+ }
241
+ activeName = next;
242
+ return next;
243
+ }
244
+ /**
245
+ * The active palette's RAW hex map — for the Ink TUI, which needs color
246
+ * STRINGS (Ink `<Text color>` does not take chalk functions) and for the
247
+ * README screenshot generator, which renders the real colors as HTML.
248
+ */
249
+ export function themeColors() {
250
+ return PALETTES[activeName];
251
+ }
50
252
  // Horizontal rule that separates the model's answer from the next block. It
51
253
  // spans the SHARED content width (src/width.ts) so the rule, the answers and
52
254
  // the tool previews all line up at the same margin. The `max` argument is kept
@@ -91,10 +91,10 @@ export function AskQuestion({ question, options, recommended, allowText, locale
91
91
  out.push(line);
92
92
  return out.length ? out : [''];
93
93
  };
94
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.accent, paddingX: 2, children: [_jsx(Text, { color: UI.title, bold: true, children: '◆ ' + t('askuser.title') }), wrap(question).map((line, i) => (_jsx(Text, { color: UI.text, children: line }, i))), _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(innerCols) }), typing ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.dim, children: t('askuser.your_answer') }), _jsx(Text, { color: UI.dim, children: t('askuser.enter_send') }), _jsxs(Text, { color: UI.accent, children: ['❯ ', text, '▏'] })] })) : (rows.map((label, i) => {
94
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.accent, paddingX: 2, children: [_jsx(Text, { color: UI.title, bold: true, children: '◆ ' + t('askuser.title') }), wrap(question).map((line, i) => (_jsx(Text, { color: UI.text, children: line }, i))), _jsx(Text, { color: UI.border, children: '─'.repeat(innerCols) }), typing ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.dim, children: t('askuser.your_answer') }), _jsx(Text, { color: UI.dim, children: t('askuser.enter_send') }), _jsxs(Text, { color: UI.accent, children: ['❯ ', text, '▏'] })] })) : (rows.map((label, i) => {
95
95
  const sel = i === cursor;
96
96
  const isRec = i === recommended;
97
- return (_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, children: sel ? '❯ ' : ' ' }), _jsx(Text, { color: sel ? UI.accent : undefined, bold: sel, children: label }), isRec ? (_jsx(Text, { color: "#6fd0b0", children: ' (' + t('askuser.recommended') + ')' })) : null] }, i));
97
+ return (_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, children: sel ? '❯ ' : ' ' }), _jsx(Text, { color: sel ? UI.accent : undefined, bold: sel, children: label }), isRec ? (_jsx(Text, { color: UI.on, children: ' (' + t('askuser.recommended') + ')' })) : null] }, i));
98
98
  })), _jsx(HintBar, { hints: typing
99
99
  ? [
100
100
  ['Enter', t('askuser.hint_send')],
@@ -39,6 +39,7 @@ function displayValue(field, value, t) {
39
39
  }
40
40
  export function ConfigMenuApp(opts) {
41
41
  const { fields, t, get, set, reset, getDefault, locale = 'en' } = opts;
42
+ const { onAfterChange } = opts;
42
43
  const { exit } = useApp();
43
44
  // The hint bar is operator text: it follows the SAME locale the labels use
44
45
  // (a hardcoded Russian bar showed up in an English UI).
@@ -89,6 +90,7 @@ export function ConfigMenuApp(opts) {
89
90
  try {
90
91
  set(f, raw);
91
92
  bump();
93
+ onAfterChange?.(f);
92
94
  setMessage(t('cfg.menu.saved', {
93
95
  v: f.path,
94
96
  value: displayValue(f, get(f.path), t),
@@ -105,6 +107,7 @@ export function ConfigMenuApp(opts) {
105
107
  if (f.type === 'boolean') {
106
108
  set(f, get(f.path) === true ? 'false' : 'true');
107
109
  bump();
110
+ onAfterChange?.(f);
108
111
  setMessage(null);
109
112
  return;
110
113
  }
@@ -125,6 +128,7 @@ export function ConfigMenuApp(opts) {
125
128
  const next = f.values[(base + dir + f.values.length) % f.values.length];
126
129
  set(f, next);
127
130
  bump();
131
+ onAfterChange?.(f);
128
132
  setMessage(null);
129
133
  };
130
134
  /** ←/→ acts on the focused row: cycle an enum, toggle a boolean. */
@@ -137,6 +141,7 @@ export function ConfigMenuApp(opts) {
137
141
  else if (f.type === 'boolean') {
138
142
  set(f, get(f.path) === true ? 'false' : 'true');
139
143
  bump();
144
+ onAfterChange?.(f);
140
145
  setMessage(null);
141
146
  }
142
147
  };
@@ -244,6 +249,7 @@ export function ConfigMenuApp(opts) {
244
249
  if (pendingReset === f.path) {
245
250
  reset(f);
246
251
  bump();
252
+ onAfterChange?.(f);
247
253
  setMessage(t('cfg.reset', { v: f.path }));
248
254
  setPendingReset(null);
249
255
  }
@@ -330,7 +336,7 @@ export function ConfigMenuApp(opts) {
330
336
  if (!visible.length) {
331
337
  items.push(_jsx(Text, { color: UI.dim, children: ' ' + t('cfg.menu.no_match') }, "none"));
332
338
  }
333
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.border, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: UI.title, children: t('cfg.menu.title') }), _jsx(Text, { color: UI.dim, children: (visible.length ? cursor + 1 : 0) + '/' + visible.length })] }), _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(Math.max(10, (columns || 80) - 6)) }), items, _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(Math.max(10, (columns || 80) - 6)) }), editing ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.text, children: t('cfg.menu.edit_hint') }), _jsx(Text, { color: UI.accent, children: '❯ ' + editBuf })] })) : filterMode ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.text, children: t('cfg.menu.filter_hint') }), _jsx(Text, { color: UI.accent, children: '❯ ' + filterQuery })] })) : message ? (_jsx(Text, { color: UI.ok, children: message })) : filterQuery ? (_jsx(Text, { color: UI.dim, children: t('cfg.menu.filter_active', { q: filterQuery }) })) : null, _jsx(HintBar, { hints: [
339
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.border, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: UI.title, children: t('cfg.menu.title') }), _jsx(Text, { color: UI.dim, children: (visible.length ? cursor + 1 : 0) + '/' + visible.length })] }), _jsx(Text, { color: UI.border, children: '─'.repeat(Math.max(10, (columns || 80) - 6)) }), items, _jsx(Text, { color: UI.border, children: '─'.repeat(Math.max(10, (columns || 80) - 6)) }), editing ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.text, children: t('cfg.menu.edit_hint') }), _jsx(Text, { color: UI.accent, children: '❯ ' + editBuf })] })) : filterMode ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.text, children: t('cfg.menu.filter_hint') }), _jsx(Text, { color: UI.accent, children: '❯ ' + filterQuery })] })) : message ? (_jsx(Text, { color: UI.ok, children: message })) : filterQuery ? (_jsx(Text, { color: UI.dim, children: t('cfg.menu.filter_active', { q: filterQuery }) })) : null, _jsx(HintBar, { hints: [
334
340
  ['↑/↓', hint('tui.hint.select')],
335
341
  ['←/→', hint('tui.hint.change')],
336
342
  ['Enter', hint('tui.hint.edit')],
@@ -20,6 +20,7 @@ import { suggestRowCount } from '../input/suggest.js';
20
20
  import { truncateToWidth, visLen } from '../input/layout.js';
21
21
  import { fullScreenLayout } from './layout.js';
22
22
  import { useMouseWheel } from './useMouseWheel.js';
23
+ import { UI } from './ui-colors.js';
23
24
  /** The header bar: title, version, working directory. */
24
25
  function Header({ store }) {
25
26
  const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
@@ -28,6 +29,9 @@ function Header({ store }) {
28
29
  .filter(Boolean)
29
30
  .join(' ');
30
31
  const left = '❯ ' + state.title + (state.version ? ' v' + state.version : '');
32
+ // Referencing the nonce keeps the React dependency honest: the header colors
33
+ // come from the shared UI map, which is mutated in place on a theme switch.
34
+ void state.themeNonce;
31
35
  const rawDir = state.workdir ? ' ' + state.workdir : '';
32
36
  // The header MUST stay exactly ONE text row: FullScreenApp reserves
33
37
  // HEADER_ROWS = 2 (title + divider) and the whole panel height is computed
@@ -43,7 +47,7 @@ function Header({ store }) {
43
47
  dir = budget > 0 ? truncateToWidth(dir, budget) : '';
44
48
  // If even title + provider exceed the width, drop the directory entirely.
45
49
  const finalDir = gap >= 1 ? dir : '';
46
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "#c8b06a", bold: true, children: left }), _jsx(Text, { color: "#7fc4f0", children: finalDir }), _jsx(Text, { children: ' '.repeat(Math.max(1, cols - visLen(left) - visLen(finalDir) - visLen(right) - 2)) }), right ? _jsx(Text, { color: "#6fd0b0", children: right }) : null] }), _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(cols) })] }));
50
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: UI.accent, bold: true, children: left }), _jsx(Text, { color: UI.accent, children: finalDir }), _jsx(Text, { children: ' '.repeat(Math.max(1, cols - visLen(left) - visLen(finalDir) - visLen(right) - 2)) }), right ? _jsx(Text, { color: UI.on, children: right }) : null] }), _jsx(Text, { color: UI.border, children: '─'.repeat(cols) })] }));
47
51
  }
48
52
  export function FullScreenApp({ store, inputProps, }) {
49
53
  const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
@@ -107,12 +111,12 @@ export function FullScreenApp({ store, inputProps, }) {
107
111
  const next = cur + delta * 3;
108
112
  store.set({ scroll: Math.max(0, Math.min(maxScroll, next)) });
109
113
  }, state.mouseScroll);
110
- return (_jsxs(Box, { flexDirection: "column", height: termRows, children: [_jsx(Header, { store: store }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 1, overflow: "hidden", justifyContent: "flex-end", children: all.length === 0 ? (_jsx(Text, { color: "#5b616e", children: translate(state.locale)('editor.empty_hint') })) : (_jsx(LogList, { entries: visible })) }), _jsx(StatusBar, { state: state, divider: false }), _jsx(InputLine, { ...inputProps, store: store,
114
+ return (_jsxs(Box, { flexDirection: "column", height: termRows, children: [_jsx(Header, { store: store }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 1, overflow: "hidden", justifyContent: "flex-end", children: all.length === 0 ? (_jsx(Text, { color: UI.dim, children: translate(state.locale)('editor.empty_hint') })) : (_jsx(LogList, { entries: visible })) }), _jsx(StatusBar, { state: state, divider: false }), _jsx(InputLine, { ...inputProps, store: store,
111
115
  // Cursor rows above the input box: header + log viewport + status.
112
116
  // The log pane is FLEXIBLE now, so the panel below it starts right
113
117
  // after everything above: header + as many log rows as fit + the
114
118
  // suggestion popup + status. `cursorTop` counts the rows ABOVE the
115
119
  // input rows; the box border row is added by InputLine.
116
- cursorTop: fsLayout.cursorTop, prompt: state.prompt, locale: state.locale, commands: state.commands, locked: state.locked }), _jsx(Text, { color: "#5b616e", wrap: "truncate", children: ' ' +
120
+ cursorTop: fsLayout.cursorTop, prompt: state.prompt, locale: state.locale, commands: state.commands, locked: state.locked }), _jsx(Text, { color: UI.dim, wrap: "truncate", children: ' ' +
117
121
  translate(state.locale)(state.mouseScroll ? 'editor.keys_hint' : 'editor.keys_hint_select') })] }));
118
122
  }
@@ -16,10 +16,11 @@ import { insertText, insertPaste, insertNewline, backspace, del, moveLeft, moveR
16
16
  import { dispatchKey, parseCsiU } from './keys.js';
17
17
  import { useSyncExternalStore } from 'react';
18
18
  import { matchSlashCommands, suggestWindow, completeSlashCommand, } from '../input/suggest.js';
19
+ import { UI } from './ui-colors.js';
19
20
  /** Render the visual rows of the buffer with the prompt on the first row. */
20
21
  function InputRows({ prompt, buf, cols, }) {
21
22
  const lay = layoutInput(prompt, buf, 0, cols);
22
- return (_jsx(Box, { flexDirection: "column", children: lay.rows.map((r, i) => (_jsxs(Text, { children: [_jsx(Text, { color: "#c8b06a", bold: true, children: r.prefix }), r.text] }, i))) }));
23
+ return (_jsx(Box, { flexDirection: "column", children: lay.rows.map((r, i) => (_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, bold: true, children: r.prefix }), r.text] }, i))) }));
23
24
  }
24
25
  export function InputLine(props) {
25
26
  const { columns } = useWindowSize();
@@ -316,9 +317,9 @@ export function InputLine(props) {
316
317
  return (_jsxs(Box, { flexDirection: "column", children: [suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [shown.map((c, i) => {
317
318
  const label = c.hint ? c.name + ' ' + c.hint : c.name;
318
319
  const selected = suggestOffset + i === suggestSelected;
319
- return (_jsxs(Text, { children: [_jsx(Text, { color: "#c8b06a", children: selected ? ' ▸ ' : ' ' }), _jsx(Text, { color: "#c8b06a", children: label }), _jsx(Text, { color: "#5b616e", children: ' ' + c.description })] }, c.name));
320
- }), moreCount > 0 && (_jsx(Text, { color: "#5b616e", children: ' ' +
320
+ return (_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, children: selected ? ' ▸ ' : ' ' }), _jsx(Text, { color: UI.accent, children: label }), _jsx(Text, { color: UI.dim, children: ' ' + c.description })] }, c.name));
321
+ }), moreCount > 0 && (_jsx(Text, { color: UI.dim, children: ' ' +
321
322
  t('editor.more', { n: String(moreCount) }) +
322
323
  ' ' +
323
- t('editor.page_hint') }))] })), _jsx(Box, { borderStyle: "round", borderColor: props.locked ? '#5b616e' : '#c8b06a', paddingLeft: 1, paddingRight: 1, flexDirection: "column", children: _jsx(InputRows, { prompt: props.prompt, buf: st.buf, cols: innerCols }) })] }));
324
+ t('editor.page_hint') }))] })), _jsx(Box, { borderStyle: "round", borderColor: props.locked ? UI.dim : UI.accent, paddingLeft: 1, paddingRight: 1, flexDirection: "column", children: _jsx(InputRows, { prompt: props.prompt, buf: st.buf, cols: innerCols }) })] }));
324
325
  }
@@ -44,7 +44,7 @@ export function SelectList({ title, items, initial, locale = 'en', onSelect, })
44
44
  finish(items[cursor]?.value ?? null);
45
45
  }
46
46
  });
47
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.border, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: UI.title, bold: true, children: '◆ ' + title }), _jsx(Text, { color: UI.dim, children: items.length })] }), _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(innerCols) }), items.slice(win.top, win.end).map((it, i) => {
47
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.border, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: UI.title, bold: true, children: '◆ ' + title }), _jsx(Text, { color: UI.dim, children: items.length })] }), _jsx(Text, { color: UI.border, children: '─'.repeat(innerCols) }), items.slice(win.top, win.end).map((it, i) => {
48
48
  const idx = win.top + i;
49
49
  const sel = idx === cursor;
50
50
  return (_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, children: sel ? '❯ ' : ' ' }), _jsx(Text, { color: sel ? UI.accent : undefined, bold: sel, children: it.label }), it.detail ? _jsx(Text, { color: UI.dim, children: ' ' + it.detail }) : null] }, it.value));
@@ -2,6 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { Box, Text, useAnimation, useWindowSize } from 'ink';
3
3
  import { translate } from '../i18n.js';
4
4
  import { DOTS, stripEllipsis } from '../spinner.js';
5
+ import { UI } from './ui-colors.js';
5
6
  import { truncateToWidth, visLen } from '../input/layout.js';
6
7
  /** Elapsed seconds of the current animated phase ("0s", "45s", "2m 05s"). */
7
8
  function elapsedLabel(startedAt, animating) {
@@ -68,16 +69,16 @@ function Badges({ state, budget = Infinity, }) {
68
69
  all.push({ key, w, node });
69
70
  };
70
71
  if (state.statusLine) {
71
- add('sl', visLen(state.statusLine) + 1, _jsxs(Text, { color: "#808896", children: [state.statusLine, ' '] }, "sl"));
72
+ add('sl', visLen(state.statusLine) + 1, _jsxs(Text, { color: UI.system, children: [state.statusLine, ' '] }, "sl"));
72
73
  }
73
74
  if (state.provider) {
74
- add('prov', visLen('[' + state.provider + '] '), _jsx(Text, { color: "#808896", children: '[' + state.provider + '] ' }, "prov"));
75
+ add('prov', visLen('[' + state.provider + '] '), _jsx(Text, { color: UI.system, children: '[' + state.provider + '] ' }, "prov"));
75
76
  }
76
77
  if (state.queueLength > 0) {
77
- add('q', visLen('⧗' + state.queueLength + ' '), _jsxs(Text, { color: "#c9a86a", children: ['⧗' + state.queueLength, ' '] }, "q"));
78
+ add('q', visLen('⧗' + state.queueLength + ' '), _jsxs(Text, { color: UI.warn, children: ['⧗' + state.queueLength, ' '] }, "q"));
78
79
  }
79
80
  if (state.tasks) {
80
- add('t', visLen(state.tasks) + 1, _jsxs(Text, { color: "#808896", children: [state.tasks, ' '] }, "t"));
81
+ add('t', visLen(state.tasks) + 1, _jsxs(Text, { color: UI.system, children: [state.tasks, ' '] }, "t"));
81
82
  }
82
83
  const toggles = [];
83
84
  if (state.thinkingEnabled)
@@ -86,14 +87,14 @@ function Badges({ state, budget = Infinity, }) {
86
87
  toggles.push('🌐\uFE0E');
87
88
  if (toggles.length) {
88
89
  const label = toggles.join(' ') + ' ';
89
- add('tg', visLen(label), _jsx(Text, { color: "#6fd0b0", children: label }, "tg"));
90
+ add('tg', visLen(label), _jsx(Text, { color: UI.on, children: label }, "tg"));
90
91
  }
91
92
  if (state.contextText) {
92
93
  const color = state.contextLevel === 'high'
93
- ? '#c98a80'
94
+ ? UI.err
94
95
  : state.contextLevel === 'warn'
95
- ? '#c9a86a'
96
- : '#a9c08c';
96
+ ? UI.warn
97
+ : UI.ok;
97
98
  const marker = state.contextLevel === 'high'
98
99
  ? '⛔ '
99
100
  : state.contextLevel === 'warn'
@@ -161,5 +162,5 @@ export function StatusBar({ state, divider = true, }) {
161
162
  const cols = useWindowSize().columns || 80;
162
163
  const leftBudget = Math.max(0, Math.min(Math.floor(cols / 3), cols - 14));
163
164
  const leftOut = showLeft ? truncateToWidth(leftText, leftBudget) : '';
164
- return (_jsxs(Box, { flexDirection: "column", children: [divider ? _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(cols) }) : null, _jsxs(Box, { flexDirection: "row", width: Math.max(1, cols - 2), height: 1, children: [_jsx(Text, { color: "#a1723f", children: leftOut || ' ' }), _jsx(Box, { flexGrow: 1 }), _jsx(Badges, { state: state, budget: Math.max(0, cols - 2 - visLen(leftOut)) })] })] }));
165
+ return (_jsxs(Box, { flexDirection: "column", children: [divider ? _jsx(Text, { color: UI.border, children: '─'.repeat(cols) }) : null, _jsxs(Box, { flexDirection: "row", width: Math.max(1, cols - 2), height: 1, children: [_jsx(Text, { color: UI.accentDim, children: leftOut || ' ' }), _jsx(Box, { flexGrow: 1 }), _jsx(Badges, { state: state, budget: Math.max(0, cols - 2 - visLen(leftOut)) })] })] }));
165
166
  }
@@ -0,0 +1,71 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The theme picker — an Ink list of the bundled palettes with a LIVE preview
3
+ // line, opened by /theme (and by the /config menu's theme row).
4
+ //
5
+ // Moving the cursor APPLIES the palette immediately (the preview line and the
6
+ // widget colors repaint), so the operator judges the real look instead of
7
+ // guessing from an id; Esc puts the previous palette back. That is why the
8
+ // preview/restore callbacks are injected — this component stays a pure list.
9
+ import { useState } from 'react';
10
+ import { Box, Text, useInput, useApp, useWindowSize } from 'ink';
11
+ import { UI } from './ui-colors.js';
12
+ import { HintBar } from './widgets.js';
13
+ import { theme } from '../theme.js';
14
+ import { translate } from '../i18n.js';
15
+ export function ThemePicker({ themes, initial, onPreview, locale = 'en', onDone, }) {
16
+ const { exit } = useApp();
17
+ const { columns } = useWindowSize();
18
+ const t = translate(locale);
19
+ const start = Math.max(0, themes.indexOf(initial));
20
+ const [cursor, setCursor] = useState(start);
21
+ const innerCols = Math.max(1, (columns || 80) - 6);
22
+ const finish = (value) => {
23
+ onDone(value);
24
+ exit();
25
+ };
26
+ const move = (dir) => {
27
+ if (!themes.length)
28
+ return;
29
+ const next = (cursor + dir + themes.length) % themes.length;
30
+ setCursor(next);
31
+ onPreview(themes[next]);
32
+ };
33
+ // A sample line of every important role, colored through `theme` (chalk):
34
+ // Ink's <Text color> takes a hex STRING, not a formatter, so a gradient like
35
+ // this has to be pre-rendered ANSI.
36
+ const preview = () => [
37
+ theme.user('user'),
38
+ theme.assistant('answer'),
39
+ theme.tool('tool'),
40
+ theme.toolResult('result'),
41
+ theme.system('system'),
42
+ theme.warn('warn'),
43
+ theme.error('error'),
44
+ theme.success('ok'),
45
+ theme.toggleOn('on'),
46
+ ].join(' ');
47
+ useInput((input, key) => {
48
+ if (key.escape || (key.ctrl && input === 'c') || input === 'q') {
49
+ finish(null);
50
+ return;
51
+ }
52
+ if (key.upArrow || input === 'k') {
53
+ move(-1);
54
+ return;
55
+ }
56
+ if (key.downArrow || input === 'j') {
57
+ move(1);
58
+ return;
59
+ }
60
+ if (key.return)
61
+ finish(themes[cursor] ?? null);
62
+ });
63
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.border, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: UI.title, bold: true, children: '◆ ' + t('theme.pick_title') }), _jsx(Text, { color: UI.dim, children: themes.length })] }), _jsx(Text, { color: UI.border, children: '─'.repeat(innerCols) }), themes.map((name, idx) => {
64
+ const sel = idx === cursor;
65
+ return (_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, children: sel ? '❯ ' : ' ' }), _jsx(Text, { color: sel ? UI.accent : undefined, bold: sel, children: name }), idx === start ? (_jsx(Text, { color: UI.dim, children: ' ' + t('tui.list.current') })) : null] }, name));
66
+ }), _jsx(Text, { color: UI.border, children: '─'.repeat(innerCols) }), _jsx(Text, { children: preview() }), _jsx(HintBar, { hints: [
67
+ ['↑/↓', t('theme.pick_move')],
68
+ ['Enter', t('tui.hint.ok')],
69
+ ['Esc', t('theme.pick_cancel')],
70
+ ] })] }));
71
+ }
@@ -344,6 +344,15 @@ export class TuiEditor {
344
344
  this.locale = locale;
345
345
  this.store.set({ locale });
346
346
  }
347
+ /**
348
+ * Repaint the Ink widgets after a palette switch. The hex map (UI) is
349
+ * mutated in place by theme.applyTheme(), but Ink only re-renders when the
350
+ * store emits — bump a nonce so every subscribed component redraws with the
351
+ * new colors.
352
+ */
353
+ setTheme(_colors) {
354
+ this.store.set({ themeNonce: Date.now() });
355
+ }
347
356
  setCommands(commands) {
348
357
  this.commands = commands;
349
358
  this.store.set({ commands });
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink';
3
+ import { ThemePicker } from './ThemePicker.js';
4
+ export async function pickThemeInk(opts) {
5
+ if (!(process.stdin.isTTY && process.stdout.isTTY))
6
+ return null;
7
+ return await new Promise((resolve) => {
8
+ let done = false;
9
+ const app = render(_jsx(ThemePicker, { ...opts, onDone: (v) => {
10
+ done = true;
11
+ resolve(v);
12
+ } }), { exitOnCtrlC: false });
13
+ app.waitUntilExit().then(() => {
14
+ if (!done)
15
+ resolve(null);
16
+ });
17
+ });
18
+ }
package/dist/tui/store.js CHANGED
@@ -45,6 +45,7 @@ function initialState() {
45
45
  searchEnabled: false,
46
46
  statusLine: '',
47
47
  columns: process.stdout.columns || 80,
48
+ themeNonce: 0,
48
49
  };
49
50
  }
50
51
  /**
@@ -0,0 +1,37 @@
1
+ // The Ink TUI's color map, derived from the ACTIVE terminal palette
2
+ // (src/theme.ts). Ink `<Text color>` takes a plain hex STRING, not a chalk
3
+ // function, so the TUI cannot reuse `theme` directly — this module is the one
4
+ // bridge between the two.
5
+ //
6
+ // `UI` is mutated IN PLACE by syncUiColors() (like `theme`): every component
7
+ // reads `UI.x` at RENDER time, so a /config theme switch repaints without
8
+ // re-importing anything. Components must never destructure a color at module
9
+ // load — that would freeze the palette at startup.
10
+ import { PALETTES, DEFAULT_THEME, isThemeName } from '../theme.js';
11
+ /** Derive the Ink map from a terminal palette. */
12
+ export function uiColorsFrom(p) {
13
+ return {
14
+ accent: p.prompt,
15
+ accentDim: p.turnLabel,
16
+ text: p.assistant,
17
+ dim: p.dim,
18
+ border: p.turnRule,
19
+ on: p.toggleOn,
20
+ off: p.dim,
21
+ ok: p.success,
22
+ warn: p.warn,
23
+ err: p.error,
24
+ system: p.system,
25
+ title: p.taskSummary,
26
+ };
27
+ }
28
+ export const UI = uiColorsFrom(PALETTES[DEFAULT_THEME]);
29
+ /** Repaint the Ink widgets with the palette of `name` (unknown -> default). */
30
+ export function syncUiColors(name) {
31
+ const id = isThemeName(name) ? name : DEFAULT_THEME;
32
+ const next = uiColorsFrom(PALETTES[id]);
33
+ for (const key of Object.keys(next)) {
34
+ UI[key] = next[key];
35
+ }
36
+ return id;
37
+ }
@@ -1,19 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Text } from 'ink';
3
- export const UI = {
4
- accent: '#c8b06a',
5
- accentDim: '#7a6b45',
6
- text: '#cfc9b0',
7
- dim: '#5b616e',
8
- border: '#4a5058',
9
- on: '#6fd0b0',
10
- off: '#5b616e',
11
- ok: '#a9c08c',
12
- warn: '#c9a86a',
13
- err: '#c98a80',
14
- system: '#808896',
15
- title: '#9fb8d8',
16
- };
3
+ // The color map lives in ui-colors.ts because it is DERIVED from the active
4
+ // terminal palette and mutated in place on a /config theme switch. Re-exported
5
+ // here so every existing `import { UI } from './widgets.js'` keeps working.
6
+ export { UI } from './ui-colors.js';
7
+ import { UI } from './ui-colors.js';
17
8
  /** `[x]` / `[ ]` — a checkbox for a boolean value. */
18
9
  export function Checkbox({ checked, focused, }) {
19
10
  return (_jsx(Text, { color: checked ? UI.on : UI.off, bold: focused, children: checked ? '[x]' : '[ ]' }));
package/llms-full.txt CHANGED
@@ -605,6 +605,19 @@ right away (where possible without a restart).
605
605
  (help, messages, spinner) and the language the agent answers you in. The
606
606
  locale lives in `ui.locale` in the config file.
607
607
 
608
+ ### Themes
609
+
610
+ `/theme` opens a pick list with a LIVE preview: the arrow keys repaint the
611
+ terminal as you move, Enter saves, Esc restores the previous palette.
612
+ `/theme <id>` applies a palette directly, `/theme list` prints every id.
613
+ The same setting is reachable from the settings side:
614
+ `/config set ui.theme <id>` or the `/config` menu («Terminal color theme»).
615
+ The switch is instant, no restart. `zames` is the default; the bundled palettes
616
+ are ports of well-known editor schemes:
617
+
618
+ `zames` (default), `one-dark`, `dracula`, `gruvbox`, `nord`, `monokai`,
619
+ `solarized-dark`, `tokyo-night`.
620
+
608
621
  Agent data is stored in `~/.zames`: browser profile, logs, undo history, sessions.
609
622
 
610
623
  ## FAQ
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.82.142",
3
+ "version": "2.83.1",
4
4
  "description": "Terminal coding agent that drives a real browser to chat with DeepSeek, Qwen or GLM — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
5
5
  "type": "module",
6
6
  "bin": {