@krtclcdy/tersio 2.2.0 → 2.4.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.
Files changed (3) hide show
  1. package/README.md +4 -11
  2. package/package.json +5 -2
  3. package/tersio.js +174 -66
package/README.md CHANGED
@@ -11,13 +11,13 @@ Terse replies, compact shell output, and minimal code decisions for [Oh My Pi (O
11
11
 
12
12
  ## Install
13
13
 
14
- Quick install (macOS/Linux/WSL — checks npm, then bootstraps the CLI):
14
+ **Default** (macOS/Linux/WSL) — one line. Installs the CLI via npm, then runs the main installer (scope + Combo preset menus) in the same pass:
15
15
 
16
16
  ```bash
17
- curl -fsSL https://raw.githubusercontent.com/KurutoDenzeru/tersio/main/install.sh | sh
17
+ curl -fsSL https://github.com/KurutoDenzeru/tersio/releases/latest/download/install.sh | sh
18
18
  ```
19
19
 
20
- OMP plugin (recommended — updates and feature flags handled by OMP):
20
+ Prefer OMP-managed updates and feature flags? Install as an OMP plugin instead:
21
21
 
22
22
  ```bash
23
23
  omp plugin install @krtclcdy/tersio
@@ -30,13 +30,6 @@ omp plugin install '@krtclcdy/tersio[caveman,ponytail]'
30
30
  omp plugin features @krtclcdy/tersio --disable rtk
31
31
  ```
32
32
 
33
- npm CLI (installs add-ons into your OMP home):
34
-
35
- ```bash
36
- npm install -g @krtclcdy/tersio@latest
37
- tersio install
38
- ```
39
-
40
33
  Then restart OMP and enable a preset:
41
34
 
42
35
  ```text
@@ -167,7 +160,7 @@ The installer writes `<file>.bak` before replacing an extension source; the upda
167
160
  ## Requirements
168
161
 
169
162
  - [OMP](https://github.com/can1357/oh-my-pi)
170
- - Node.js 18+ with npm
163
+ - Node.js 20.12+ with npm
171
164
 
172
165
  Windows/WSL have separate OMP homes — install from the environment where OMP runs. Inside WSL, `command -v npm` must resolve to a Linux path, not `/mnt/c/`.
173
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krtclcdy/tersio",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "✂️ Token-saving OMP add-ons: terse Caveman replies, compact RTK shell output, lean Ponytail code calls — one-command combo presets.",
5
5
  "keywords": [
6
6
  "omp",
@@ -119,11 +119,14 @@
119
119
  "test": "tsx --test test/*.test.ts test/*/*.test.ts"
120
120
  },
121
121
  "engines": {
122
- "node": ">=18"
122
+ "node": ">=20.12.0"
123
123
  },
124
124
  "devDependencies": {
125
125
  "@types/node": "^26.4.1",
126
126
  "tsx": "^4.23.13",
127
127
  "typescript": "^7.0.2"
128
+ },
129
+ "dependencies": {
130
+ "@clack/prompts": "^1.8.0"
128
131
  }
129
132
  }
package/tersio.js CHANGED
@@ -10,6 +10,7 @@ import readline from 'node:readline';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { promisify } from 'node:util';
12
12
  import { createRequire } from 'node:module';
13
+ import { cancel as clackCancel, confirm as clackConfirm, select as clackSelect, spinner as clackSpinner, tasks as clackTasks } from '@clack/prompts';
13
14
  import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, sha256File, parseChecksum, readTextIfExists, rtkPlatformSpec, } from './extensions/lib/utils.js';
14
15
  const IS_WINDOWS = process.platform === 'win32';
15
16
  const RTK_BINARY_NAME = IS_WINDOWS ? 'rtk.exe' : 'rtk';
@@ -112,7 +113,18 @@ function debug(...a) {
112
113
  const RL = readline.createInterface({ input: process.stdin, output: process.stdout });
113
114
  let rlOpen = true;
114
115
  function ask(q) {
115
- return new Promise((resolve) => RL.question(q, resolve));
116
+ return new Promise((resolve) => {
117
+ if (!rlOpen) {
118
+ resolve('');
119
+ return;
120
+ }
121
+ try {
122
+ RL.question(q, resolve);
123
+ }
124
+ catch {
125
+ resolve('');
126
+ }
127
+ });
116
128
  }
117
129
  function closeRL() { if (rlOpen) {
118
130
  RL.close();
@@ -139,6 +151,9 @@ async function execP(cmd, args, opts = {}) {
139
151
  ...opts,
140
152
  });
141
153
  }
154
+ async function execNetwork(label, cmd, args, opts = {}) {
155
+ return withInteractiveSpinner(label, () => execP(cmd, args, opts));
156
+ }
142
157
  async function writeIfChanged(dest, content, options = {}) {
143
158
  const existing = await readTextIfExists(dest);
144
159
  if (existing === content) {
@@ -321,7 +336,7 @@ async function stepPonytail(pluginsDir, userDir, options) {
321
336
  else {
322
337
  // Try omp plugin install first
323
338
  try {
324
- await execP(OMP_BIN, ['plugin', 'install', PONYTAIL_GITHUB_SPEC], { cwd: pluginsDir });
339
+ await execNetwork('Installing Ponytail plugin', OMP_BIN, ['plugin', 'install', PONYTAIL_GITHUB_SPEC], { cwd: pluginsDir });
325
340
  console.log(' [ok] omp plugin install ran');
326
341
  }
327
342
  catch (e) {
@@ -329,7 +344,7 @@ async function stepPonytail(pluginsDir, userDir, options) {
329
344
  }
330
345
  if (options.reinstall) {
331
346
  try {
332
- await execP('npm', ['install', PONYTAIL_NPM_SPEC, '--save', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 120000 });
347
+ await execNetwork('Refreshing Ponytail package', 'npm', ['install', PONYTAIL_NPM_SPEC, '--save', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 120000 });
333
348
  console.log(' [ok] Ponytail refreshed');
334
349
  }
335
350
  catch (e) {
@@ -343,12 +358,12 @@ async function stepPonytail(pluginsDir, userDir, options) {
343
358
  if (!ponytailExtExists) {
344
359
  console.log(' [info] pi-extension/index.js not found after omp plugin install — trying npm/bun install...');
345
360
  try {
346
- await execP('npm', ['install'], { cwd: pluginsDir, timeout: 120000 });
361
+ await execNetwork('Installing Ponytail dependencies', 'npm', ['install'], { cwd: pluginsDir, timeout: 120000 });
347
362
  console.log(' [ok] npm install completed');
348
363
  }
349
364
  catch {
350
365
  try {
351
- await execP('bun', ['install'], { cwd: pluginsDir, timeout: 120000 });
366
+ await execNetwork('Installing Ponytail dependencies', 'bun', ['install'], { cwd: pluginsDir, timeout: 120000 });
352
367
  console.log(' [ok] bun install completed');
353
368
  }
354
369
  catch (e2) {
@@ -364,7 +379,7 @@ async function stepPonytail(pluginsDir, userDir, options) {
364
379
  try {
365
380
  const dest = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail');
366
381
  await fs.mkdir(path.dirname(dest), { recursive: true });
367
- await execP('git', ['clone', '--depth', '1', 'https://github.com/DietrichGebert/ponytail.git', dest], { timeout: 180000 });
382
+ await execNetwork('Cloning Ponytail repository', 'git', ['clone', '--depth', '1', 'https://github.com/DietrichGebert/ponytail.git', dest], { timeout: 180000 });
368
383
  console.log(' [ok] git clone completed');
369
384
  ponytailExtExists = await probeExt();
370
385
  }
@@ -421,11 +436,11 @@ async function stepSelfPlugin(pluginsDir, options) {
421
436
  await fs.mkdir(pluginsDir, { recursive: true });
422
437
  await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
423
438
  try {
424
- await execP('npm', ['install', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 180000 });
439
+ await execNetwork('Installing Tersio plugin dependencies', 'npm', ['install', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 180000 });
425
440
  }
426
441
  catch {
427
442
  try {
428
- await execP('bun', ['install'], { cwd: pluginsDir, timeout: 180000 });
443
+ await execNetwork('Installing Tersio plugin dependencies', 'bun', ['install'], { cwd: pluginsDir, timeout: 180000 });
429
444
  }
430
445
  catch (e) {
431
446
  console.log(` [fail] Could not install ${PACKAGE_NAME} into plugins dir: ${e.message}`);
@@ -524,7 +539,7 @@ async function extractRtkArchive(archivePath, extractDir) {
524
539
  async function stepRtk(binDir, options) {
525
540
  console.log('\n[3/7] Installing RTK binary...');
526
541
  try {
527
- const release = await fetchJson(RTK_RELEASE_API);
542
+ const release = await withInteractiveSpinner('Finding latest RTK release', () => fetchJson(RTK_RELEASE_API));
528
543
  const triple = resolveRtkTriple();
529
544
  if (!triple)
530
545
  return;
@@ -541,10 +556,14 @@ async function stepRtk(binDir, options) {
541
556
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'omp-rtk-'));
542
557
  try {
543
558
  const archivePath = path.join(tmpDir, asset.name);
544
- const [checksumsText] = await Promise.all([
545
- downloadRtkChecksums(release),
546
- httpsDownload(asset.browser_download_url, archivePath),
547
- ]);
559
+ const checksumsText = await withInteractiveSpinner('Downloading RTK binary', async (update) => {
560
+ const [downloadedChecksums] = await Promise.all([
561
+ downloadRtkChecksums(release),
562
+ httpsDownload(asset.browser_download_url, archivePath),
563
+ ]);
564
+ update('Verifying RTK checksum');
565
+ return downloadedChecksums;
566
+ });
548
567
  if (!await verifyRtkArchive(archivePath, asset.name, checksumsText))
549
568
  return;
550
569
  const extractDir = path.join(tmpDir, 'extracted');
@@ -623,7 +642,7 @@ async function fetchCavemanRule(options) {
623
642
  if (options.dryRun)
624
643
  return (await readTextIfExists(path.join(path.dirname(CAVEMAN_INDEX), 'rule.md'))) || '';
625
644
  try {
626
- return await httpsGet(CAVEMAN_REMOTE_RULE);
645
+ return await withInteractiveSpinner('Fetching Caveman rule', () => httpsGet(CAVEMAN_REMOTE_RULE));
627
646
  }
628
647
  catch (e) {
629
648
  console.log(` [warn] Could not fetch caveman rule: ${e.message}`);
@@ -679,29 +698,61 @@ async function runDoctor() {
679
698
  const comboIndex = path.join(extDir, 'combo-toggle', 'index.js');
680
699
  const modeReinforcement = path.join(extDir, 'shared', 'mode-reinforcement.js');
681
700
  const selfPkg = path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'package.json');
682
- // Independent probes run concurrently; logs below keep fixed order.
683
- const [ompVersion, agentEntries, extEntries, sharedStateText, configText, ponytailPkgText, ponytailExtText, pluginsPkgRaw, selfPkgText, rtkBinText, cavemanIndexText, cavemanRuleText, rtkIndexText, updaterIndexText, comboIndexText, modeReinforcementText, ruleMtime, rtkMtime, ponytailMtime, updateVersion,] = await Promise.all([
684
- execP(OMP_BIN, ['--version']).then((r) => r.stdout.trim(), () => null),
685
- fs.readdir(agentDir).catch(() => null),
686
- fs.readdir(extDir).catch(() => null),
687
- readTextIfExists(path.join(extDir, 'shared', 'session-state.js')),
688
- readTextIfExists(configPath),
689
- readTextIfExists(ponytailPkg),
690
- readTextIfExists(ponytailExt),
691
- readTextIfExists(path.join(pluginsDir, 'package.json')),
692
- readTextIfExists(selfPkg),
693
- readTextIfExists(rtkBin),
694
- readTextIfExists(cavemanIndex),
695
- readTextIfExists(cavemanRule),
696
- readTextIfExists(rtkIndex),
697
- readTextIfExists(updaterIndex),
698
- readTextIfExists(comboIndex),
699
- readTextIfExists(modeReinforcement),
700
- fs.stat(cavemanRule).catch(() => null),
701
- fs.stat(rtkBin).catch(() => null),
702
- fs.stat(ponytailPkg).catch(() => null),
703
- checkForUpdate(),
704
- ]);
701
+ // Independent probes start concurrently; sections report in fixed order as
702
+ // their data settles. Every probe resolves instead of rejecting.
703
+ const probes = {
704
+ ompVersion: execP(OMP_BIN, ['--version']).then((r) => r.stdout.trim(), () => null),
705
+ agentEntries: fs.readdir(agentDir).catch(() => null),
706
+ extEntries: fs.readdir(extDir).catch(() => null),
707
+ sharedStateText: readTextIfExists(path.join(extDir, 'shared', 'session-state.js')),
708
+ configText: readTextIfExists(configPath),
709
+ ponytailPkgText: readTextIfExists(ponytailPkg),
710
+ ponytailExtText: readTextIfExists(ponytailExt),
711
+ pluginsPkgRaw: readTextIfExists(path.join(pluginsDir, 'package.json')),
712
+ selfPkgText: readTextIfExists(selfPkg),
713
+ rtkBinText: readTextIfExists(rtkBin),
714
+ cavemanIndexText: readTextIfExists(cavemanIndex),
715
+ cavemanRuleText: readTextIfExists(cavemanRule),
716
+ rtkIndexText: readTextIfExists(rtkIndex),
717
+ updaterIndexText: readTextIfExists(updaterIndex),
718
+ comboIndexText: readTextIfExists(comboIndex),
719
+ modeReinforcementText: readTextIfExists(modeReinforcement),
720
+ ruleMtime: fs.stat(cavemanRule).catch(() => null),
721
+ rtkMtime: fs.stat(rtkBin).catch(() => null),
722
+ ponytailMtime: fs.stat(ponytailPkg).catch(() => null),
723
+ updateVersion: checkForUpdate(),
724
+ };
725
+ const rtkVersionProbe = probes.rtkBinText.then((text) => text === null ? null : execP(rtkBin, ['--version'], { timeout: 5000 }).then((r) => r.stdout.trim() || r.stderr.trim() || null, (e) => {
726
+ const err = e;
727
+ return err.stdout?.trim() || err.stderr?.trim() || null;
728
+ }));
729
+ const [ompVersion, agentEntries, extEntries, sharedStateText, configText, updateVersion] = await runInteractivePhase('Checking environment and installation', () => Promise.all([
730
+ probes.ompVersion,
731
+ probes.agentEntries,
732
+ probes.extEntries,
733
+ probes.sharedStateText,
734
+ probes.configText,
735
+ probes.updateVersion,
736
+ ]));
737
+ const [cavemanIndexText, rtkIndexText, updaterIndexText, comboIndexText, modeReinforcementText, ponytailPkgText, ponytailExtText, pluginsPkgRaw, selfPkgText] = await runInteractivePhase('Checking extensions and plugins', () => Promise.all([
738
+ probes.cavemanIndexText,
739
+ probes.rtkIndexText,
740
+ probes.updaterIndexText,
741
+ probes.comboIndexText,
742
+ probes.modeReinforcementText,
743
+ probes.ponytailPkgText,
744
+ probes.ponytailExtText,
745
+ probes.pluginsPkgRaw,
746
+ probes.selfPkgText,
747
+ ]));
748
+ const [cavemanRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, ponytailMtime] = await runInteractivePhase('Checking add-ons', () => Promise.all([
749
+ probes.cavemanRuleText,
750
+ probes.ruleMtime,
751
+ probes.rtkBinText,
752
+ probes.rtkMtime,
753
+ rtkVersionProbe,
754
+ probes.ponytailMtime,
755
+ ]));
705
756
  // Categorized output with a tally; section headers group related probes.
706
757
  const tally = { ok: 0, missing: 0, warn: 0 };
707
758
  function check(label, ok, detail = '') {
@@ -750,10 +801,6 @@ async function runDoctor() {
750
801
  section('Add-ons');
751
802
  const ruleAge = ruleMtime ? `updated ${relTime(Date.now() - ruleMtime.mtimeMs)}` : '';
752
803
  check('Caveman rule', cavemanRuleText !== null, ruleAge);
753
- const rtkVersion = rtkBinText === null ? null : await execP(rtkBin, ['--version'], { timeout: 5000 }).then((r) => r.stdout.trim() || r.stderr.trim() || null, (e) => {
754
- const err = e;
755
- return err.stdout?.trim() || err.stderr?.trim() || null;
756
- });
757
804
  const rtkAge = rtkMtime ? `updated ${relTime(Date.now() - rtkMtime.mtimeMs)}` : '';
758
805
  if (rtkBinText === null)
759
806
  check('RTK binary', false, rtkBin);
@@ -855,11 +902,27 @@ async function runUninstall(options = {}) {
855
902
  console.log(` ${rtkBin}`);
856
903
  }
857
904
  if (!confirmed) {
858
- const answer = await ask('\nProceed? [y/N]: ');
859
- if (!answer.toLowerCase().startsWith('y')) {
860
- console.log('Aborted.');
905
+ if (tty()) {
861
906
  closeRL();
862
- return false;
907
+ const confirmedChoice = await clackConfirm({ message: 'Remove the listed Tersio files?', initialValue: false });
908
+ if (typeof confirmedChoice !== 'boolean') {
909
+ clackCancel('Aborted.');
910
+ closeRL();
911
+ return false;
912
+ }
913
+ if (!confirmedChoice) {
914
+ console.log('Aborted.');
915
+ closeRL();
916
+ return false;
917
+ }
918
+ }
919
+ else {
920
+ const answer = await ask('\nProceed? [y/N]: ');
921
+ if (!answer.toLowerCase().startsWith('y')) {
922
+ console.log('Aborted.');
923
+ closeRL();
924
+ return false;
925
+ }
863
926
  }
864
927
  }
865
928
  // Remove extension directories (independent paths, so concurrently).
@@ -918,7 +981,6 @@ async function runUninstall(options = {}) {
918
981
  return true;
919
982
  }
920
983
  const SCOPE_MAP = { user: '1', project: '2', both: '3' };
921
- const COMBO_MENU = { '1': 'off', '2': 'medium', '3': 'balanced', '4': 'max' };
922
984
  // --- Update check (cached; used by the bare-invocation banner and doctor) ---
923
985
  const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
924
986
  function newerThan(a, b) {
@@ -935,7 +997,7 @@ async function latestPublishedVersion() {
935
997
  const args = IS_WINDOWS
936
998
  ? ['/d', '/s', '/c', 'npm', 'view', PACKAGE_NAME, 'version', '--prefer-online']
937
999
  : ['view', PACKAGE_NAME, 'version', '--prefer-online'];
938
- const r = await execP(IS_WINDOWS ? process.env.ComSpec || 'cmd.exe' : 'npm', args, {
1000
+ const r = await execNetwork('Checking npm registry for tersio updates', IS_WINDOWS ? process.env.ComSpec || 'cmd.exe' : 'npm', args, {
939
1001
  timeout: 4000,
940
1002
  maxBuffer: 1024 * 1024,
941
1003
  windowsHide: true,
@@ -1013,7 +1075,7 @@ async function runLatestUpdate() {
1013
1075
  else {
1014
1076
  try {
1015
1077
  const globalArgs = IS_WINDOWS ? ['/d', '/s', '/c', 'npm', 'install', '-g', `${PACKAGE_NAME}@latest`, '--no-audit', '--no-fund'] : ['install', '-g', `${PACKAGE_NAME}@latest`, '--no-audit', '--no-fund'];
1016
- const globalResult = await execP(npmCommand, globalArgs, {
1078
+ const globalResult = await execNetwork('Updating global Tersio CLI', npmCommand, globalArgs, {
1017
1079
  timeout: 300000,
1018
1080
  maxBuffer: 10 * 1024 * 1024,
1019
1081
  windowsHide: true,
@@ -1031,7 +1093,7 @@ async function runLatestUpdate() {
1031
1093
  }
1032
1094
  }
1033
1095
  try {
1034
- const result = await execP(npmCommand, npmCommandArgs, {
1096
+ const result = await execNetwork('Running latest Tersio installer', npmCommand, npmCommandArgs, {
1035
1097
  timeout: 300000,
1036
1098
  maxBuffer: 10 * 1024 * 1024,
1037
1099
  windowsHide: true,
@@ -1049,7 +1111,6 @@ async function runLatestUpdate() {
1049
1111
  process.stdout.write(err.stdout);
1050
1112
  if (err.stderr)
1051
1113
  process.stderr.write(err.stderr);
1052
- console.error(`\n[fail] Could not run ${PACKAGE_NAME}@latest: ${err.message}`);
1053
1114
  process.exitCode = 1;
1054
1115
  }
1055
1116
  }
@@ -1065,6 +1126,47 @@ function defaultProfile() {
1065
1126
  function tty() {
1066
1127
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
1067
1128
  }
1129
+ let spinnerDepth = 0;
1130
+ // Run async work under a TTY-only timer spinner. The spinner is cleared before
1131
+ // the caller prints its normal result line, preserving locked output shapes.
1132
+ async function withInteractiveSpinner(message, work) {
1133
+ if (!tty() || spinnerDepth > 0)
1134
+ return work(() => { });
1135
+ // Clack manages stdin itself. Close the legacy question interface before its
1136
+ // first use; non-interactive callers never reach this branch.
1137
+ closeRL();
1138
+ const active = clackSpinner({ indicator: 'timer' });
1139
+ active.start(message);
1140
+ spinnerDepth += 1;
1141
+ try {
1142
+ return await work((next) => { active.message(next); });
1143
+ }
1144
+ finally {
1145
+ active.clear();
1146
+ spinnerDepth -= 1;
1147
+ }
1148
+ }
1149
+ async function askInteractiveChoice(message, options, initialValue) {
1150
+ if (!tty())
1151
+ return { status: 'unavailable' };
1152
+ closeRL();
1153
+ const choice = await clackSelect({ message, options, initialValue });
1154
+ if (typeof choice !== 'string') {
1155
+ clackCancel('Aborted.');
1156
+ return { status: 'cancelled' };
1157
+ }
1158
+ return { status: 'selected', value: choice };
1159
+ }
1160
+ // Run collecting work under one TTY-only Clack task. Callers print after the
1161
+ // task completes, keeping normal output out of the spinner animation.
1162
+ async function runInteractivePhase(title, collect) {
1163
+ if (!tty())
1164
+ return collect();
1165
+ closeRL();
1166
+ let result;
1167
+ await clackTasks([{ title, task: async () => { result = await collect(); } }]);
1168
+ return result;
1169
+ }
1068
1170
  // Determine install scope: reinstall > flag > non-interactive default > prompt.
1069
1171
  async function resolveScope() {
1070
1172
  if (reinstall) {
@@ -1085,6 +1187,17 @@ async function resolveScope() {
1085
1187
  console.log(` Scope: user (${install ? 'install default' : '--scope omitted, defaulting to user with --yes'})`);
1086
1188
  return '1';
1087
1189
  }
1190
+ if (tty()) {
1191
+ const choice = await askInteractiveChoice('Install scope', [
1192
+ { value: '1', label: 'User-level', hint: 'all OMP sessions' },
1193
+ { value: '2', label: 'Project-level', hint: 'this repo only' },
1194
+ { value: '3', label: 'Both' },
1195
+ ], '1');
1196
+ if (choice.status === 'selected')
1197
+ return choice.value;
1198
+ closeRL();
1199
+ process.exit(130);
1200
+ }
1088
1201
  console.log('\nInstall scope:');
1089
1202
  console.log(' 1) User-level (all OMP sessions)');
1090
1203
  console.log(' 2) Project-level (this repo only)');
@@ -1103,22 +1216,17 @@ async function resolveProfile() {
1103
1216
  // Only for a real user at a terminal, only when no default flags were
1104
1217
  // given, and never for --apply-update runs.
1105
1218
  if (tty() && !profileFlagsGiven && !applyUpdate && (install || reinstall)) {
1106
- console.log(' Session-start defaults — Combo preset:');
1107
- console.log(' 1) off');
1108
- console.log(' 2) medium (caveman=lite, rtk=on, ponytail=lite)');
1109
- console.log(' 3) balanced (caveman=full, rtk=on, ponytail=full)');
1110
- console.log(' 4) max (caveman=ultra, rtk=on, ponytail=ultra)');
1111
- for (;;) {
1112
- const answer = (await ask(' Choose [1-4] (default 1): ')).trim();
1113
- if (!answer) {
1114
- profile.comboDefault = 'off';
1115
- break;
1116
- }
1117
- if (COMBO_MENU[answer]) {
1118
- profile.comboDefault = COMBO_MENU[answer];
1119
- break;
1120
- }
1121
- console.log(` [fail] Invalid choice: ${answer}. Choose 1, 2, 3, or 4.`);
1219
+ const choice = await askInteractiveChoice('Session-start defaults — Combo preset', [
1220
+ { value: 'off', label: 'off' },
1221
+ { value: 'medium', label: 'medium', hint: 'caveman=lite, rtk=on, ponytail=lite' },
1222
+ { value: 'balanced', label: 'balanced', hint: 'caveman=full, rtk=on, ponytail=full' },
1223
+ { value: 'max', label: 'max', hint: 'caveman=ultra, rtk=on, ponytail=ultra' },
1224
+ ], 'off');
1225
+ if (choice.status === 'selected')
1226
+ profile.comboDefault = choice.value;
1227
+ else {
1228
+ closeRL();
1229
+ process.exit(130);
1122
1230
  }
1123
1231
  }
1124
1232
  // Explicit flags always win over the Combo preset.