zames_pro 2.83.2 → 2.84.0

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.84.0] - 2026-10-27
11
+
12
+ ### Added
13
+
14
+ - **ui:** theme previews, 6 more palettes, inline /config preview
15
+
16
+ ## [2.83.4] - 2026-10-27
17
+
18
+ ### Fixed
19
+
20
+ - **ui:** theme the input text, the status row and the input frame
21
+
10
22
  ## [2.83.2] - 2026-10-27
11
23
 
12
24
  ### Fixed
package/README.md CHANGED
@@ -631,7 +631,16 @@ The switch is instant, no restart. `zames` is the default; the bundled palettes
631
631
  are ports of well-known editor schemes:
632
632
 
633
633
  `zames` (default), `one-dark`, `dracula`, `gruvbox`, `nord`, `monokai`,
634
- `solarized-dark`, `tokyo-night`.
634
+ `solarized-dark`, `tokyo-night`, `catppuccin`, `github-dark`, `ayu`,
635
+ `rose-pine`, `everforest`, `kanagawa`.
636
+
637
+ A theme repaints EVERYTHING: the interface, the agent's answers, the code
638
+ highlighting inside them, the status row and the input frame. To make the choice
639
+ concrete, a SAMPLE SESSION in that palette is shown before you pick (in `/theme`
640
+ below the list, in `/config` press `p` on the theme row); after
641
+ `/theme <id>` the sample is printed too. The frame itself is a separate
642
+ setting: `/config set ui.inputBorder <round|single|double|bold|none>`
643
+ (default `round`; `none` removes it).
635
644
 
636
645
  Agent data is stored in `~/.zames`: browser profile, logs, undo history, sessions.
637
646
 
@@ -1354,7 +1363,16 @@ enum переключаются на месте; числа и строки от
1354
1363
  редакторов:
1355
1364
 
1356
1365
  `zames` (по умолчанию), `one-dark`, `dracula`, `gruvbox`, `nord`, `monokai`,
1357
- `solarized-dark`, `tokyo-night`.
1366
+ `solarized-dark`, `tokyo-night`, `catppuccin`, `github-dark`, `ayu`,
1367
+ `rose-pine`, `everforest`, `kanagawa`.
1368
+
1369
+ Тема перекрашивает ВСЁ: интерфейс, ответы агента, подсветку кода в ответах,
1370
+ строку статуса и рамку ввода. Чтобы было видно, ЧТО меняется, перед выбором
1371
+ показывается образец сессии в этой палитре (в `/theme` — под списком, в
1372
+ `/config` — клавиша `p` на строке темы). После применения `/theme <id>`
1373
+ образец печатается в терминал. Саму рамку можно менять отдельно:
1374
+ `/config set ui.inputBorder <round|single|double|bold|none>` (по умолчанию
1375
+ `round`, `none` — без рамки).
1358
1376
 
1359
1377
  Данные агента хранятся в `~/.zames`: профиль браузера, логи, история undo,
1360
1378
  сессии.
