zames_pro 2.82.141 → 2.83.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/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')],
@@ -12,15 +12,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  import { useSyncExternalStore } from 'react';
13
13
  import { Box, Text, useWindowSize, useInput } from 'ink';
14
14
  import { LogList } from './LogView.js';
15
- import { windowByRows, totalRows, entryRows } from './log-window.js';
15
+ import { windowByRows, totalRows } from './log-window.js';
16
16
  import { StatusBar } from './StatusBar.js';
17
17
  import { InputLine } from './InputLine.js';
18
- import { contentWidth } from '../width.js';
19
18
  import { translate } from '../i18n.js';
20
19
  import { suggestRowCount } from '../input/suggest.js';
21
20
  import { truncateToWidth, visLen } from '../input/layout.js';
22
21
  import { fullScreenLayout } from './layout.js';
23
22
  import { useMouseWheel } from './useMouseWheel.js';
23
+ import { UI } 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);
@@ -29,6 +29,9 @@ function Header({ store }) {
29
29
  .filter(Boolean)
30
30
  .join(' ');
31
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;
32
35
  const rawDir = state.workdir ? ' ' + state.workdir : '';
33
36
  // The header MUST stay exactly ONE text row: FullScreenApp reserves
34
37
  // HEADER_ROWS = 2 (title + divider) and the whole panel height is computed
@@ -44,7 +47,7 @@ function Header({ store }) {
44
47
  dir = budget > 0 ? truncateToWidth(dir, budget) : '';
45
48
  // If even title + provider exceed the width, drop the directory entirely.
46
49
  const finalDir = gap >= 1 ? dir : '';
47
- 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) })] }));
48
51
  }
49
52
  export function FullScreenApp({ store, inputProps, }) {
50
53
  const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
@@ -69,22 +72,13 @@ export function FullScreenApp({ store, inputProps, }) {
69
72
  // it half empty. `scroll` is measured in ROWS here (0 = newest at the
70
73
  // bottom).
71
74
  const all = state.log;
72
- // The viewport height is RESERVED with the floor (never more rows than the
73
- // pane can hold) and the box is allowed to take the slack: rounding the
74
- // reservation UP on every entry made the panel taller than the terminal on a
75
- // short window, so the input rows fell below the last screen row and the
76
- // caret drifted under the box while a long multi-line buffer was typed.
77
- const totalFloor = totalRows(all, cols, 'floor');
75
+ // The scroll bound uses the EXACT row count: it is what the content really
76
+ // renders, so the newest row stays reachable. (Reserving the viewport with
77
+ // the floor is a separate concern, handled in `fullScreenLayout`.)
78
78
  const total = totalRows(all, cols);
79
79
  const maxScroll = Math.max(0, total - logHeight);
80
80
  const scroll = Math.max(0, Math.min(state.scroll, maxScroll));
81
81
  const visible = windowByRows(all, logHeight, scroll, cols);
82
- // Rows the WINDOWED content really needs. The reservation above rounds DOWN
83
- // per entry (floor), so the difference is the slack the content needs. The
84
- // box takes at most `logHeight` and at least one row; never more, because
85
- // that is exactly what pushed the input rows off a short screen.
86
- const filledRows = visible.reduce((n, e) => n + entryRows(e, cols), 0);
87
- const logBoxHeight = Math.max(1, Math.min(logHeight, Math.max(filledRows, 1)));
88
82
  // Scroll the log with PgUp/PgDn; Home/End jump to the very top/bottom. The
89
83
  // input line OWNS Home/End while its buffer is non-empty (they move the
90
84
  // caret), so the jump only applies to an EMPTY buffer — which is exactly
@@ -117,13 +111,12 @@ export function FullScreenApp({ store, inputProps, }) {
117
111
  const next = cur + delta * 3;
118
112
  store.set({ scroll: Math.max(0, Math.min(maxScroll, next)) });
119
113
  }, state.mouseScroll);
120
- 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,
121
115
  // Cursor rows above the input box: header + log viewport + status.
122
116
  // The log pane is FLEXIBLE now, so the panel below it starts right
123
117
  // after everything above: header + as many log rows as fit + the
124
118
  // suggestion popup + status. `cursorTop` counts the rows ABOVE the
125
119
  // input rows; the box border row is added by InputLine.
126
- 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: ' ' +
127
121
  translate(state.locale)(state.mouseScroll ? 'editor.keys_hint' : 'editor.keys_hint_select') })] }));
128
122
  }
129
- void contentWidth;
@@ -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
  }
@@ -3,7 +3,7 @@ import { Static, Box, Text } from 'ink';
3
3
  import { theme } from '../theme.js';
4
4
  /** One log entry. `raw` items already carry ANSI colors and are printed
5
5
  * verbatim; structured items get a themed prefix. */
6
- export function LogRow({ entry }) {
6
+ function LogRow({ entry }) {
7
7
  if (entry.raw) {
8
8
  // Pre-rendered (markdown answer, tool preview). Print as-is; Ink passes
9
9
  // embedded ANSI escape sequences through to the terminal.
@@ -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
  }
@@ -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 });
package/dist/tui/edit.js CHANGED
@@ -292,7 +292,7 @@ export function searchStart(s) {
292
292
  };
293
293
  return searchRefresh(st, true);
294
294
  }
295
- export function searchRefresh(s, resetToNewest) {
295
+ function searchRefresh(s, resetToNewest) {
296
296
  const q = s.searchQuery;
297
297
  const index = resetToNewest ? s.history.length - 1 : s.searchIndex;
298
298
  for (let i = index; i >= 0; i--) {
@@ -7,7 +7,7 @@ import { inputBoxRows } from '../input/layout.js';
7
7
  /** Rows of the fixed header: the title row + the divider. */
8
8
  export const HEADER_ROWS = 2;
9
9
  /** Rows of the status bar above the input box. */
10
- export const STATUS_ROWS = 1;
10
+ const STATUS_ROWS = 1;
11
11
  /** Rows of the key hint UNDER the input box. */
12
12
  export const HINT_ROWS = 1;
13
13
  export function fullScreenLayout(input) {
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
+ }