ai-profile-switch 2.2.0 → 2.2.2

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.
@@ -0,0 +1,65 @@
1
+ // Chooses the screen language and looks up messages. English fills any gap.
2
+ import { LANGUAGES, LANGUAGE_ENV, META_FILE } from './constants.mjs';
3
+ import { readJsonFile } from './json-fs.mjs';
4
+ import en from '../locales/en.mjs';
5
+ import ko from '../locales/ko.mjs';
6
+
7
+ const MESSAGES = { en, ko };
8
+ export const LANGUAGE_NAMES = { en: 'English', ko: '한국어' };
9
+
10
+ function languageCode(value) {
11
+ const code = String(value ?? '').trim().split(/[-_.@]/)[0].toLowerCase();
12
+ return code || null;
13
+ }
14
+
15
+ // Terminal locale first (LC_ALL → LC_MESSAGES → LANG), then the OS locale.
16
+ // A set but unknown locale such as C means English rather than a fallthrough.
17
+ export function systemLanguage(env = process.env, intlLocale) {
18
+ const code = languageCode(env.LC_ALL || env.LC_MESSAGES || env.LANG)
19
+ ?? languageCode(intlLocale ?? Intl.DateTimeFormat().resolvedOptions().locale);
20
+ return LANGUAGES.includes(code) ? code : 'en';
21
+ }
22
+
23
+ export function savedLanguage() {
24
+ try {
25
+ const { language } = readJsonFile(META_FILE, {}) ?? {};
26
+ return LANGUAGES.includes(language) ? language : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export function detectLanguage({ saved = savedLanguage(), env = process.env, intlLocale } = {}) {
33
+ if (saved) return saved;
34
+ const forced = languageCode(env[LANGUAGE_ENV]);
35
+ if (LANGUAGES.includes(forced)) return forced;
36
+ return systemLanguage(env, intlLocale);
37
+ }
38
+
39
+ let current;
40
+
41
+ export function language() {
42
+ current ??= detectLanguage();
43
+ return current;
44
+ }
45
+
46
+ export function setLanguage(code) {
47
+ current = code;
48
+ }
49
+
50
+ export function t(key, vars = {}) {
51
+ const message = MESSAGES[language()][key] ?? en[key];
52
+ if (message === undefined) throw new Error(`Missing message: ${key}`);
53
+ return message.replace(/\{(\w+)\}/g, (match, name) => (Object.hasOwn(vars, name) ? String(vars[name]) : match));
54
+ }
55
+
56
+ const TIME_UNITS = [['year', 31536000], ['month', 2592000], ['week', 604800], ['day', 86400], ['hour', 3600], ['minute', 60]];
57
+
58
+ export function relativeTime(ms, now = Date.now()) {
59
+ const seconds = Math.round((ms - now) / 1000);
60
+ const format = new Intl.RelativeTimeFormat(language(), { numeric: 'auto' });
61
+ for (const [unit, size] of TIME_UNITS) {
62
+ if (Math.abs(seconds) >= size) return format.format(Math.round(seconds / size), unit);
63
+ }
64
+ return t('time.justNow');
65
+ }
@@ -11,7 +11,8 @@ import {
11
11
  } from './constants.mjs';
12
12
  import { createHash } from 'node:crypto';
13
13
  import { addProfileToMeta, removeProfileFromMeta, readMeta, writeMeta } from './config.mjs';
14
- import { warn } from './ui.mjs';
14
+ import { userError, warn } from './ui.mjs';
15
+ import { t } from './i18n.mjs';
15
16
 
16
17
  export function pathExists(path) {
17
18
  try { lstatSync(path); return true; } catch (err) {
@@ -31,23 +32,21 @@ export function createLink(target, link, isDir, copySource = resolve(dirname(lin
31
32
  } catch (err) {
32
33
  if (!IS_WINDOWS || isDir || !['EPERM', 'ENOTSUP'].includes(err.code)) throw err;
33
34
  copyFileSync(copySource, link);
34
- warn(`Symlink unavailable: copied shared file instead (${link}).`);
35
- warn('Enable Windows Developer Mode for full shared-settings sync.');
35
+ warn(t('profile.copiedInstead', { link }));
36
+ warn(t('profile.devMode'));
36
37
  }
37
38
  }
38
39
 
39
40
  export function validateProfileName(name) {
40
- if (!name) return 'Profile name cannot be empty';
41
+ if (!name) return t('profile.empty');
41
42
  if (name.length > PROFILE_NAME_MAX_LENGTH) {
42
- return `Profile name must be ${PROFILE_NAME_MAX_LENGTH} characters or fewer`;
43
+ return t('profile.tooLong', { max: PROFILE_NAME_MAX_LENGTH });
43
44
  }
44
- if (RESERVED_NAMES.includes(name)) return `"${name}" is a reserved name`;
45
+ if (RESERVED_NAMES.includes(name)) return t('profile.reserved', { name });
45
46
  if (IS_WINDOWS && /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(name)) {
46
- return `"${name}" is a reserved Windows device name`;
47
- }
48
- if (!PROFILE_NAME_REGEX.test(name)) {
49
- return 'Only lowercase letters, numbers, and hyphens allowed (must start/end with letter or number)';
47
+ return t('profile.reservedWindows', { name });
50
48
  }
49
+ if (!PROFILE_NAME_REGEX.test(name)) return t('profile.invalidName');
51
50
  return null;
52
51
  }
53
52
 
@@ -87,13 +86,16 @@ export function assertRegisteredProfile(name) {
87
86
  const error = validateProfileName(name);
88
87
  if (error) throw new Error(error);
89
88
  const meta = readMeta();
90
- if (!meta.profiles.includes(name)) throw new Error(`Profile "${name}" is not registered`);
89
+ if (!meta.profiles.includes(name)) {
90
+ throw userError(t('profile.notFound', { name }), [
91
+ ...(meta.profiles.length ? [{ label: t('hint.profiles'), text: meta.profiles.join(', ') }] : []),
92
+ { label: t('hint.create'), command: `cpf add ${name}` },
93
+ ]);
94
+ }
91
95
  const dir = profileDir(name);
92
- if (!pathExists(dir)) throw new Error(`Profile "${name}" directory is missing`);
96
+ if (!pathExists(dir)) throw new Error(t('profile.missingDir', { name }));
93
97
  const entry = lstatSync(dir);
94
- if (entry.isSymbolicLink() || !entry.isDirectory()) {
95
- throw new Error(`Profile "${name}" must be a directory, not a symbolic link or file`);
96
- }
98
+ if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(t('profile.notDirectory', { name }));
97
99
  return meta;
98
100
  }
99
101
 
@@ -103,16 +105,25 @@ export function removeProfile(name) {
103
105
  removeProfileFromMeta(name);
104
106
  }
105
107
 
106
- export function renameProfile(oldName, newName) {
108
+ // Checks a rename without changing anything, so callers can warn only when it can go ahead.
109
+ export function checkRename(oldName, newName) {
107
110
  const meta = assertRegisteredProfile(oldName);
108
111
  const error = validateProfileName(newName);
109
112
  if (error) throw new Error(error);
113
+ if (oldName !== newName && (meta.profiles.includes(newName) || pathExists(profileDir(newName)))) {
114
+ throw userError(t('profile.exists', { name: newName }), [
115
+ { label: t('hint.profiles'), text: meta.profiles.join(', ') },
116
+ { label: t('hint.otherName'), command: `cpf rename ${oldName} ${newName}-2` },
117
+ ]);
118
+ }
119
+ return meta;
120
+ }
121
+
122
+ export function renameProfile(oldName, newName) {
123
+ const meta = checkRename(oldName, newName);
110
124
  if (oldName === newName) return false;
111
125
  const oldDir = profileDir(oldName);
112
126
  const newDir = profileDir(newName);
113
- if (meta.profiles.includes(newName) || pathExists(newDir)) {
114
- throw new Error(`Profile "${newName}" already exists`);
115
- }
116
127
 
117
128
  renameSync(oldDir, newDir);
118
129
  try {
@@ -220,6 +231,37 @@ function validateJson(source) {
220
231
  }
221
232
  }
222
233
 
234
+ // Claude Code saves absolute paths in its plugin indexes. Copied as they are, a profile would keep
235
+ // loading plugins from the source folder and break once that folder is removed.
236
+ const PLUGIN_INDEXES = ['installed_plugins.json', 'known_marketplaces.json'];
237
+
238
+ function repointPlugins(plugins, sourceDir, targetDir) {
239
+ if (!pathExists(plugins) || !lstatSync(plugins).isDirectory()) return;
240
+ const to = join(targetDir, 'plugins');
241
+ const prefixes = [...new Set([join(sourceDir, 'plugins'), join(canonicalPath(sourceDir), 'plugins')])];
242
+ const move = value => {
243
+ if (typeof value === 'string') {
244
+ const from = prefixes.find(prefix => value === prefix
245
+ || value.startsWith(prefix + sep) || value.startsWith(prefix + '/'));
246
+ return from ? to + value.slice(from.length) : value;
247
+ }
248
+ if (Array.isArray(value)) return value.map(move);
249
+ if (value && typeof value === 'object') {
250
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, move(item)]));
251
+ }
252
+ return value;
253
+ };
254
+ for (const name of PLUGIN_INDEXES) {
255
+ const path = join(plugins, name);
256
+ if (!pathExists(path) || !lstatSync(path).isFile()) continue;
257
+ const text = readFileSync(path, 'utf8');
258
+ let index;
259
+ try { index = JSON.parse(text); } catch { continue; }
260
+ const moved = JSON.stringify(move(index), null, 2);
261
+ if (moved !== JSON.stringify(index, null, 2)) writeFileSync(path, moved + '\n');
262
+ }
263
+ }
264
+
223
265
  function stageEntry(source, destination) {
224
266
  if (!pathExists(source)) return;
225
267
  if (pathExists(destination)) {
@@ -282,6 +324,7 @@ function prepareMigration(source, profileName, shareSettings, stage) {
282
324
  stageEntry(join(source.directory, entry), join(stagedProfile, entry));
283
325
  if (!pathExists(join(stagedProfile, entry))) mkdirSync(join(stagedProfile, entry));
284
326
  }
327
+ repointPlugins(join(stagedProfile, 'plugins'), source.directory, target);
285
328
  for (const entry of [...SHARED_FILES, ...SHARED_DIRS]) {
286
329
  const shared = shareSettings ? stagedShared : stagedProfile;
287
330
  const from = join(source.directory, entry);
@@ -1,6 +1,7 @@
1
1
  import { createInterface, emitKeypressEvents } from 'node:readline';
2
2
  import { PassThrough } from 'node:stream';
3
- import { color, cursorControl, sym } from './ui.mjs';
3
+ import { color, cursorControl, displayWidth, sym } from './ui.mjs';
4
+ import { t } from './i18n.mjs';
4
5
 
5
6
  function rl() {
6
7
  return createInterface({ input: process.stdin, output: process.stdout });
@@ -29,17 +30,18 @@ function readKeys(onKey) {
29
30
  };
30
31
  }
31
32
 
32
- export async function text(message, defaultVal) {
33
- const suffix = defaultVal !== undefined ? color.dim(` (${defaultVal})`) : '';
33
+ // hint is shown only while asking, such as "now: side · empty to cancel".
34
+ export async function text(message, defaultVal, hint) {
35
+ const suffix = hint ? ` ${color.dim(hint)}` : defaultVal !== undefined ? color.dim(` (${defaultVal})`) : '';
34
36
  const r = rl();
35
37
  const cursor = cursorControl();
36
38
  return new Promise((resolve) => {
37
- r.question(`${color.cyan('?')} ${message}${suffix}\n ${color.dim(sym.arrow)} `, (answer) => {
39
+ r.question(`${color.cyan(sym.step)} ${message}${suffix}\n${color.dim(sym.rail)} ${color.dim(sym.arrow)} `, (answer) => {
38
40
  r.close();
39
41
  const result = answer.trim() || defaultVal || '';
40
- // Replace 2-line prompt with single completed line: ✓ message … answer
42
+ // Replace 2-line prompt with single completed line: ◇ message … answer
41
43
  if (cursor.enabled) {
42
- process.stdout.write(`${cursor.up(2)}\r${cursor.clearLine}${color.green(sym.check)} ${message} ${color.dim(sym.dots)} ${color.cyan(result)}\n${cursor.clearDown}`);
44
+ process.stdout.write(`${cursor.up(2)}\r${cursor.clearLine}${color.green(sym.stepDone)} ${message} ${color.dim(sym.dots)} ${result ? color.cyan(result) : color.dim(t('prompt.cancelled'))}\n${cursor.clearDown}`);
43
45
  }
44
46
  resolve(result);
45
47
  });
@@ -63,11 +65,12 @@ export async function confirm(message, defaultVal = true) {
63
65
  const cursor = cursorControl();
64
66
 
65
67
  const render = (final = false) => {
66
- const prefix = final ? color.green(sym.check) : color.cyan('?');
68
+ const prefix = final ? color.green(sym.stepDone) : color.cyan(sym.step);
67
69
  const sep = final ? color.dim(` ${sym.dots} `) : color.dim(` ${sym.arrow} `);
68
- const no = !selected ? color.cyan('No') : color.dim('No');
69
- const yes = selected ? color.cyan('Yes') : color.dim('Yes');
70
- process.stdout.write(`\r${cursor.clearLine}${prefix} ${message}${sep}${no} ${color.dim('/')} ${yes}`);
70
+ const no = !selected ? color.cyan(t('prompt.no')) : color.dim(t('prompt.no'));
71
+ const yes = selected ? color.cyan(t('prompt.yes')) : color.dim(t('prompt.yes'));
72
+ const answer = final ? (selected ? yes : no) : `${no} ${color.dim('/')} ${yes}`;
73
+ process.stdout.write(`\r${cursor.clearLine}${prefix} ${message}${sep}${answer}`);
71
74
  };
72
75
 
73
76
  render();
@@ -82,7 +85,8 @@ export async function confirm(message, defaultVal = true) {
82
85
  } else if (character === 'n' || character === 'N') {
83
86
  selected = false;
84
87
  render();
85
- } else if (key.name === 'return' || key.name === 'enter') {
88
+ } else if (key.name === 'return' || key.name === 'enter' || key.name === 'escape') {
89
+ if (key.name === 'escape') selected = false;
86
90
  stop();
87
91
  render(true);
88
92
  process.stdout.write('\n');
@@ -96,52 +100,115 @@ export async function confirm(message, defaultVal = true) {
96
100
  });
97
101
  }
98
102
 
99
- export async function select(message, choices, { output = process.stdout, initial = 0 } = {}) {
103
+ // Choices are strings or { label, value, hint, details }. hint sits after the label,
104
+ // details are extra dim lines under it. numbered adds 1-9 keys that pick at once.
105
+ // body lines sit between the question and the choices; back says Esc goes back.
106
+ export async function select(message, choices, {
107
+ output = process.stdout, initial = 0, numbered = false, done, body = [], back = false,
108
+ } = {}) {
109
+ const items = choices.map(choice => (typeof choice === 'string' ? { label: choice, value: choice } : choice));
100
110
  return new Promise((resolve) => {
101
- let selected = Math.max(0, Math.min(initial, choices.length - 1));
102
- let rendered = false;
111
+ let selected = Math.max(0, Math.min(initial, items.length - 1));
112
+ let height = 0;
113
+ let top = 0;
103
114
  const cursor = cursorControl(output);
115
+ const rail = color.dim(sym.rail);
116
+ const indent = ' '.repeat(numbered ? 5 : 2);
117
+ const keys = numbered && items.length > 1 && items.length <= 9
118
+ ? t(back ? 'prompt.keysNumberedBack' : 'prompt.keysNumbered', { arrows: sym.arrows, max: items.length })
119
+ : t(back ? 'prompt.keysBack' : 'prompt.keys', { arrows: sym.arrows });
120
+
121
+ // The choices from index from up to to, with details under the ones detailed(i) picks.
122
+ const rows = (detailed = () => true, from = 0, to = items.length) => {
123
+ const lines = [];
124
+ if (from > 0) lines.push(`${rail} ${color.dim(t('prompt.moreAbove', { count: from }))}`);
125
+ items.slice(from, to).forEach((item, offset) => {
126
+ const i = from + offset;
127
+ const number = numbered ? `${i + 1} ` : '';
128
+ const head = i === selected
129
+ ? color.cyan(`${sym.cursor} ${number}${item.label}`)
130
+ : ` ${numbered ? `${color.dim(String(i + 1))} ` : ''}${item.label}`;
131
+ lines.push(`${rail} ${head}${item.hint ? ` ${item.hint}` : ''}`);
132
+ if (!detailed(i) || !item.details?.length) return;
133
+ for (const detail of item.details) lines.push(`${rail} ${indent}${detail}`);
134
+ lines.push(rail);
135
+ });
136
+ if (to < items.length) lines.push(`${rail} ${color.dim(t('prompt.moreBelow', { count: items.length - to }))}`);
137
+ lines.push(`${rail} ${color.dim(keys)}`, color.dim(sym.railEnd));
138
+ return lines;
139
+ };
140
+
141
+ // A line wider than the window wraps, so count the screen rows it really takes.
142
+ const screenRows = lines => lines.reduce((sum, line) => sum
143
+ + Math.max(1, Math.ceil(displayWidth(line) / (output.columns || Infinity))), 0);
144
+ const header = [`${color.cyan(sym.step)} ${message}`, ...body.map(line => `${rail} ${line}`)];
145
+
146
+ // The cursor can't move up past the top of the window, so a menu taller than the window
147
+ // can't be redrawn in place: every redraw would push another copy of its first rows into
148
+ // the scrollback. It shrinks to fit instead: details only under the selected choice, then
149
+ // only the choices around it. The row under the menu is where the cursor waits.
150
+ const fits = lines => screenRows(header) + screenRows(lines) < (output.rows || Infinity);
151
+ const layout = () => {
152
+ const full = rows();
153
+ if (fits(full)) return full;
154
+ const shown = (from, to) => rows(i => i === selected, from, to);
155
+ if (fits(shown(0, items.length))) return shown(0, items.length);
156
+ const end = from => {
157
+ let to = Math.max(from, selected) + 1;
158
+ while (to < items.length && fits(shown(from, to + 1))) to++;
159
+ return to;
160
+ };
161
+ top = Math.min(top, selected);
162
+ while (top < selected && !fits(shown(top, end(top)))) top++;
163
+ return shown(top, end(top));
164
+ };
104
165
 
105
166
  const render = () => {
106
- if (rendered) {
107
- output.write(cursor.up(choices.length));
167
+ if (height) output.write(cursor.up(height));
168
+ const lines = layout();
169
+ for (const line of lines) output.write(`${cursor.clearLine}${line}\n`);
170
+ // A shorter menu than last time leaves old rows below it.
171
+ output.write(cursor.clearDown);
172
+ height = screenRows(lines);
173
+ };
174
+
175
+ const finish = (value, summary) => {
176
+ stop();
177
+ if (cursor.enabled) {
178
+ output.write(`${cursor.up(height + screenRows(header))}\r${cursor.clearLine}${summary}\n${cursor.clearDown}`);
108
179
  }
109
- choices.forEach((choice, i) => {
110
- const label = typeof choice === 'string' ? choice : choice.label;
111
- const prefix = i === selected ? color.cyan(` ${sym.cursor} `) : ' ';
112
- output.write(`${cursor.clearLine}${prefix}${label}\n`);
113
- });
114
- rendered = true;
180
+ resolve(value);
181
+ };
182
+
183
+ const pick = () => {
184
+ const item = items[selected];
185
+ const summary = done ? done(item.value) : `${message} ${color.dim(sym.dots)} ${color.cyan(item.label.trim())}`;
186
+ finish(item.value, `${color.green(sym.stepDone)} ${summary}`);
115
187
  };
116
188
 
117
189
  if (!process.stdin.isTTY) {
118
- // Non-interactive: pick first choice without rendering menu
119
- resolve(typeof choices[selected] === 'string' ? choices[selected] : choices[selected].value);
190
+ // Non-interactive: pick the initial choice without rendering the menu
191
+ resolve(items[selected].value);
120
192
  return;
121
193
  }
122
194
 
123
- output.write(`${color.cyan('?')} ${message}\n`);
195
+ for (const line of header) output.write(`${line}\n`);
124
196
  render();
125
197
 
126
198
  const stop = readKeys((character, key) => {
127
199
  if (key.name === 'up') {
128
- selected = (selected - 1 + choices.length) % choices.length;
200
+ selected = (selected - 1 + items.length) % items.length;
129
201
  render();
130
202
  } else if (key.name === 'down') {
131
- selected = (selected + 1) % choices.length;
203
+ selected = (selected + 1) % items.length;
132
204
  render();
205
+ } else if (numbered && /^[1-9]$/.test(character ?? '') && Number(character) <= items.length) {
206
+ selected = Number(character) - 1;
207
+ pick();
133
208
  } else if (key.name === 'return' || key.name === 'enter') {
134
- stop();
135
- const choice = choices[selected];
136
- const value = typeof choice === 'string' ? choice : choice.value;
137
- if (cursor.enabled) {
138
- output.write(`${cursor.up(choices.length + 1)}\r${cursor.clearLine}${color.green(sym.check)} ${message} ${color.dim(sym.dots)} ${color.cyan(value)}\n${cursor.clearDown}`);
139
- }
140
- resolve(value);
141
- } else if (key.ctrl && key.name === 'c') {
142
- stop();
143
- output.write('\n');
144
- resolve(null);
209
+ pick();
210
+ } else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
211
+ finish(null, `${color.dim(sym.stepDone)} ${message} ${color.dim(t('prompt.cancelled'))}`);
145
212
  }
146
213
  });
147
214
  });
@@ -6,6 +6,7 @@ import {
6
6
  SHARED_DIR, SHAREABLE_ENTRIES, SHAREABLE_FILES, ACCOUNT_SKILL_ENTRIES,
7
7
  } from './constants.mjs';
8
8
  import { readMeta, getActiveProfile, getSharedEntries, setSharedEntries } from './config.mjs';
9
+ import { t } from './i18n.mjs';
9
10
  import {
10
11
  pathExists, createLink, copyEntry, identicalEntries, emptyEntry, linkTarget, canonicalPath,
11
12
  profileDir, listProfiles,
@@ -93,7 +94,7 @@ export function syncShared(names = listProfiles(), options) {
93
94
 
94
95
  function validateEntry(entry) {
95
96
  if (!SHAREABLE_ENTRIES.includes(entry)) {
96
- throw new Error(`"${entry}" cannot be shared. Choose one of: ${SHAREABLE_ENTRIES.join(', ')}`);
97
+ throw new Error(t('share.cannot', { entry, list: SHAREABLE_ENTRIES.join(', ') }));
97
98
  }
98
99
  }
99
100
 
@@ -120,9 +121,9 @@ function seedShared(entry, from) {
120
121
  export function shareEntry(entry, from = getActiveProfile()) {
121
122
  validateEntry(entry);
122
123
  const meta = readMeta();
123
- if (!meta.shareSettings) throw new Error('Sharing needs shared settings. This setup keeps each profile separate.');
124
+ if (!meta.shareSettings) throw new Error(t('share.needsSettings'));
124
125
  const entries = meta.shared ?? [];
125
- if (entries.includes(entry)) throw new Error(`"${entry}" is already shared`);
126
+ if (entries.includes(entry)) throw new Error(t('share.already', { entry }));
126
127
  mkdirSync(SHARED_DIR, { recursive: true });
127
128
  if (!pathExists(join(SHARED_DIR, entry))) seedShared(entry, from);
128
129
  setSharedEntries([...entries, entry]);
@@ -133,7 +134,7 @@ export function shareEntry(entry, from = getActiveProfile()) {
133
134
  export function unshareEntry(entry) {
134
135
  validateEntry(entry);
135
136
  const entries = getSharedEntries();
136
- if (!entries.includes(entry)) throw new Error(`"${entry}" is not shared`);
137
+ if (!entries.includes(entry)) throw new Error(t('share.notShared', { entry }));
137
138
  const sharedPath = join(SHARED_DIR, entry);
138
139
  for (const name of listProfiles()) {
139
140
  if (entry === 'skills') {
package/src/lib/ui.mjs CHANGED
@@ -1,9 +1,13 @@
1
- // ANSI colors, terminal capability detection, and box-drawing helpers.
1
+ // ANSI colors, terminal capability detection, and the left-rail layout helpers.
2
2
  // Single source for any module that writes to stdout/stderr.
3
3
 
4
- export const hasColor = Boolean(process.stdout.isTTY || process.env.FORCE_COLOR)
5
- && !process.env.NO_COLOR
6
- && process.env.TERM !== 'dumb';
4
+ const colorAllowed = !process.env.NO_COLOR && process.env.TERM !== 'dumb';
5
+ export let hasColor = Boolean(process.stdout.isTTY || process.env.FORCE_COLOR) && colorAllowed;
6
+
7
+ // The claude picker draws on stderr while its stdout is captured by the shell.
8
+ export function colorFor(stream) {
9
+ hasColor = Boolean(stream.isTTY || process.env.FORCE_COLOR) && colorAllowed;
10
+ }
7
11
 
8
12
  // On legacy Windows consoles (cmd.exe without UTF-8 codepage), Unicode box
9
13
  // characters may not render. Detect via the WT_SESSION env var (set by
@@ -15,17 +19,16 @@ export const supportsUnicode = Boolean(
15
19
  );
16
20
 
17
21
  const ESC = '\x1b[';
18
- const RESET = hasColor ? `${ESC}0m` : '';
19
22
  const c = (code) => hasColor ? `${ESC}${code}m` : '';
20
23
 
21
24
  export const color = {
22
- bold: (s) => `${c('1')}${s}${RESET}`,
23
- dim: (s) => `${c('2')}${s}${RESET}`,
24
- green: (s) => `${c('32')}${s}${RESET}`,
25
- yellow: (s) => `${c('33')}${s}${RESET}`,
26
- blue: (s) => `${c('34')}${s}${RESET}`,
27
- cyan: (s) => `${c('36')}${s}${RESET}`,
28
- red: (s) => `${c('31')}${s}${RESET}`,
25
+ bold: (s) => `${c('1')}${s}${c('0')}`,
26
+ dim: (s) => `${c('2')}${s}${c('0')}`,
27
+ green: (s) => `${c('32')}${s}${c('0')}`,
28
+ yellow: (s) => `${c('33')}${s}${c('0')}`,
29
+ blue: (s) => `${c('34')}${s}${c('0')}`,
30
+ cyan: (s) => `${c('36')}${s}${c('0')}`,
31
+ red: (s) => `${c('31')}${s}${c('0')}`,
29
32
  };
30
33
 
31
34
  export function cursorControl(output = process.stdout) {
@@ -50,24 +53,17 @@ export const sym = {
50
53
  bulletOn: supportsUnicode ? '●' : '*',
51
54
  bulletOff: supportsUnicode ? '○' : 'o',
52
55
  dash: supportsUnicode ? '──' : '--',
56
+ arrows: supportsUnicode ? '↑↓' : 'Up/Down',
57
+ // Left rail that groups one flow: ┌ start, │ body, ◆ question, ◇ answered, └ end.
58
+ railTop: supportsUnicode ? '┌' : '+',
59
+ rail: supportsUnicode ? '│' : '|',
60
+ railEnd: supportsUnicode ? '└' : '`',
61
+ step: supportsUnicode ? '◆' : '*',
62
+ stepDone: supportsUnicode ? '◇' : 'o',
63
+ barOn: supportsUnicode ? '▰' : '#',
64
+ barOff: supportsUnicode ? '▱' : '-',
53
65
  };
54
66
 
55
- const BOX_TL = supportsUnicode ? '╭' : '+';
56
- const BOX_TR = supportsUnicode ? '╮' : '+';
57
- const BOX_BL = supportsUnicode ? '╰' : '+';
58
- const BOX_BR = supportsUnicode ? '╯' : '+';
59
- const BOX_H = supportsUnicode ? '─' : '-';
60
- const BOX_V = supportsUnicode ? '│' : '|';
61
-
62
- export function box(lines) {
63
- const maxLen = Math.max(...lines.map(stripAnsi).map(l => l.length));
64
- const pad = (s) => s + ' '.repeat(maxLen - stripAnsi(s).length);
65
- const top = ` ${BOX_TL}${BOX_H.repeat(maxLen + 2)}${BOX_TR}`;
66
- const bottom = ` ${BOX_BL}${BOX_H.repeat(maxLen + 2)}${BOX_BR}`;
67
- const body = lines.map(l => ` ${BOX_V} ${pad(l)} ${BOX_V}`).join('\n');
68
- return `${top}\n${body}\n${bottom}`;
69
- }
70
-
71
67
  export function success(msg) {
72
68
  process.stdout.write(` ${color.green(sym.check)} ${msg}\n`);
73
69
  }
@@ -76,8 +72,31 @@ export function warn(msg) {
76
72
  process.stdout.write(` ${color.yellow(sym.warn)} ${msg}\n`);
77
73
  }
78
74
 
79
- export function error(msg) {
75
+ // Hints are { label, text } or { label, command } lines telling what to do next.
76
+ export function error(msg, hints = []) {
80
77
  process.stderr.write(` ${color.red(sym.cross)} ${msg}\n`);
78
+ for (const hint of hints) {
79
+ process.stderr.write(` ${color.dim(hint.label)} ${hint.command ? color.cyan(hint.command) : hint.text}\n`);
80
+ }
81
+ }
82
+
83
+ export function userError(message, hints = []) {
84
+ return Object.assign(new Error(message), { hints });
85
+ }
86
+
87
+ export function intro(title, note = '', output = process.stdout) {
88
+ output.write(`${color.dim(sym.railTop)} ${color.bold(title)}${note ? ` ${color.dim(note)}` : ''}\n${color.dim(sym.rail)}\n`);
89
+ }
90
+
91
+ export function outro(message, output = process.stdout) {
92
+ output.write(`${color.dim(sym.railEnd)} ${message}\n`);
93
+ }
94
+
95
+ // The number is always printed so the bar never relies on color alone.
96
+ export function meter(percent, width = 10) {
97
+ const filled = Math.round(percent / 100 * width);
98
+ const paint = percent >= 80 ? color.red : percent >= 50 ? color.yellow : color.green;
99
+ return `${paint(sym.barOn.repeat(filled))}${color.dim(sym.barOff.repeat(width - filled))} ${String(percent).padStart(3)}%`;
81
100
  }
82
101
 
83
102
  export function info(msg) {
@@ -87,3 +106,9 @@ export function info(msg) {
87
106
  export function stripAnsi(s) {
88
107
  return s.replace(/\x1b\[[0-9;]*m/g, '');
89
108
  }
109
+
110
+ // Hangul and other wide characters take two terminal columns.
111
+ export function displayWidth(text) {
112
+ return [...stripAnsi(text)].reduce((width, char) => width
113
+ + (/[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\uff00-\uff60]/.test(char) ? 2 : 1), 0);
114
+ }