package/dist/config.js CHANGED
@@ -134,6 +134,7 @@ export const DEFAULTS = {
134
134
  ui: {
135
135
  locale: DEFAULT_LOCALE,
136
136
  theme: DEFAULT_THEME,
137
+ inputBorder: 'round',
137
138
  contextLimit: 1_000_000,
138
139
  // 0 = auto: terminal width, capped at 100 columns (the historical width).
139
140
  answerWidth: 0,
@@ -291,6 +292,13 @@ export const CONFIG_SCHEMA = [
291
292
  labelKey: 'cfg.f.maxIterations',
292
293
  groupKey: 'cfg.group.agent',
293
294
  },
295
+ {
296
+ path: 'ui.inputBorder',
297
+ type: 'enum',
298
+ values: ['round', 'single', 'double', 'bold', 'none'],
299
+ labelKey: 'cfg.f.ui_inputBorder',
300
+ groupKey: 'cfg.group.ui',
301
+ },
294
302
  {
295
303
  path: 'ui.theme',
296
304
  type: 'enum',
@@ -55,6 +55,10 @@ export const configMessages = {
55
55
  ru: 'Язык интерфейса и ответов агента',
56
56
  en: 'Interface and agent reply language',
57
57
  },
58
+ 'cfg.f.ui_inputBorder': {
59
+ ru: 'Рамка строки ввода: round | single | double | bold | none',
60
+ en: 'Input box frame: round | single | double | bold | none',
61
+ },
58
62
  'cfg.f.ui_theme': {
59
63
  ru: 'Цветовая тема терминала',
60
64
  en: 'Terminal color theme',
@@ -212,6 +212,14 @@ export const deepseekMessages = {
212
212
  ru: '✔ Сохранено: {v} = {value}',
213
213
  en: '✔ Saved: {v} = {value}',
214
214
  },
215
+ 'cfg.menu.theme_preview': {
216
+ ru: 'Так тема выглядит в работе:',
217
+ en: 'How the theme looks in a session:',
218
+ },
219
+ 'cfg.menu.theme_preview_key': {
220
+ ru: 'пример темы',
221
+ en: 'theme sample',
222
+ },
215
223
  'cfg.menu.default': { ru: 'по умолчанию', en: 'default' },
216
224
  'cfg.menu.filter_hint': {
217
225
  ru: '/ — поиск по настройкам, d — сбросить (дважды), * — изменено от дефолта',
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@ import fs from 'fs/promises';
5
5
  import { readFileSync, statSync } from 'fs';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { theme, applyTheme, themeColors, themeNames, currentThemeName, isThemeName, } from './theme.js';
8
+ import { themePreviewAnsi } from './tui/theme-preview.js';
9
+ import { setInputBorder } from './tui/ui-colors.js';
8
10
  import { DeepSeekBrowser, headlessUACacheReady } from './browser.js';
9
11
  import { QwenBrowser } from './qwen-browser.js';
10
12
  import { GlmBrowser } from './glm-browser.js';
@@ -123,6 +125,10 @@ setAnswerWidth(config.ui?.answerWidth ?? 0);
123
125
  // banner, startup notices): theme.ts starts on the default and every call site
124
126
  // reads the live object, so one apply here repaints the whole session.
125
127
  applyTheme(config.ui?.theme);
128
+ // Same for the input-box FRAME style: the TUI reads it per render, so one
129
+ // apply before the editor is created covers the first paint (a /config change
130
+ // goes through setConfigRuntime below).
131
+ setInputBorder(config.ui?.inputBorder);
126
132
  // Current interface/agent language. Changed by the /config lang <ru|en> command.
127
133
  let currentLocale = isLocale(config.ui?.locale)
128
134
  ? config.ui.locale
@@ -1943,6 +1949,11 @@ async function main() {
1943
1949
  // object is shared and mutated in place, so every later render uses it;
1944
1950
  // the editor is asked for a full redraw because the rows already on screen
1945
1951
  // (status, prompt, hints) were painted with the OLD colors.
1952
+ if (path === 'ui.inputBorder') {
1953
+ setInputBorder(value);
1954
+ if (editor)
1955
+ editor.setTheme?.(themeColors());
1956
+ }
1946
1957
  if (path === 'ui.theme') {
1947
1958
  const applied = applyTheme(value);
1948
1959
  config.ui.theme = applied;
@@ -2176,8 +2187,9 @@ async function main() {
2176
2187
  // read the shared UI map, and without a bump the operator would cycle
2177
2188
  // palette ids while the colors stayed those of the previous one.
2178
2189
  onAfterChange: (field) => {
2179
- if (field.path === 'ui.theme')
2190
+ if (field.path === 'ui.theme' || field.path === 'ui.inputBorder') {
2180
2191
  editor?.setTheme?.(themeColors());
2192
+ }
2181
2193
  },
2182
2194
  locale: currentLocale,
2183
2195
  };
@@ -2238,6 +2250,11 @@ async function main() {
2238
2250
  return;
2239
2251
  }
2240
2252
  emit(theme.assistant(t('theme.set', { v: applyThemeById(arg) })));
2253
+ // A terminal can only show colors it can paint, and the operator asked
2254
+ // "what exactly changes?" — print a sample of every role in the new
2255
+ // palette right below the confirmation.
2256
+ emit('');
2257
+ emit(themePreviewAnsi(arg));
2241
2258
  return;
2242
2259
  }
2243
2260
  // No argument: a pick list in the TUI, the id list otherwise. The cursor
@@ -2267,6 +2284,8 @@ async function main() {
2267
2284
  }
2268
2285
  if (picked) {
2269
2286
  emit(theme.assistant(t('theme.set', { v: applyThemeById(picked) })));
2287
+ emit('');
2288
+ emit(themePreviewAnsi(picked));
2270
2289
  }
2271
2290
  else {
2272
2291
  // Cancelled: the previews must not stick. Restore WITHOUT persisting.
@@ -353,24 +353,26 @@ export function truncateToWidth(s, maxCols) {
353
353
  const reset = out.includes(ESC + '[') ? ESC + '[0m' : '';
354
354
  return out + reset + '…';
355
355
  }
356
- // Inner content width of the framed input box: the rounded border takes 1
357
- // column on each side and the box adds 1 column of padding on each side, so
358
- // the text must wrap to `cols - 4`. Exported so the layout ABOVE the box
356
+ // Inner content width of the framed input box: the border takes 1 column on
357
+ // each side (2 total, 0 when ui.inputBorder is 'none') and the box adds
358
+ // 1 column of padding on each side, so the text wraps to `cols - border - 2`. Exported so the layout ABOVE the box
359
359
  // (FullScreenApp) and the box itself (InputLine) use ONE number — a mismatch
360
360
  // made the reserved height disagree with the rendered rows and the panel
361
361
  // overflowed.
362
- export function inputInnerCols(cols) {
363
- return Math.max(4, (Number(cols) || 80) - 4);
362
+ export function inputInnerCols(cols, borderCols = 2) {
363
+ return Math.max(4, (Number(cols) || 80) - borderCols - 2);
364
364
  }
365
365
  // Real height (rows) of the framed input box for the CURRENT buffer: 2 border
366
366
  // rows + the number of VISUAL rows the buffer wraps to. Using the LOGICAL line
367
367
  // count (buf.split('\n').length) was wrong: a long line wraps and renders MORE
368
368
  // rows than it has newlines, so the box was reserved too few rows, the second
369
369
  // line printed OVER the first and the cursor landed on the wrong row.
370
- export function inputBoxRows(prompt, buf, cursor, cols) {
371
- const inner = inputInnerCols(cols);
370
+ export function inputBoxRows(prompt, buf, cursor, cols, borderCols = 2) {
371
+ const inner = inputInnerCols(cols, borderCols);
372
372
  const rows = layoutInput(prompt, buf, cursor, inner).rows.length;
373
- return 2 + Math.max(1, rows);
373
+ // Two BORDER rows only when a frame is drawn ('none' reserves just the text).
374
+ const borderRows = borderCols > 0 ? 2 : 0;
375
+ return borderRows + Math.max(1, rows);
374
376
  }
375
377
  export function layoutInput(promptStr, buf, cursor, cols) {
376
378
  // Reserve the LAST column. If a printed row reaches exactly `cols` columns,
package/dist/theme.js CHANGED
@@ -179,6 +179,134 @@ export const PALETTES = {
179
179
  brown: '#ff9e64',
180
180
  toggleOn: '#73daca',
181
181
  },
182
+ // Catppuccin Mocha — the soft pastel palette that took over the editor
183
+ // ecosystem (Neovim, VS Code, terminals).
184
+ catppuccin: {
185
+ bg: '#1e1e2e',
186
+ user: '#89b4fa',
187
+ userBar: '#5477a8',
188
+ turnRule: '#313244',
189
+ turnLabel: '#6c7086',
190
+ prompt: '#f9e2af',
191
+ dir: '#89dceb',
192
+ assistant: '#cdd6f4',
193
+ tool: '#fab387',
194
+ toolResult: '#a6adc8',
195
+ system: '#9399b2',
196
+ dim: '#6c7086',
197
+ taskSummary: '#89b4fa',
198
+ warn: '#f9e2af',
199
+ error: '#f38ba8',
200
+ success: '#a6e3a1',
201
+ brown: '#eba0ac',
202
+ toggleOn: '#94e2d5',
203
+ },
204
+ // GitHub Dark — the palette of GitHub's dark UI (familiar to anyone who
205
+ // reads diffs all day).
206
+ 'github-dark': {
207
+ bg: '#0d1117',
208
+ user: '#58a6ff',
209
+ userBar: '#3a6ea8',
210
+ turnRule: '#21262d',
211
+ turnLabel: '#6e7681',
212
+ prompt: '#d29922',
213
+ dir: '#79c0ff',
214
+ assistant: '#c9d1d9',
215
+ tool: '#ffa657',
216
+ toolResult: '#8b949e',
217
+ system: '#8b949e',
218
+ dim: '#6e7681',
219
+ taskSummary: '#a5d6ff',
220
+ warn: '#d29922',
221
+ error: '#f85149',
222
+ success: '#3fb950',
223
+ brown: '#db6d28',
224
+ toggleOn: '#56d4dd',
225
+ },
226
+ // Ayu Dark — the minimal warm-accent scheme from the Ayu editor theme.
227
+ ayu: {
228
+ bg: '#0f1419',
229
+ user: '#59c2ff',
230
+ userBar: '#35789e',
231
+ turnRule: '#1f2430',
232
+ turnLabel: '#5c6773',
233
+ prompt: '#ffb454',
234
+ dir: '#59c2ff',
235
+ assistant: '#c7ccd1',
236
+ tool: '#ff8f40',
237
+ toolResult: '#a0a8b0',
238
+ system: '#8a9199',
239
+ dim: '#5c6773',
240
+ taskSummary: '#39bae6',
241
+ warn: '#ffb454',
242
+ error: '#f07178',
243
+ success: '#aad94c',
244
+ brown: '#e6b450',
245
+ toggleOn: '#95e6cb',
246
+ },
247
+ // Rosé Pine — muted rose-pine tones, a calmer alternative to Dracula.
248
+ 'rose-pine': {
249
+ bg: '#191724',
250
+ user: '#9ccfd8',
251
+ userBar: '#5b8490',
252
+ turnRule: '#26233a',
253
+ turnLabel: '#6e6a86',
254
+ prompt: '#f6c177',
255
+ dir: '#9ccfd8',
256
+ assistant: '#e0def4',
257
+ tool: '#ebbcba',
258
+ toolResult: '#908caa',
259
+ system: '#908caa',
260
+ dim: '#6e6a86',
261
+ taskSummary: '#c4a7e7',
262
+ warn: '#f6c177',
263
+ error: '#eb6f92',
264
+ success: '#31748f',
265
+ brown: '#ebbcba',
266
+ toggleOn: '#9ccfd8',
267
+ },
268
+ // Everforest — the soft green forest palette (low contrast, easy on eyes).
269
+ everforest: {
270
+ bg: '#2d353b',
271
+ user: '#7fbbb3',
272
+ userBar: '#5a8b85',
273
+ turnRule: '#3d484d',
274
+ turnLabel: '#859289',
275
+ prompt: '#dbbc7f',
276
+ dir: '#7fbbb3',
277
+ assistant: '#d3c6aa',
278
+ tool: '#e69875',
279
+ toolResult: '#9da9a0',
280
+ system: '#859289',
281
+ dim: '#7a8478',
282
+ taskSummary: '#a7c080',
283
+ warn: '#dbbc7f',
284
+ error: '#e67e80',
285
+ success: '#a7c080',
286
+ brown: '#e69875',
287
+ toggleOn: '#83c092',
288
+ },
289
+ // Kanagawa — Hokusai-inspired muted indigo (the "wave" theme).
290
+ kanagawa: {
291
+ bg: '#1f1f28',
292
+ user: '#7e9cd8',
293
+ userBar: '#55709e',
294
+ turnRule: '#2a2a37',
295
+ turnLabel: '#727169',
296
+ prompt: '#e6c384',
297
+ dir: '#7fb4ca',
298
+ assistant: '#dcd7ba',
299
+ tool: '#ffa066',
300
+ toolResult: '#a6a69c',
301
+ system: '#c8c093',
302
+ dim: '#727169',
303
+ taskSummary: '#957fb8',
304
+ warn: '#e6c384',
305
+ error: '#e46876',
306
+ success: '#98bb6c',
307
+ brown: '#d27e59',
308
+ toggleOn: '#7aa89f',
309
+ },
182
310
  };
183
311
  /** The palette id used when the configured theme is unknown/missing. */
184
312
  export const DEFAULT_THEME = 'zames';
@@ -14,6 +14,7 @@ import { useMemo, useState } from 'react';
14
14
  import { Box, Text, useInput, useApp, useWindowSize } from 'ink';
15
15
  import { translate } from '../i18n.js';
16
16
  import { Checkbox, Select, UI, HintBar } from './widgets.js';
17
+ import { ThemePreview } from './theme-preview.js';
17
18
  /** Strip ANSI to measure a line's visible width. */
18
19
  function stripAnsi(s) {
19
20
  return s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
@@ -52,6 +53,8 @@ export function ConfigMenuApp(opts) {
52
53
  const [editBuf, setEditBuf] = useState('');
53
54
  const [pendingReset, setPendingReset] = useState(null);
54
55
  const [message, setMessage] = useState(null);
56
+ // Inline palette preview of the theme row ('p').
57
+ const [previewTheme, setPreviewTheme] = useState(false);
55
58
  // `get`/`set` mutate the LIVE config object in place, so React has no state
56
59
  // change to react to and the row kept the OLD value on screen after a
57
60
  // toggle/cycle (the operator saw «ru» while the config already held «en»).
@@ -216,12 +219,14 @@ export function ConfigMenuApp(opts) {
216
219
  setCursor((c) => visible.length ? (c - 1 + visible.length) % visible.length : 0);
217
220
  setPendingReset(null);
218
221
  setMessage(null);
222
+ setPreviewTheme(false);
219
223
  return;
220
224
  }
221
225
  if (key.downArrow || input === 'j') {
222
226
  setCursor((c) => (visible.length ? (c + 1) % visible.length : 0));
223
227
  setPendingReset(null);
224
228
  setMessage(null);
229
+ setPreviewTheme(false);
225
230
  return;
226
231
  }
227
232
  if (input === 'g') {
@@ -238,6 +243,30 @@ export function ConfigMenuApp(opts) {
238
243
  setMessage(null);
239
244
  return;
240
245
  }
246
+ if (input === 'p' || input === 'P') {
247
+ // Inline palette PREVIEW for the theme row: the settings list is where
248
+ // most operators meet ui.theme, and cycling the enum only shows the id.
249
+ // 'p' opens the sample session painted with the CURRENT palette, so the
250
+ // choice can be judged without leaving /config. Other rows: no-op.
251
+ const f = visible[cursor];
252
+ if (f && f.path === 'ui.theme') {
253
+ setPreviewTheme((v) => !v);
254
+ setMessage(null);
255
+ return;
256
+ }
257
+ }
258
+ if (input === 'p' || input === 'P') {
259
+ // Inline palette PREVIEW on the theme row: /config is where most
260
+ // operators meet ui.theme, and cycling the enum only shows the id. 'p'
261
+ // toggles a sample session painted with the CURRENT palette, so the
262
+ // choice can be judged without leaving the menu. Other rows: no-op.
263
+ const f = visible[cursor];
264
+ if (f && f.path === 'ui.theme') {
265
+ setPreviewTheme((v) => !v);
266
+ setMessage(null);
267
+ return;
268
+ }
269
+ }
241
270
  if (key.return || input === ' ') {
242
271
  activate();
243
272
  return;
@@ -336,10 +365,13 @@ export function ConfigMenuApp(opts) {
336
365
  if (!visible.length) {
337
366
  items.push(_jsx(Text, { color: UI.dim, children: ' ' + t('cfg.menu.no_match') }, "none"));
338
367
  }
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: [
368
+ 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)) }), previewTheme && visible[cursor]?.path === 'ui.theme' ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.dim, children: t('cfg.menu.theme_preview') }), _jsx(ThemePreview, { name: String(get('ui.theme') ?? 'zames'), compact: true })] })) : null, 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: [
340
369
  ['↑/↓', hint('tui.hint.select')],
341
370
  ['←/→', hint('tui.hint.change')],
342
371
  ['Enter', hint('tui.hint.edit')],
372
+ ...(visible[cursor]?.path === 'ui.theme'
373
+ ? [['p', hint('cfg.menu.theme_preview_key')]]
374
+ : []),
343
375
  ['/', hint('tui.hint.search')],
344
376
  ['d', hint('tui.hint.reset')],
345
377
  ['q', hint('tui.hint.quit')],
@@ -20,7 +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
+ import { UI, inputBorderCols } from './ui-colors.js';
24
24
  /** The header bar: title, version, working directory. */
25
25
  function Header({ store }) {
26
26
  const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
@@ -65,6 +65,7 @@ export function FullScreenApp({ store, inputProps, }) {
65
65
  buf: state.edit.buf,
66
66
  cursor: state.edit.cursor,
67
67
  suggestRows: suggestRowCount(state.edit.buf, state.commands, cols),
68
+ borderCols: inputBorderCols(),
68
69
  });
69
70
  const logHeight = fsLayout.logHeight;
70
71
  // Window the log BY RENDERED ROW: a long wrapped answer can be taller than
@@ -16,11 +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
+ import { UI, inputBorderStyle } from './ui-colors.js';
20
20
  /** Render the visual rows of the buffer with the prompt on the first row. */
21
21
  function InputRows({ prompt, buf, cols, }) {
22
22
  const lay = layoutInput(prompt, buf, 0, cols);
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
+ return (_jsx(Box, { flexDirection: "column", children: lay.rows.map((r, i) => (_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, bold: true, children: r.prefix }), _jsx(Text, { color: UI.text, children: r.text })] }, i))) }));
24
24
  }
25
25
  export function InputLine(props) {
26
26
  const { columns } = useWindowSize();
@@ -49,7 +49,11 @@ export function InputLine(props) {
49
49
  // `Math.max(4, cols - 4)` here was one independent copy that could drift from
50
50
  // the reservation (the box then rendered more rows than were reserved and the
51
51
  // input rows overprinted the status bar).
52
- const innerCols = inputInnerCols(cols);
52
+ // The frame style comes from ui.inputBorder (set live by /config): round
53
+ // (default), single, double, bold, none. Read per render so a switch applies
54
+ // immediately; 'none' also frees the two border columns for the text.
55
+ const borderStyle = inputBorderStyle();
56
+ const innerCols = inputInnerCols(cols, borderStyle ? 2 : 0);
53
57
  // Mirror the state into a ref so the useInput callback (a stable closure Ink
54
58
  // calls for every key) always sees the latest values.
55
59
  const stRef = useRef(st);
@@ -321,5 +325,5 @@ export function InputLine(props) {
321
325
  }), moreCount > 0 && (_jsx(Text, { color: UI.dim, children: ' ' +
322
326
  t('editor.more', { n: String(moreCount) }) +
323
327
  ' ' +
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 }) })] }));
328
+ t('editor.page_hint') }))] })), _jsx(Box, { borderStyle: borderStyle, borderColor: props.locked ? UI.dim : UI.accent, paddingLeft: 1, paddingRight: 1, flexDirection: "column", children: _jsx(InputRows, { prompt: props.prompt, buf: st.buf, cols: innerCols }) })] }));
325
329
  }
