termux-dev 1.6.0 → 1.8.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 (96) hide show
  1. package/README.md +214 -166
  2. package/assets/banner.svg +23 -28
  3. package/assets/preview.png +0 -0
  4. package/assets/preview.svg +92 -10
  5. package/dist/cli/banner.js +1 -1
  6. package/dist/cli/clipboard-write.js +55 -0
  7. package/dist/cli/clipboard.js +182 -106
  8. package/dist/cli/colors.js +67 -0
  9. package/dist/cli/confirm.js +50 -0
  10. package/dist/cli/doctor.js +4 -4
  11. package/dist/cli/effort.js +80 -0
  12. package/dist/cli/export.js +3 -3
  13. package/dist/cli/files.js +19 -5
  14. package/dist/cli/headless.js +23 -11
  15. package/dist/cli/index.js +1377 -480
  16. package/dist/cli/keys.js +69 -0
  17. package/dist/cli/liveblock.js +45 -0
  18. package/dist/cli/log.js +19 -0
  19. package/dist/cli/markdown.js +158 -36
  20. package/dist/cli/mcp-panel.js +432 -0
  21. package/dist/cli/permission.js +84 -0
  22. package/dist/cli/picker.js +135 -0
  23. package/dist/cli/prompt.js +256 -56
  24. package/dist/cli/resume.js +15 -0
  25. package/dist/cli/rewind.js +51 -0
  26. package/dist/cli/runtime_errors.js +6 -0
  27. package/dist/cli/server.js +85 -24
  28. package/dist/cli/settings.js +307 -0
  29. package/dist/cli/statusline.js +1 -1
  30. package/dist/cli/theme.js +126 -108
  31. package/dist/cli/transcript.js +114 -0
  32. package/dist/cli/typeahead.js +371 -0
  33. package/dist/cli/ui.js +1021 -0
  34. package/dist/cli/updater.js +23 -21
  35. package/dist/cli/usage-panel.js +242 -0
  36. package/dist/cli/width.js +178 -0
  37. package/dist/core/capabilities.js +433 -0
  38. package/dist/core/catalog-util.js +27 -0
  39. package/dist/core/checkpoint.js +90 -0
  40. package/dist/core/context-stats.js +29 -0
  41. package/dist/core/cost.js +191 -0
  42. package/dist/core/formats.js +199 -0
  43. package/dist/core/history.js +51 -1
  44. package/dist/core/hooks.js +79 -0
  45. package/dist/core/i18n.js +42 -0
  46. package/dist/core/local-config.js +69 -0
  47. package/dist/core/locales/de.js +210 -0
  48. package/dist/core/locales/en.js +221 -0
  49. package/dist/core/locales/es.js +210 -0
  50. package/dist/core/locales/fr.js +210 -0
  51. package/dist/core/locales/ja.js +210 -0
  52. package/dist/core/locales/pt.js +210 -0
  53. package/dist/core/locales/ru.js +210 -0
  54. package/dist/core/locales/uk.js +210 -0
  55. package/dist/core/locales/zh.js +210 -0
  56. package/dist/core/loop.js +125 -49
  57. package/dist/core/memory.js +39 -12
  58. package/dist/core/models.js +33 -20
  59. package/dist/core/network.js +12 -11
  60. package/dist/core/notify.js +54 -0
  61. package/dist/core/planfile.js +43 -0
  62. package/dist/core/price-catalog.json +1 -0
  63. package/dist/core/prices.js +249 -0
  64. package/dist/core/reasoning-catalog.json +1 -0
  65. package/dist/core/reasoning.js +393 -0
  66. package/dist/core/redact.js +41 -0
  67. package/dist/core/secure-file.js +22 -0
  68. package/dist/core/session.js +16 -5
  69. package/dist/core/snapshot.js +14 -2
  70. package/dist/core/title.js +61 -0
  71. package/dist/core/types.js +1 -1
  72. package/dist/core/usage.js +43 -7
  73. package/dist/core/vision-catalog.json +1 -0
  74. package/dist/core/vision.js +123 -0
  75. package/dist/mcp/client.js +36 -5
  76. package/dist/mcp/http-client.js +174 -0
  77. package/dist/mcp/manager.js +330 -93
  78. package/dist/permissions/guard.js +385 -117
  79. package/dist/prompts/builder.js +46 -38
  80. package/dist/providers/anthropic.js +517 -0
  81. package/dist/providers/index.js +18 -1
  82. package/dist/providers/openai.js +138 -125
  83. package/dist/providers/shared.js +116 -0
  84. package/dist/providers/toolguard.js +101 -0
  85. package/dist/tools/bash.js +141 -51
  86. package/dist/tools/diagnostics.js +76 -48
  87. package/dist/tools/fs.js +77 -5
  88. package/dist/tools/index.js +2 -1
  89. package/dist/tools/mcp.js +72 -0
  90. package/dist/tools/packages.js +16 -1
  91. package/dist/tools/plan.js +8 -8
  92. package/dist/tools/questions.js +50 -54
  93. package/dist/tools/server.js +6 -0
  94. package/dist/tools/todo.js +20 -18
  95. package/dist/tools/web.js +143 -8
  96. package/package.json +1 -1