@@ -115,12 +115,13 @@ function Badges({ state, budget = Infinity, }) {
115
115
  }
116
116
  return _jsx(_Fragment, { children: all.slice(start).map((b) => b.node) });
117
117
  }
118
- export function StatusBar({ state, divider = true, }) {
119
- // Drive the spinner from Ink's shared animation timer (one timer for the
120
- // whole tree). The interval matches the old 400ms dot cadence.
118
+ /**
119
+ * The LEFT part of the status row (spinner/pause/scroll marker) as a string.
120
+ * Extracted from the component so the text and its rules are unit-testable
121
+ * without a terminal; the component only truncates and colorizes it.
122
+ */
123
+ export function statusLeftText(state, t, frame = 0) {
121
124
  const animating = state.busy || !!state.animBase;
122
- const { frame } = useAnimation({ interval: 400, isActive: animating });
123
- const t = translate(state.locale);
124
125
  const stateLbl = stateLabel(state, state.locale);
125
126
  let left;
126
127
  if (state.pendingText) {
@@ -147,20 +148,31 @@ export function StatusBar({ state, divider = true, }) {
147
148
  // When the log is scrolled up, show a marker in the status line (NOT as a
148
149
  // separate row below the input — that pushed the panel up and made the input
149
150
  // jump). It replaces the spinner text while scrolled, so it never adds a row.
151
+ // It carries NO timer: the elapsed label belongs to an animation, and a
152
+ // counter running next to «scrolled 5 lines» only ate width.
150
153
  const scrollNote = state.scroll > 0
151
- ? translate(state.locale)('status.scrolled', {
152
- n: String(state.scroll),
153
- })
154
+ ? translate(state.locale)('status.scrolled', { n: String(state.scroll) })
154
155
  : '';
155
- const leftText = scrollNote || prefix + left;
156
+ return scrollNote || prefix + left;
157
+ }
158
+ export function StatusBar({ state, divider = true, }) {
159
+ // Drive the spinner from Ink's shared animation timer (one timer for the
160
+ // whole tree). The interval matches the old 400ms dot cadence.
161
+ const animating = state.busy || !!state.animBase;
162
+ const { frame } = useAnimation({ interval: 400, isActive: animating });
163
+ const t = translate(state.locale);
164
+ const leftText = statusLeftText(state, t, frame);
156
165
  const showLeft = !!leftText;
157
- // Keep the row on ONE line. The left status gets at most a third of the
158
- // width (it is the least important); the badges take the rest and DROP whole
159
- // badges until they fit (the context counter survives longest). Estimating
160
- // the badge width for the left budget was circular and let the row overshoot
161
- // to a second line on a narrow terminal.
166
+ // Keep the row on ONE line: the left status may use whatever the BADGES do
167
+ // not need, minus a small gap so the two never touch. A fixed "a third of
168
+ // the width" budget cut the pause counter mid-number («5…») while half the
169
+ // row sat empty; measuring the badges (they are DROPPED whole when the row
170
+ // is tight, the context counter survives longest) is both accurate and
171
+ // keeps the layout stable.
162
172
  const cols = useWindowSize().columns || 80;
163
- const leftBudget = Math.max(0, Math.min(Math.floor(cols / 3), cols - 14));
173
+ const badgeW = badgeTextWidth(state);
174
+ const gap = badgeW > 0 ? 2 : 0;
175
+ const leftBudget = Math.max(0, cols - 2 - badgeW - gap);
164
176
  const leftOut = showLeft ? truncateToWidth(leftText, leftBudget) : '';
165
177
  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)) })] })] }));
166
178
  }
@@ -10,7 +10,7 @@ import { useState } from 'react';
10
10
  import { Box, Text, useInput, useApp, useWindowSize } from 'ink';
11
11
  import { UI } from './ui-colors.js';
12
12
  import { HintBar } from './widgets.js';
13
- import { theme } from '../theme.js';
13
+ import { ThemePreview } from './theme-preview.js';
14
14
  import { translate } from '../i18n.js';
15
15
  export function ThemePicker({ themes, initial, onPreview, locale = 'en', onDone, }) {
16
16
  const { exit } = useApp();
@@ -30,20 +30,6 @@ export function ThemePicker({ themes, initial, onPreview, locale = 'en', onDone,
30
30
  setCursor(next);
31
31
  onPreview(themes[next]);
32
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
33
  useInput((input, key) => {
48
34
  if (key.escape || (key.ctrl && input === 'c') || input === 'q') {
49
35
  finish(null);
@@ -63,7 +49,7 @@ export function ThemePicker({ themes, initial, onPreview, locale = 'en', onDone,
63
49
  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
50
  const sel = idx === cursor;
65
51
  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: [
52
+ }), _jsx(Text, { color: UI.border, children: '─'.repeat(innerCols) }), _jsx(ThemePreview, { name: themes[cursor] ?? initial, compact: true }), _jsx(HintBar, { hints: [
67
53
  ['↑/↓', t('theme.pick_move')],
68
54
  ['Enter', t('tui.hint.ok')],
69
55
  ['Esc', t('theme.pick_cancel')],
@@ -13,7 +13,7 @@ export const HINT_ROWS = 1;
13
13
  export function fullScreenLayout(input) {
14
14
  const termRows = Math.max(1, Math.floor(input.termRows) || 24);
15
15
  const cols = Math.max(1, Math.floor(input.cols) || 80);
16
- const box = inputBoxRows(input.prompt, input.buf, input.cursor, cols);
16
+ const box = inputBoxRows(input.prompt, input.buf, input.cursor, cols, input.borderCols ?? 2);
17
17
  const suggestRows = Math.max(0, Math.floor(input.suggestRows) || 0);
18
18
  const panelRows = HEADER_ROWS + STATUS_ROWS + box + suggestRows + HINT_ROWS;
19
19
  const logHeight = Math.max(1, termRows - panelRows);
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import chalk from 'chalk';
4
+ import { PALETTES, DEFAULT_THEME, isThemeName } from '../theme.js';
5
+ /**
6
+ * The sample lines. Kept PURE DATA so tests can assert the preview covers the
7
+ * important roles and the Ink/plain renderers cannot drift apart.
8
+ */
9
+ export function previewLines() {
10
+ return [
11
+ { role: 'dir', text: '~/project' },
12
+ { role: 'prompt', text: '❯ объясни этот код', bold: true },
13
+ { role: 'assistant', text: '● Ответ: функция читает файл и парсит JSON.' },
14
+ { role: 'tool', text: '🔧 Read {"path":"package.json"}' },
15
+ { role: 'toolResult', text: '→ ✔ 42 строки' },
16
+ { role: 'taskSummary', text: '· duration: 6s · tools: 1' },
17
+ { role: 'warn', text: '▲ предупреждение' },
18
+ { role: 'error', text: '✖ ошибка' },
19
+ { role: 'success', text: '✔ готово' },
20
+ { role: 'toggleOn', text: '🌐 поиск вкл' },
21
+ { role: 'system', text: '● сервисное сообщение' },
22
+ { role: 'dim', text: '· подсказка' },
23
+ ];
24
+ }
25
+ function paletteOf(name) {
26
+ return PALETTES[isThemeName(name) ? name : DEFAULT_THEME];
27
+ }
28
+ /** The preview as one chalk-painted string (for a plain terminal line). */
29
+ export function themePreviewAnsi(name) {
30
+ const p = paletteOf(name);
31
+ return previewLines()
32
+ .map((l) => {
33
+ const c = chalk.hex(p[l.role]);
34
+ return l.bold ? c.bold(l.text) : c(l.text);
35
+ })
36
+ .join('\n');
37
+ }
38
+ /** The preview as an Ink block (for the picker / the config menu). */
39
+ export function ThemePreview({ name, compact = false, }) {
40
+ const p = paletteOf(name);
41
+ const lines = compact
42
+ ? previewLines().filter((l) => ['prompt', 'assistant', 'tool', 'error', 'success', 'dim'].includes(l.role))
43
+ : previewLines();
44
+ return (_jsx(Box, { flexDirection: "column", children: lines.map((l, i) => (_jsx(Text, { color: p[l.role], bold: l.bold, children: l.text }, i))) }));
45
+ }
@@ -26,6 +26,36 @@ export function uiColorsFrom(p) {
26
26
  };
27
27
  }
28
28
  export const UI = uiColorsFrom(PALETTES[DEFAULT_THEME]);
29
+ // Border STYLE of the framed surfaces (the input box today). Ink only ships a
30
+ // handful of box styles; 'none' draws no frame at all. Kept next to the colors
31
+ // because it is the same kind of presentation state, and mutated in place so a
32
+ // /config switch applies without re-importing anything.
33
+ let inputBorder = 'round';
34
+ /** Switch the input frame style ('round'|'single'|'double'|'bold'|'none'). */
35
+ export function setInputBorder(name) {
36
+ const v = String(name ?? '')
37
+ .trim()
38
+ .toLowerCase();
39
+ inputBorder =
40
+ v === 'none' ||
41
+ v === 'single' ||
42
+ v === 'double' ||
43
+ v === 'bold' ||
44
+ v === 'round'
45
+ ? v
46
+ : 'round';
47
+ return inputBorder;
48
+ }
49
+ /** The current input frame style as an Ink borderStyle value (none = no box). */
50
+ export function inputBorderStyle() {
51
+ if (inputBorder === 'none')
52
+ return undefined;
53
+ return inputBorder;
54
+ }
55
+ /** Border columns the frame consumes (2 = left + right, 0 when off). */
56
+ export function inputBorderCols() {
57
+ return inputBorderStyle() ? 2 : 0;
58
+ }
29
59
  /** Repaint the Ink widgets with the palette of `name` (unknown -> default). */
30
60
  export function syncUiColors(name) {
31
61
  const id = isThemeName(name) ? name : DEFAULT_THEME;
package/llms-full.txt CHANGED
@@ -616,7 +616,16 @@ The switch is instant, no restart. `zames` is the default; the bundled palettes
616
616
  are ports of well-known editor schemes:
617
617
 
618
618
  `zames` (default), `one-dark`, `dracula`, `gruvbox`, `nord`, `monokai`,
619
- `solarized-dark`, `tokyo-night`.
619
+ `solarized-dark`, `tokyo-night`, `catppuccin`, `github-dark`, `ayu`,
620
+ `rose-pine`, `everforest`, `kanagawa`.
621
+
622
+ A theme repaints EVERYTHING: the interface, the agent's answers, the code
623
+ highlighting inside them, the status row and the input frame. To make the choice
624
+ concrete, a SAMPLE SESSION in that palette is shown before you pick (in `/theme`
625
+ below the list, in `/config` press `p` on the theme row); after
626
+ `/theme <id>` the sample is printed too. The frame itself is a separate
627
+ setting: `/config set ui.inputBorder <round|single|double|bold|none>`
628
+ (default `round`; `none` removes it).
620
629
 
621
630
  Agent data is stored in `~/.zames`: browser profile, logs, undo history, sessions.
622
631
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.83.2",
3
+ "version": "2.84.0",
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": {
@@ -47,7 +47,8 @@
47
47
  "postinstall": "node scripts/postinstall.mjs",
48
48
  "prepare": "husky",
49
49
  "glm-smoke": "tsx scripts/glm-smoke.mts",
50
- "seo:topics": "tsx scripts/seo-topics.mts"
50
+ "seo:topics": "tsx scripts/seo-topics.mts",
51
+ "ui-smoke": "tsx scripts/ui-smoke.mts"
51
52
  },
52
53
  "keywords": [
53
54
  "zames",