@@ -37,20 +37,20 @@ export async function getCurrentVersion() {
37
37
  }
38
38
  export async function checkForUpdates(timeoutMs = 10000) {
39
39
  const currentVersion = await getCurrentVersion();
40
- const controller = new AbortController();
41
- const timer = setTimeout(() => controller.abort(), timeoutMs);
40
+ // One deadline for the whole check. The signal is passed to EVERY request: on a flaky
41
+ // mobile connection a stalled second request used to block devx at startup forever.
42
+ const signal = AbortSignal.timeout(timeoutMs);
42
43
  try {
43
44
  // 1. Try fetching latest package.json with cache-busting timestamp
44
45
  const cacheBusterUrl = `${REMOTE_PKG_URL}?t=${Date.now()}`;
45
46
  const res = await fetch(cacheBusterUrl, {
46
- signal: controller.signal,
47
+ signal,
47
48
  headers: {
48
49
  'User-Agent': 'devx-updater',
49
50
  'Cache-Control': 'no-cache, no-store, must-revalidate',
50
51
  'Pragma': 'no-cache'
51
52
  }
52
53
  });
53
- clearTimeout(timer);
54
54
  let latestVersion = '';
55
55
  let releaseNotes = '';
56
56
  if (res.ok) {
@@ -60,6 +60,7 @@ export async function checkForUpdates(timeoutMs = 10000) {
60
60
  // 2. Also check latest GitHub release tag
61
61
  try {
62
62
  const relRes = await fetch(`${REMOTE_RELEASE_URL}?t=${Date.now()}`, {
63
+ signal,
63
64
  headers: { 'User-Agent': 'devx-updater' }
64
65
  });
65
66
  if (relRes.ok) {
@@ -87,12 +88,12 @@ export async function checkForUpdates(timeoutMs = 10000) {
87
88
  };
88
89
  }
89
90
  catch (err) {
90
- clearTimeout(timer);
91
+ const timedOut = err.name === 'AbortError' || err.name === 'TimeoutError';
91
92
  return {
92
93
  updateAvailable: false,
93
94
  currentVersion,
94
95
  latestVersion: currentVersion,
95
- error: err.name === 'AbortError' ? 'Timeout (10s)' : err.message
96
+ error: timedOut ? `Timeout (${Math.round(timeoutMs / 1000)}s)` : err.message
96
97
  };
97
98
  }
98
99
  }
@@ -109,10 +110,10 @@ export async function performSelfUpdate(latestVersion) {
109
110
  const __filename = fileURLToPath(import.meta.url);
110
111
  const projectRoot = path.resolve(path.dirname(__filename), '../../');
111
112
  const isGit = await isGitRepository(projectRoot);
112
- console.log('\n' + pc.bold(pc.cyan('─── 🚀 Starting devx Update ──────────────────────────')));
113
+ console.log('\n' + pc.bold(pc.cyan('─── Starting devx Update ──────────────────────────')));
113
114
  if (!isGit) {
114
115
  // 1. Installed via global npm package: termux-dev
115
- process.stdout.write(` ${pc.cyan('⚡')} [1/1] ${pc.white(`Updating termux-dev via npm (v${latestVersion})...`)}\n`);
116
+ process.stdout.write(` ${pc.cyan('›')} [1/1] ${pc.white(`Updating termux-dev via npm (v${latestVersion})...`)}\n`);
116
117
  const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
117
118
  let fIdx = 0;
118
119
  const interval = setInterval(() => {
@@ -121,12 +122,12 @@ export async function performSelfUpdate(latestVersion) {
121
122
  try {
122
123
  execSync('npm install -g termux-dev@latest --prefer-online', { stdio: 'pipe' });
123
124
  clearInterval(interval);
124
- process.stdout.write(`\r ${pc.green('✔')} ${pc.bold(pc.white(`Successfully updated termux-dev to v${latestVersion} via npm!`))}\n`);
125
+ process.stdout.write(`\r ${pc.green('✓')} ${pc.bold(pc.white(`Successfully updated termux-dev to v${latestVersion} via npm!`))}\n`);
125
126
  }
126
127
  catch (err) {
127
128
  clearInterval(interval);
128
129
  process.stdout.write(`\r ${pc.red('✖')} ${pc.red(`Update failed: ${err.message}`)}\n`);
129
- console.log(pc.yellow(`\n💡 To update manually, run: ${pc.bold('npm install -g termux-dev@latest')}`));
130
+ console.log(pc.yellow(`\nTo update manually, run: ${pc.bold('npm install -g termux-dev@latest')}`));
130
131
  return false;
131
132
  }
132
133
  }
@@ -134,20 +135,20 @@ export async function performSelfUpdate(latestVersion) {
134
135
  // 2. Installed via git clone (Developer mode)
135
136
  const steps = [
136
137
  {
137
- title: '📦 Pulling latest updates from GitHub repository...',
138
+ title: 'Pulling latest updates from GitHub repository...',
138
139
  action: () => {
139
140
  execSync('git pull origin main', { cwd: projectRoot, stdio: 'pipe' });
140
141
  }
141
142
  },
142
143
  {
143
- title: '🔨 Building and compiling TypeScript sources...',
144
+ title: 'Building and compiling TypeScript sources...',
144
145
  action: () => {
145
146
  execSync('npm install', { cwd: projectRoot, stdio: 'pipe' });
146
147
  execSync('npm run build', { cwd: projectRoot, stdio: 'pipe' });
147
148
  }
148
149
  },
149
150
  {
150
- title: '🔑 Ensuring binary execution permissions & linking...',
151
+ title: 'Ensuring binary execution permissions & linking...',
151
152
  action: () => {
152
153
  try {
153
154
  execSync('npm link', { cwd: projectRoot, stdio: 'pipe' });
@@ -158,7 +159,7 @@ export async function performSelfUpdate(latestVersion) {
158
159
  ];
159
160
  for (let i = 0; i < steps.length; i++) {
160
161
  const step = steps[i];
161
- process.stdout.write(` ${pc.cyan('⚡')} [${i + 1}/${steps.length}] ${pc.white(step.title)}\n`);
162
+ process.stdout.write(` ${pc.cyan('›')} [${i + 1}/${steps.length}] ${pc.white(step.title)}\n`);
162
163
  const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
163
164
  let fIdx = 0;
164
165
  const interval = setInterval(() => {
@@ -168,18 +169,18 @@ export async function performSelfUpdate(latestVersion) {
168
169
  await new Promise(resolve => setTimeout(resolve, 200));
169
170
  step.action();
170
171
  clearInterval(interval);
171
- process.stdout.write(`\r ${pc.green('✔')} ${pc.bold(pc.white(step.title))}\n`);
172
+ process.stdout.write(`\r ${pc.green('✓')} ${pc.bold(pc.white(step.title))}\n`);
172
173
  }
173
174
  catch (err) {
174
175
  clearInterval(interval);
175
176
  process.stdout.write(`\r ${pc.red('✖')} ${pc.red(step.title)} - ${err.message}\n`);
176
177
  console.log(pc.red(`\nUpdate failed during step ${i + 1}: ${err.message}`));
177
- console.log(pc.yellow(`\n💡 To update manually, run: ${pc.bold('npm install -g termux-dev')}`));
178
+ console.log(pc.yellow(`\nTo update manually, run: ${pc.bold('npm install -g termux-dev')}`));
178
179
  return false;
179
180
  }
180
181
  }
181
182
  }
182
- console.log(pc.bold(pc.green(`\n✅ Successfully updated to v${latestVersion}! Restarting devx...\n`)));
183
+ console.log(pc.bold(pc.green(`\nSuccessfully updated to v${latestVersion}! Restarting devx...\n`)));
183
184
  console.log(pc.bold(pc.cyan('──────────────────────────────────────────────────────\n')));
184
185
  await new Promise(r => setTimeout(r, 1000));
185
186
  try {
@@ -218,21 +219,22 @@ export async function runStartupUpdateCheck(config) {
218
219
  if (result.updateAvailable) {
219
220
  console.log();
220
221
  console.log(pc.bold(pc.cyan(' ┌───────────────────────────────────────────────────────────┐')));
221
- console.log(pc.bold(pc.cyan(' │')) + pc.bold(pc.yellow(` 🚀 A new update is available: `)) + pc.dim(`v${result.currentVersion}`) + pc.bold(pc.green(` ➔ v${result.latestVersion}`)) + ' '.repeat(Math.max(1, 23 - result.currentVersion.length - result.latestVersion.length)) + pc.bold(pc.cyan('│')));
222
+ console.log(pc.bold(pc.cyan(' │')) + pc.bold(pc.yellow(` A new update is available: `)) + pc.dim(`v${result.currentVersion}`) + pc.bold(pc.green(` → v${result.latestVersion}`)) + ' '.repeat(Math.max(1, 23 - result.currentVersion.length - result.latestVersion.length)) + pc.bold(pc.cyan('│')));
222
223
  console.log(pc.bold(pc.cyan(' │')) + pc.dim(` Repository: https://github.com/apvcode/Termux-Dev `) + pc.bold(pc.cyan('│')));
223
224
  console.log(pc.bold(pc.cyan(' └───────────────────────────────────────────────────────────┘')));
224
225
  console.log();
225
226
  try {
226
227
  const choice = await select({
227
228
  message: pc.bold('Do you want to update devx now?'),
229
+ loop: false,
228
230
  choices: [
229
231
  {
230
- name: `⚡ Update now (Pull v${result.latestVersion}, rebuild & restart)`,
232
+ name: `Update now (Pull v${result.latestVersion}, rebuild & restart)`,
231
233
  value: 'update',
232
234
  description: 'Download latest code, compile TypeScript, and restart devx'
233
235
  },
234
236
  {
235
- name: '⏭ Skip for now (Continue to chat)',
237
+ name: 'Skip for now (Continue to chat)',
236
238
  value: 'skip',
237
239
  description: 'Keep current version and start session'
238
240
  }
@@ -248,7 +250,7 @@ export async function runStartupUpdateCheck(config) {
248
250
  }
249
251
  else {
250
252
  // Briefly show up to date message
251
- process.stdout.write(` ${pc.green('✔')} ${pc.dim(`devx is up to date (v${result.currentVersion})`)}\n`);
253
+ process.stdout.write(` ${pc.green('✓')} ${pc.dim(`devx is up to date (v${result.currentVersion})`)}\n`);
252
254
  await new Promise(r => setTimeout(r, 450));
253
255
  // Clean up line
254
256
  process.stdout.write('\x1b[1A\x1b[2K');
@@ -0,0 +1,242 @@
1
+ import { LiveBlock } from './liveblock.js';
2
+ import { splitKeys } from './keys.js';
3
+ import { palette, formatTokens } from './ui.js';
4
+ import { displayWidth, padEndWidth, truncateWidth } from './width.js';
5
+ import { formatMoney } from '../core/cost.js';
6
+ const fmtTokens = (n) => (n < 1000 ? `${Math.round(n)}` : formatTokens(n));
7
+ function formatBytes(bytes) {
8
+ if (bytes < 1024)
9
+ return `${bytes} B`;
10
+ if (bytes < 1024 * 1024)
11
+ return `${(bytes / 1024).toFixed(1)} KB`;
12
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
13
+ }
14
+ const pct = (part, whole) => (whole > 0 ? (part / whole) * 100 : 0);
15
+ function formatPct(value) {
16
+ if (value === 0)
17
+ return '0%';
18
+ if (value < 1)
19
+ return '<1%';
20
+ return `${Math.round(value)}%`;
21
+ }
22
+ function segmentsOf(c) {
23
+ return [
24
+ { key: 'system', label: 'System prompt', tokens: c.system, color: palette.accent },
25
+ { key: 'tools', label: `Tool schemas (${c.toolCount})`, tokens: c.tools, color: palette.plan },
26
+ { key: 'user', label: 'Your messages', tokens: c.user, color: palette.ok },
27
+ { key: 'assistant', label: 'Model replies', tokens: c.assistant, color: palette.text },
28
+ { key: 'tool', label: 'Tool results', tokens: c.toolResults, color: palette.warn },
29
+ { key: 'images', label: c.imageCount > 0 ? `Images (${c.imageCount})` : 'Images', tokens: c.images, color: palette.yolo },
30
+ ];
31
+ }
32
+ /**
33
+ * A bar of `width` cells, one coloured run per kind of content. Every non-empty kind gets at
34
+ * least one cell, so a small share does not disappear.
35
+ */
36
+ export function renderBar(segments, limit, width) {
37
+ const cells = segments.map((s) => (s.tokens > 0 ? Math.max(1, Math.round((s.tokens / Math.max(1, limit)) * width)) : 0));
38
+ let used = cells.reduce((a, b) => a + b, 0);
39
+ // Rounding up the small ones can overshoot: take the cells back from the biggest.
40
+ while (used > width) {
41
+ const biggest = cells.indexOf(Math.max(...cells));
42
+ if (cells[biggest] <= 1)
43
+ break;
44
+ cells[biggest]--;
45
+ used--;
46
+ }
47
+ let bar = '';
48
+ segments.forEach((s, i) => {
49
+ if (cells[i] > 0)
50
+ bar += s.color('█'.repeat(cells[i]));
51
+ });
52
+ return bar + palette.faint('░'.repeat(Math.max(0, width - used)));
53
+ }
54
+ /** How the cost number is to be read, in a few words. */
55
+ function costNote(session) {
56
+ const st = session.costState;
57
+ if ((st.unpriced ?? 0) > 0 && (st.priced ?? 0) === 0 && session.totalCost === 0)
58
+ return 'no price known for this model';
59
+ const parts = [];
60
+ if (st.estimated)
61
+ parts.push('catalogue prices, an estimate');
62
+ if ((st.unpriced ?? 0) > 0)
63
+ parts.push(`${st.unpriced} request${st.unpriced === 1 ? '' : 's'} not priced`);
64
+ return parts.join(', ');
65
+ }
66
+ /** The content as rows of text, without the frame. `iw` is the number of cells available. */
67
+ function buildRows(d, iw, detail) {
68
+ const rows = [];
69
+ const line = (text) => rows.push({ kind: 'line', text });
70
+ const title = (text) => rows.push({ kind: 'title', text });
71
+ const blank = () => rows.push({ kind: 'blank', text: '' });
72
+ const pair = (label, value, labelWidth = 16) => {
73
+ const room = Math.max(4, Math.min(labelWidth, iw - 3 - displayWidth(value)));
74
+ const left = ' ' + palette.muted(padEndWidth(truncateWidth(label, room), room));
75
+ const gap = Math.max(1, iw - displayWidth(left) - displayWidth(value));
76
+ line(left + ' '.repeat(gap) + value);
77
+ };
78
+ const c = d.context;
79
+ const s = d.session;
80
+ const usedPct = pct(c.total, c.limit);
81
+ const usedColor = usedPct >= 85 ? palette.err : usedPct >= 70 ? palette.warn : palette.text;
82
+ // ── context
83
+ title('CONTEXT');
84
+ const summary = `${formatPct(usedPct)} ${fmtTokens(c.total)} / ${fmtTokens(c.limit)}`;
85
+ const barWidth = Math.max(4, iw - 4 - displayWidth(summary) - 2);
86
+ line(' ' + renderBar(segmentsOf(c), c.limit, barWidth) + ' ' + usedColor(summary));
87
+ if (detail === 'full') {
88
+ const labelWidth = Math.max(6, Math.min(22, iw - 19));
89
+ for (const seg of segmentsOf(c)) {
90
+ if (seg.tokens === 0 && seg.key === 'images')
91
+ continue;
92
+ const left = ' ' + seg.color('■') + ' ' + (seg.tokens > 0 ? palette.text(padEndWidth(truncateWidth(seg.label, labelWidth), labelWidth)) : palette.faint(padEndWidth(truncateWidth(seg.label, labelWidth), labelWidth)));
93
+ const right = palette.text(fmtTokens(seg.tokens).padStart(6)) + palette.faint((' ' + formatPct(pct(seg.tokens, c.limit))).padStart(6));
94
+ line(left + ' '.repeat(Math.max(1, iw - displayWidth(left) - displayWidth(right))) + right);
95
+ }
96
+ const freeLeft = ' ' + palette.faint('□') + ' ' + palette.faint(padEndWidth('Free', labelWidth));
97
+ const freeRight = palette.faint(fmtTokens(c.free).padStart(6) + (' ' + formatPct(pct(c.free, c.limit))).padStart(6));
98
+ line(freeLeft + ' '.repeat(Math.max(1, iw - displayWidth(freeLeft) - displayWidth(freeRight))) + freeRight);
99
+ }
100
+ if (detail === 'full' && d.limitSource) {
101
+ const text = {
102
+ catalogue: 'Window size: from the models.dev catalogue.',
103
+ table: "Window size: from devx's table of models.",
104
+ rule: 'Window size: guessed from the model name.',
105
+ default: 'Window size: assumed, the model is unknown. Set contextLimits in the config.',
106
+ config: 'Window size: from contextLimits in your config.',
107
+ saved: 'Window size: saved when you chose the model.',
108
+ };
109
+ const warn = d.limitSource === 'default' || d.limitSource === 'rule';
110
+ line(' ' + (warn ? palette.warn : palette.faint)(truncateWidth(text[d.limitSource], iw - 2)));
111
+ }
112
+ if (detail !== 'min') {
113
+ line(' ' + palette.faint(truncateWidth(`Old messages are dropped above ${fmtTokens(c.compactAt)} (85%).`, iw - 2)));
114
+ if (d.compactions > 0) {
115
+ line(' ' + palette.faint(truncateWidth(`Happened ${d.compactions}x, ${d.dropped} message${d.dropped === 1 ? '' : 's'} dropped so far.`, iw - 2)));
116
+ }
117
+ }
118
+ if (detail === 'full' && s.lastPromptTokens > 0) {
119
+ line(' ' + palette.faint(truncateWidth(`Last request: the provider counted ${fmtTokens(s.lastPromptTokens)} input.`, iw - 2)));
120
+ }
121
+ if (d.noVision && c.imageCount > 0 && detail !== 'min')
122
+ line(' ' + palette.warn(truncateWidth('This model cannot read images: they are not sent.', iw - 2)));
123
+ // ── session
124
+ if (detail !== 'min')
125
+ blank();
126
+ title('SESSION');
127
+ pair('Cost', palette.bold(formatMoney(s.totalCost, s.costState)));
128
+ const note = costNote(s);
129
+ if (note && detail === 'full')
130
+ line(' ' + palette.faint(truncateWidth(note, iw - 2)));
131
+ const tokens = iw >= 40 ? `${fmtTokens(s.promptTokens)} in · ${fmtTokens(s.completionTokens)} out` : `${fmtTokens(s.promptTokens)}/${fmtTokens(s.completionTokens)}`;
132
+ pair('Tokens', palette.text(tokens));
133
+ if (s.cacheReadTokens > 0 && detail !== 'min')
134
+ pair('Cached input', palette.text(fmtTokens(s.cacheReadTokens)));
135
+ pair('Requests', palette.text(String(s.requestsCount)));
136
+ // ── traffic
137
+ if (detail === 'full') {
138
+ blank();
139
+ title('TRAFFIC');
140
+ pair('Sent / received', palette.text(iw >= 40 ? `${formatBytes(s.bytesSent)} / ${formatBytes(s.bytesReceived)}` : `${formatBytes(s.bytesSent)}/${formatBytes(s.bytesReceived)}`));
141
+ if (s.dataSaverLimitMB) {
142
+ const used = s.totalBytes / (1024 * 1024);
143
+ const share = Math.min(100, Math.round((used / s.dataSaverLimitMB) * 100));
144
+ const text = `${used.toFixed(1)} / ${s.dataSaverLimitMB} MB (${share}%)`;
145
+ pair('Data saver', share >= 90 ? palette.err(text) : palette.text(text));
146
+ }
147
+ }
148
+ return rows;
149
+ }
150
+ function keyHints(width) {
151
+ const variants = ['esc close', 'esc'];
152
+ for (const text of variants) {
153
+ const [key, ...rest] = text.split(' ');
154
+ const out = palette.text(key) + (rest.length ? ' ' + palette.faint(rest.join(' ')) : '');
155
+ if (displayWidth(out) <= width)
156
+ return out;
157
+ }
158
+ return '';
159
+ }
160
+ function renderCard(d, cols, rows) {
161
+ const W = Math.min(cols - 1, 76);
162
+ const iw = W - 4;
163
+ const rule = palette.rule;
164
+ const lines = [];
165
+ const box = (content) => rule('│') + ' ' + padEndWidth(content, iw) + ' ' + rule('│');
166
+ const heading = ' ' + palette.bold(palette.accent('usage')) + ' ';
167
+ const fmt = d.format === 'anthropic' ? 'anthropic' : 'openai';
168
+ const modelText = truncateWidth(`${d.model}${d.effort ? ` · ${d.effort}` : ''} · ${fmt}`, Math.max(8, W - 4 - displayWidth(heading) - 4));
169
+ const tag = ' ' + palette.faint(modelText) + ' ';
170
+ const fill = Math.max(0, W - 4 - displayWidth(heading) - displayWidth(tag));
171
+ lines.push(rule('╭─') + heading + rule('─'.repeat(fill)) + tag + rule('─╮'));
172
+ const body = buildRows(d, iw, 'full');
173
+ lines.push(box(''));
174
+ for (const row of body) {
175
+ if (row.kind === 'title')
176
+ lines.push(box(' ' + palette.faint(row.text)));
177
+ else if (row.kind === 'blank')
178
+ lines.push(box(''));
179
+ else
180
+ lines.push(box(row.text));
181
+ }
182
+ lines.push(box(''));
183
+ lines.push(rule('╰' + '─'.repeat(W - 2) + '╯'));
184
+ lines.push(' ' + keyHints(cols - 2));
185
+ return lines.length <= rows - 1 ? lines : null;
186
+ }
187
+ function renderPlain(d, cols, rows) {
188
+ const width = Math.max(20, cols - 1);
189
+ const head = ' ' + palette.bold(palette.accent('usage')) + palette.faint(' · ' + truncateWidth(d.model, Math.max(6, width - 10)));
190
+ for (const detail of ['full', 'brief', 'min']) {
191
+ const body = buildRows(d, width - 1, detail);
192
+ const lines = [head, ...body.map((r) => (r.kind === 'title' ? ' ' + palette.faint(r.text) : r.kind === 'blank' ? '' : ' ' + r.text)), ' ' + keyHints(width - 1)];
193
+ if (lines.length <= rows - 1 || detail === 'min') {
194
+ // On a very short screen drop the blank rows too.
195
+ return lines.length <= rows - 1 ? lines : lines.filter((l) => l !== '');
196
+ }
197
+ }
198
+ return [head];
199
+ }
200
+ /** The whole panel as terminal rows: a framed card on a wide screen, a plain list on a narrow or short one. */
201
+ export function renderUsagePanel(d, cols, rows) {
202
+ if (cols >= 60) {
203
+ const card = renderCard(d, cols, rows);
204
+ if (card)
205
+ return card;
206
+ }
207
+ return renderPlain(d, cols, rows);
208
+ }
209
+ /** Shows the panel until Esc (or q, Enter, Ctrl+C) is pressed. The panel is erased on close. */
210
+ export function runUsagePanel(data, options = {}) {
211
+ return new Promise((resolve) => {
212
+ const { input, output } = options.io ?? { input: process.stdin, output: process.stdout };
213
+ const block = new LiveBlock(output);
214
+ const draw = () => block.draw(renderUsagePanel(data, output.columns || 80, output.rows || 30));
215
+ const wasRaw = !!input.isRaw;
216
+ input.setRawMode?.(true);
217
+ input.resume();
218
+ let closed = false;
219
+ const close = () => {
220
+ if (closed)
221
+ return;
222
+ closed = true;
223
+ input.off('data', onData);
224
+ output.off?.('resize', draw);
225
+ block.erase();
226
+ if (!wasRaw)
227
+ input.setRawMode?.(false);
228
+ resolve();
229
+ };
230
+ const onData = (chunk) => {
231
+ for (const key of splitKeys(chunk.toString())) {
232
+ if (closed)
233
+ return;
234
+ if (key === '\x1b' || key === 'q' || key === '\r' || key === '\n' || key === ' ' || key === '\x03')
235
+ return close();
236
+ }
237
+ };
238
+ input.on('data', onData);
239
+ output.on?.('resize', draw);
240
+ draw();
241
+ });
242
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Terminal display width helpers.
3
+ *
4
+ * `string.length` counts UTF-16 units, but a terminal counts cells: CJK characters and
5
+ * emoji take two cells, combining marks take none. Anything drawn inside a box
6
+ * (borders, padding, right-aligned labels) must be measured in cells, otherwise a single
7
+ * emoji in the input shifts the right border and wraps the whole line.
8
+ */
9
+ const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
10
+ export function stripAnsi(text) {
11
+ return text.replace(ANSI, '');
12
+ }
13
+ /** Width in terminal cells of a single code point. */
14
+ export function codePointWidth(cp) {
15
+ if (cp === 0)
16
+ return 0;
17
+ if (cp < 32 || (cp >= 0x7f && cp < 0xa0))
18
+ return 0; // control characters
19
+ if ((cp >= 0x0300 && cp <= 0x036f) || // combining diacritics
20
+ (cp >= 0x200b && cp <= 0x200f) || // zero width space / joiners / marks
21
+ (cp >= 0x2060 && cp <= 0x2064) ||
22
+ (cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors
23
+ cp === 0xfeff ||
24
+ (cp >= 0xe0100 && cp <= 0xe01ef)) {
25
+ return 0;
26
+ }
27
+ if ((cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
28
+ (cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, punctuation
29
+ (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK symbols
30
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK extension A
31
+ (cp >= 0x4e00 && cp <= 0xa4cf) || // CJK unified, Yi
32
+ (cp >= 0xa960 && cp <= 0xa97f) ||
33
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
34
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility
35
+ (cp >= 0xfe30 && cp <= 0xfe6f) ||
36
+ (cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
37
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
38
+ (cp >= 0x1f300 && cp <= 0x1f64f) || // emoji: symbols, emoticons
39
+ (cp >= 0x1f680 && cp <= 0x1f6ff) || // transport
40
+ (cp >= 0x1f900 && cp <= 0x1f9ff) || // supplemental symbols
41
+ (cp >= 0x1fa70 && cp <= 0x1faff) ||
42
+ (cp >= 0x20000 && cp <= 0x3fffd) // CJK extension B+
43
+ ) {
44
+ return 2;
45
+ }
46
+ return 1;
47
+ }
48
+ /** Display width of a string in terminal cells (ANSI escape sequences are ignored). */
49
+ export function displayWidth(text) {
50
+ let width = 0;
51
+ for (const ch of stripAnsi(text)) {
52
+ width += codePointWidth(ch.codePointAt(0));
53
+ }
54
+ return width;
55
+ }
56
+ /**
57
+ * Cuts plain text (no ANSI) to at most `max` cells, never splitting a character.
58
+ * When it has to cut, the result ends with an ellipsis and still fits in `max` cells.
59
+ */
60
+ export function truncateWidth(text, max) {
61
+ if (max <= 0)
62
+ return '';
63
+ if (displayWidth(text) <= max)
64
+ return text;
65
+ const budget = max - 1; // room for the ellipsis
66
+ let out = '';
67
+ let width = 0;
68
+ for (const ch of text) {
69
+ const w = codePointWidth(ch.codePointAt(0));
70
+ if (width + w > budget)
71
+ break;
72
+ out += ch;
73
+ width += w;
74
+ }
75
+ return out + '…';
76
+ }
77
+ /** Pads a (possibly colored) string with spaces on the right up to `width` cells. */
78
+ export function padEndWidth(text, width) {
79
+ const missing = width - displayWidth(text);
80
+ return missing > 0 ? text + ' '.repeat(missing) : text;
81
+ }
82
+ /** Pads a (possibly colored) string with spaces on the left up to `width` cells. */
83
+ export function padStartWidth(text, width) {
84
+ const missing = width - displayWidth(text);
85
+ return missing > 0 ? ' '.repeat(missing) + text : text;
86
+ }
87
+ /**
88
+ * Returns the slice of plain `text` that starts at character index `start` and fits
89
+ * into `maxCells`, together with how many characters were taken.
90
+ */
91
+ export function sliceByWidth(text, start, maxCells) {
92
+ let out = '';
93
+ let cells = 0;
94
+ let length = 0;
95
+ let index = 0;
96
+ for (const ch of text) {
97
+ const units = ch.length;
98
+ if (index >= start) {
99
+ const w = codePointWidth(ch.codePointAt(0));
100
+ if (cells + w > maxCells)
101
+ break;
102
+ out += ch;
103
+ cells += w;
104
+ length += units;
105
+ }
106
+ index += units;
107
+ }
108
+ return { text: out, length, cells };
109
+ }
110
+ /** Hard-wraps plain text to lines of at most `width` cells (explicit newlines are kept). */
111
+ export function wrapWidth(text, width) {
112
+ const lines = [];
113
+ for (const rawLine of text.split(/\r\n|\r|\n/)) {
114
+ if (rawLine === '') {
115
+ lines.push('');
116
+ continue;
117
+ }
118
+ let current = '';
119
+ let cells = 0;
120
+ for (const ch of rawLine) {
121
+ const w = codePointWidth(ch.codePointAt(0));
122
+ if (cells + w > width && current !== '') {
123
+ lines.push(current);
124
+ current = '';
125
+ cells = 0;
126
+ }
127
+ current += ch;
128
+ cells += w;
129
+ }
130
+ lines.push(current);
131
+ }
132
+ return lines;
133
+ }
134
+ /** Wraps text at word boundaries (a word longer than the line is cut). */
135
+ export function wrapWords(text, width) {
136
+ const lines = [];
137
+ let current = '';
138
+ for (const word of text.split(/\s+/).filter(Boolean)) {
139
+ if (displayWidth(word) > width) {
140
+ if (current)
141
+ lines.push(current);
142
+ lines.push(truncateWidth(word, width));
143
+ current = '';
144
+ }
145
+ else if (current === '') {
146
+ current = word;
147
+ }
148
+ else if (displayWidth(current) + 1 + displayWidth(word) <= width) {
149
+ current += ' ' + word;
150
+ }
151
+ else {
152
+ lines.push(current);
153
+ current = word;
154
+ }
155
+ }
156
+ if (current)
157
+ lines.push(current);
158
+ return lines;
159
+ }
160
+ /** The last `maxCells` cells of a text (what a running line shows: the newest words). */
161
+ export function tailWidth(text, maxCells) {
162
+ const chars = Array.from(text);
163
+ let cells = 0;
164
+ let start = chars.length;
165
+ while (start > 0) {
166
+ const w = codePointWidth(chars[start - 1].codePointAt(0));
167
+ if (cells + w > maxCells)
168
+ break;
169
+ cells += w;
170
+ start--;
171
+ }
172
+ return chars.slice(start).join('');
173
+ }
174
+ /** Wraps at word boundaries; text without spaces (Chinese, Japanese) is wrapped anywhere. */
175
+ export function wrapText(text, width) {
176
+ const unbreakable = text.split(/\s+/).some((word) => displayWidth(word) > width);
177
+ return unbreakable ? wrapWidth(text, width) : wrapWords(text, width);
178
+ }