termux-dev 1.8.0 → 1.8.1

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/cli/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { Command } from 'commander';
2
2
  import * as p from '@clack/prompts';
3
- import { search, password, input } from '@inquirer/prompts';
4
3
  import pc from 'picocolors';
5
4
  import os from 'os';
6
5
  import path from 'path';
@@ -21,6 +20,8 @@ import { notifyDevice, acquireWakeLock, isTermux, notificationBody, sendDesktopN
21
20
  import { copyToClipboard } from './clipboard-write.js';
22
21
  import { REWIND_CURRENT, messageDraft, rewindItems, userEntries } from './rewind.js';
23
22
  import { runPicker } from './picker.js';
23
+ import { runFinder, runTextInput } from './finder.js';
24
+ import { renderWelcome } from './welcome.js';
24
25
  import { savePlanFile } from '../core/planfile.js';
25
26
  import { BASE_TITLE, RESTORE_TERMINAL_TITLE, SAVE_TERMINAL_TITLE, cleanTitle, generateChatTitle, provisionalTitle, terminalTitleSequence } from '../core/title.js';
26
27
  import { runDoctor } from './doctor.js';
@@ -32,7 +33,7 @@ import { SmoothStreamer } from './smooth.js';
32
33
  import { askPrompt } from './prompt.js';
33
34
  import { resolveAtMentions } from './files.js';
34
35
  import { runStartupUpdateCheck, checkForUpdates, performSelfUpdate } from './updater.js';
35
- import { setActiveTheme, getCurrentTheme, listThemes, findTheme, renderSwatch, renderThemePreview } from './theme.js';
36
+ import { setActiveTheme, getCurrentTheme, listThemes, findTheme } from './theme.js';
36
37
  import { runHeadlessMode } from './headless.js';
37
38
  import { CustomCommandManager } from '../core/commands.js';
38
39
  import { UsageTracker } from '../core/usage.js';
@@ -54,7 +55,7 @@ import { askConfirm, askPlanDecision, printFarewell } from './confirm.js';
54
55
  import { createCheckpoint, diffCheckpoint, restoreCheckpoint } from '../core/checkpoint.js';
55
56
  import { log, note } from './log.js';
56
57
  import { displayWidth, tailWidth } from './width.js';
57
- import { collapsedThoughtLine, messagesToItems, renderTranscript, toolResultRows } from './transcript.js';
58
+ import { COMPACT_MARK, collapsedThoughtLine, renderItem, messagesToItems, renderTranscript, toolResultRows } from './transcript.js';
58
59
  import { chooseEffort, effortCommand } from './effort.js';
59
60
  import { effectiveReasoning, parseEffortArg, detectRoute, thinkLabel } from '../core/reasoning.js';
60
61
  import { catalogInfo, installCapabilities, refreshCapabilities, refreshCatalog } from '../core/capabilities.js';
@@ -63,7 +64,7 @@ import { contextBreakdown } from '../core/context-stats.js';
63
64
  import { runUsagePanel } from './usage-panel.js';
64
65
  import { TurnComposer } from './typeahead.js';
65
66
  import { EXTENDED_KEYS_OFF, normalizeKey, splitKeys } from './keys.js';
66
- import { getLanguage, setLanguage, t } from '../core/i18n.js';
67
+ import { LANGUAGES, getLanguage, setLanguage, t } from '../core/i18n.js';
67
68
  import { anthropicOnlyModels, formatFromBaseUrl, refreshFormats } from '../core/formats.js';
68
69
  import { providerFormat } from '../providers/index.js';
69
70
  const CONFIG_PATH = path.join(os.homedir(), '.devxrc.json');
@@ -138,9 +139,11 @@ const POPULAR_PROVIDER_MODELS = {
138
139
  groq: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'],
139
140
  mistral: ['mistral-large-latest', 'mistral-small-latest', 'codestral-latest'],
140
141
  };
141
- async function selectModel(baseUrl, apiKey, currentModel, providerId = '', allowCancel = true) {
142
+ const formatContext = (n) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`);
143
+ /** `step` is "3/4" during the first run, so the card says where in the setup you are. */
144
+ async function selectModel(baseUrl, apiKey, currentModel, providerId = '', _allowCancel = true, step) {
142
145
  const s = new UiSpinner();
143
- s.start('Fetching available models from provider...');
146
+ s.start(t('onboard.model.fetching'));
144
147
  const result = await fetchModels(baseUrl, apiKey);
145
148
  // The same gateway may list more models (Claude) in its Anthropic format, with the same key.
146
149
  if (result.ok && probeEndpointsAllowed) {
@@ -160,201 +163,152 @@ async function selectModel(baseUrl, apiKey, currentModel, providerId = '', allow
160
163
  contextLength: getModelContextLimit(id)
161
164
  }));
162
165
  }
163
- let modelChoice;
164
- if (models.length > 0) {
165
- try {
166
- modelChoice = await search({
167
- message: `Select a model (${models.length} available, type to filter):`,
168
- source: async (term) => {
169
- const q = (term || '').trim().toLowerCase();
170
- const list = [];
171
- if (allowCancel) {
172
- list.push({ name: pc.yellow('← Back / Cancel'), value: '__cancel__', description: 'Keep current model' });
173
- }
174
- list.push({ name: pc.yellow('+ Type a custom model name...'), value: 'custom_input', description: 'Enter any model ID manually' });
175
- for (const m of models) {
176
- const isCurrent = m.id === currentModel;
177
- const prefix = isCurrent ? pc.cyan('› ') : ' ';
178
- const nameStr = isCurrent ? pc.bold(pc.cyan(m.id + ' (current)')) : m.id;
179
- const ctxStr = (m.viaAnthropic ? 'Anthropic format' + (m.contextLength ? ', ' : '') : '') + (m.contextLength ? `${m.contextLength >= 1_000_000 ? (m.contextLength / 1_000_000).toFixed(1) + 'M' : Math.round(m.contextLength / 1000) + 'k'} context` : '');
180
- list.push({
181
- name: prefix + nameStr,
182
- value: m.id,
183
- description: ctxStr
184
- });
185
- }
186
- if (!q)
187
- return list;
188
- return list.filter(item => item.value.toLowerCase().includes(q) || item.name.toLowerCase().includes(q));
189
- },
190
- pageSize: 10
191
- });
192
- }
193
- catch {
194
- return null;
195
- }
196
- if (modelChoice === '__cancel__') {
197
- return null;
198
- }
199
- if (modelChoice === 'custom_input') {
200
- try {
201
- const customModel = await input({
202
- message: 'Type the model name (e.g. gpt-4o or claude-3-7-sonnet):',
203
- });
204
- if (!customModel || !customModel.trim())
205
- return null;
206
- const name = customModel.trim();
207
- return { model: name, contextLimit: getModelContextLimit(name) };
208
- }
209
- catch {
210
- return null;
211
- }
212
- }
213
- const found = models.find(m => m.id === modelChoice);
214
- const limit = (found && found.contextLength) || getModelContextLimit(modelChoice);
215
- return { model: modelChoice, contextLimit: limit };
216
- }
217
- else {
218
- log.warn('Could not fetch models automatically.');
219
- try {
220
- const typedModel = await input({
221
- message: 'Type the model name you want to use (e.g. gpt-4o):',
222
- default: currentModel || ''
223
- });
224
- if (!typedModel || !typedModel.trim())
225
- return null;
226
- const name = typedModel.trim();
227
- return { model: name, contextLimit: getModelContextLimit(name) };
228
- }
229
- catch {
166
+ const typeName = async (note) => {
167
+ const name = await runTextInput({
168
+ title: t('onboard.model.title'),
169
+ subtitle: step,
170
+ label: t('onboard.model.label'),
171
+ initial: models.length === 0 ? currentModel : '',
172
+ notes: note ? [note] : undefined,
173
+ });
174
+ return name ? { model: name, contextLimit: getModelContextLimit(name) } : null;
175
+ };
176
+ if (models.length === 0)
177
+ return typeName(t('onboard.model.manual'));
178
+ const CUSTOM = '__custom__';
179
+ for (;;) {
180
+ const choice = await runFinder({
181
+ title: t('onboard.model.title'),
182
+ subtitle: step ?? `${models.length}`,
183
+ placeholder: t('onboard.model.placeholder'),
184
+ current: currentModel,
185
+ items: [
186
+ { value: CUSTOM, label: t('onboard.model.custom'), detail: t('onboard.model.customDetail'), pinned: true },
187
+ ...models.map((m) => ({
188
+ value: m.id,
189
+ label: m.id,
190
+ detail: [m.viaAnthropic ? t('onboard.model.anthropic') : '', m.contextLength ? t('onboard.model.context', { n: formatContext(m.contextLength) }) : ''].filter(Boolean).join(', '),
191
+ })),
192
+ ],
193
+ });
194
+ if (choice === undefined)
230
195
  return null;
196
+ if (choice === CUSTOM) {
197
+ const typed = await typeName();
198
+ if (typed)
199
+ return typed;
200
+ continue; // Esc in the field: back to the list
231
201
  }
202
+ const found = models.find(m => m.id === choice);
203
+ return { model: choice, contextLimit: (found && found.contextLength) || getModelContextLimit(choice) };
232
204
  }
233
205
  }
206
+ /** The host of an address, for the detail column: `api.openai.com`. */
207
+ const hostOf = (url) => url.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
234
208
  async function selectProvider(currentConfig, allowCancel = true) {
235
209
  const apiKeys = currentConfig.apiKeys || {};
236
210
  const baseUrls = currentConfig.baseUrls || {};
237
211
  if (currentConfig.provider && currentConfig.apiKey) {
238
212
  apiKeys[currentConfig.provider] = currentConfig.apiKey;
239
213
  }
240
- let providerId;
241
- try {
242
- providerId = await search({
243
- message: 'Select AI Provider (type to filter, Esc to cancel):',
244
- source: async (term) => {
245
- const q = (term || '').trim().toLowerCase();
246
- const filtered = q
247
- ? ALL_PROVIDERS.filter(pr => pr.label.toLowerCase().includes(q) || pr.value.toLowerCase().includes(q))
248
- : ALL_PROVIDERS;
249
- const list = filtered.map(pr => {
250
- const isCurrent = pr.value === currentConfig.provider;
251
- const labelStr = isCurrent ? pc.bold(pc.cyan(pr.label + ' (current)')) : pr.label;
252
- const hasKey = apiKeys[pr.value] ? `[Key: ${maskApiKey(apiKeys[pr.value])}] ` : '';
253
- return {
254
- name: `${isCurrent ? pc.cyan('› ') : ' '}${labelStr}`,
255
- value: pr.value,
256
- description: `${hasKey}${pr.baseUrl || 'Custom endpoint'}`
257
- };
258
- });
259
- if (allowCancel) {
260
- list.unshift({
261
- name: pc.yellow('← Back / Cancel'),
262
- value: '__cancel__',
263
- description: 'Keep current provider'
264
- });
265
- }
266
- return list;
267
- },
268
- pageSize: 10
269
- });
270
- }
271
- catch {
272
- return null;
273
- }
274
- if (providerId === '__cancel__') {
275
- return null;
276
- }
277
- const selectedProvider = ALL_PROVIDERS.find(pr => pr.value === providerId) || { baseUrl: '', label: providerId };
278
- let baseUrl = baseUrls[providerId] || selectedProvider.baseUrl;
279
- if (providerId === 'custom' || !baseUrl) {
280
- try {
281
- const customUrl = await input({
282
- message: 'Enter Base URL (e.g. https://api.openai.com/v1):',
283
- default: baseUrl || ''
214
+ const firstRun = !allowCancel;
215
+ const stepOf = (n) => (firstRun ? `${n}/5` : undefined);
216
+ let providerId = '';
217
+ let baseUrl = '';
218
+ let apiKey = '';
219
+ let stage = 'provider';
220
+ let modelRes = null;
221
+ while (!modelRes) {
222
+ if (stage === 'provider') {
223
+ const choice = await runFinder({
224
+ title: t('onboard.provider.title'),
225
+ subtitle: stepOf(3),
226
+ placeholder: t('onboard.provider.placeholder'),
227
+ current: currentConfig.provider,
228
+ notes: firstRun ? [t('onboard.provider.note')] : undefined,
229
+ items: ALL_PROVIDERS.map((pr) => ({
230
+ value: pr.value,
231
+ label: pr.label,
232
+ detail: `${apiKeys[pr.value] ? maskApiKey(apiKeys[pr.value]) + ' ' : ''}${hostOf(pr.baseUrl || '')}`.trim(),
233
+ keywords: `${pr.value} ${hostOf(pr.baseUrl || '')}`,
234
+ pinned: pr.value === 'custom',
235
+ })),
284
236
  });
285
- if (!customUrl || !customUrl.trim())
286
- return null;
287
- baseUrl = customUrl.trim();
288
- baseUrls[providerId] = baseUrl;
237
+ if (choice === undefined)
238
+ return null; // Esc on the first step leaves the whole setup
239
+ providerId = choice;
240
+ const known = ALL_PROVIDERS.find(pr => pr.value === providerId);
241
+ baseUrl = baseUrls[providerId] || known?.baseUrl || '';
242
+ stage = providerId === 'custom' || !baseUrl ? 'url' : 'key';
289
243
  }
290
- catch {
291
- return null;
244
+ else if (stage === 'url') {
245
+ const url = await runTextInput({
246
+ title: t('onboard.baseUrl.title'),
247
+ subtitle: stepOf(3),
248
+ label: t('onboard.baseUrl.label'),
249
+ initial: baseUrl,
250
+ placeholder: t('onboard.baseUrl.placeholder'),
251
+ });
252
+ if (url === undefined) {
253
+ stage = 'provider';
254
+ continue;
255
+ }
256
+ baseUrl = url;
257
+ baseUrls[providerId] = baseUrl;
258
+ stage = 'key';
292
259
  }
293
- }
294
- let apiKey = '';
295
- if (providerId === 'ollama') {
296
- apiKey = 'ollama';
297
- }
298
- else {
299
- const savedKey = apiKeys[providerId];
300
- if (savedKey) {
301
- let keyChoice;
302
- try {
303
- keyChoice = await search({
304
- message: `API key found for ${selectedProvider.label} (${maskApiKey(savedKey)}):`,
305
- source: async () => [
306
- { name: `● Continue with saved API key (${maskApiKey(savedKey)})`, value: 'use_saved' },
307
- { name: '+ Enter a new API key', value: 'new_key' },
308
- { name: pc.yellow('← Cancel'), value: '__cancel__' }
260
+ else if (stage === 'key') {
261
+ const label = ALL_PROVIDERS.find(pr => pr.value === providerId)?.label || providerId;
262
+ const back = providerId === 'custom' || !(ALL_PROVIDERS.find(pr => pr.value === providerId)?.baseUrl) ? 'url' : 'provider';
263
+ if (providerId === 'ollama') {
264
+ apiKey = 'ollama';
265
+ stage = 'model';
266
+ continue;
267
+ }
268
+ const saved = apiKeys[providerId];
269
+ let useSaved = false;
270
+ if (saved) {
271
+ const choice = await runPicker({
272
+ title: t('onboard.key.title'),
273
+ subtitle: label,
274
+ items: [
275
+ { value: 'saved', label: t('onboard.key.useSaved'), detail: maskApiKey(saved) },
276
+ { value: 'new', label: t('onboard.key.enterNew') },
309
277
  ],
310
- pageSize: 5
278
+ notes: [t('onboard.key.savedTitle', { provider: label })],
311
279
  });
280
+ if (choice === undefined) {
281
+ stage = back;
282
+ continue;
283
+ }
284
+ useSaved = choice === 'saved';
312
285
  }
313
- catch {
314
- return null;
315
- }
316
- if (keyChoice === '__cancel__')
317
- return null;
318
- if (keyChoice === 'use_saved') {
319
- apiKey = savedKey;
286
+ if (useSaved) {
287
+ apiKey = saved;
320
288
  }
321
289
  else {
322
- try {
323
- const key = await password({
324
- message: `Enter new API Key for ${selectedProvider.label}:`,
325
- mask: '•'
326
- });
327
- if (!key || !key.trim())
328
- return null;
329
- apiKey = key.trim();
330
- apiKeys[providerId] = apiKey;
331
- }
332
- catch {
333
- return null;
290
+ const key = await runTextInput({
291
+ title: t('onboard.key.title'),
292
+ subtitle: stepOf(4) ?? label,
293
+ label: t('onboard.key.label', { provider: label }),
294
+ secret: true,
295
+ notes: [t('onboard.key.note')],
296
+ });
297
+ if (key === undefined) {
298
+ stage = back;
299
+ continue;
334
300
  }
301
+ apiKey = key;
302
+ apiKeys[providerId] = apiKey;
335
303
  }
304
+ stage = 'model';
336
305
  }
337
306
  else {
338
- try {
339
- const key = await password({
340
- message: `Enter API Key for ${selectedProvider.label} (or press Esc to cancel):`,
341
- mask: '•'
342
- });
343
- if (!key || !key.trim())
344
- return null;
345
- apiKey = key.trim();
346
- apiKeys[providerId] = apiKey;
347
- }
348
- catch {
349
- return null;
350
- }
307
+ modelRes = await selectModel(baseUrl, apiKey, currentConfig.model || '', providerId, allowCancel, stepOf(5));
308
+ if (!modelRes)
309
+ stage = apiKey === 'ollama' ? 'provider' : 'key';
351
310
  }
352
311
  }
353
- // Pick a model for this provider
354
- const modelRes = await selectModel(baseUrl, apiKey, currentConfig.model || '', providerId, allowCancel);
355
- if (!modelRes) {
356
- return null;
357
- }
358
312
  const updatedConfig = {
359
313
  ...currentConfig,
360
314
  provider: providerId,
@@ -369,15 +323,54 @@ async function selectProvider(currentConfig, allowCancel = true) {
369
323
  await saveConfig(updatedConfig);
370
324
  return updatedConfig;
371
325
  }
326
+ /** The themes as a list; the interface takes the colours of the focused one at once, so you see what you pick. */
327
+ async function chooseTheme(current, step) {
328
+ const before = current ?? getCurrentTheme().id;
329
+ const picked = await runPicker({
330
+ title: t('onboard.theme.title'),
331
+ subtitle: step,
332
+ items: listThemes().map((th) => ({ value: th.id, label: th.name, detail: th.desc })),
333
+ current: before,
334
+ labelWidth: 20,
335
+ notes: [t('onboard.theme.note')],
336
+ onFocus: (id) => { setActiveTheme(id); },
337
+ });
338
+ setActiveTheme(picked ?? before);
339
+ return picked;
340
+ }
341
+ /** First run: the welcome card, the language, then provider, key and model, each on its own small card. */
372
342
  async function runOnboarding() {
373
- console.clear();
374
- p.intro(pc.bgCyan(pc.black(' Welcome to devx ')));
375
- p.note('No configuration found. Let\'s set up your AI provider.', 'Setup');
376
- const res = await selectProvider({ apiKeys: {}, baseUrls: {} }, false);
343
+ const cols = () => process.stdout.columns || 80;
344
+ const show = (done) => {
345
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
346
+ console.log();
347
+ for (const line of renderWelcome(DEVX_VERSION, cols(), done))
348
+ console.log(line);
349
+ console.log();
350
+ };
351
+ show(0);
352
+ const language = await runPicker({
353
+ title: t('onboard.language.title'),
354
+ subtitle: '1/5',
355
+ items: LANGUAGES.map((l) => ({ value: l.id, label: l.name })),
356
+ current: getLanguage(),
357
+ notes: [t('onboard.language.note')],
358
+ labelWidth: 14,
359
+ });
360
+ if (language === undefined) {
361
+ process.exit(0);
362
+ }
363
+ setLanguage(language);
364
+ show(1);
365
+ const theme = await chooseTheme(undefined, '2/5');
366
+ show(2);
367
+ const res = await selectProvider({ apiKeys: {}, baseUrls: {}, language, ...(theme ? { theme } : {}), onboarded: true }, false);
377
368
  if (!res) {
378
369
  process.exit(0);
379
370
  }
380
- p.outro(pc.green('Setup complete! Configuration saved to ~/.devxrc.json'));
371
+ show(5);
372
+ console.log(renderNotice('ok', t('onboard.done')));
373
+ console.log();
381
374
  return res;
382
375
  }
383
376
  /**
@@ -609,6 +602,11 @@ function formatToolGeneratingLabel(name, targetHint) {
609
602
  return `→ Clarifying questions...`;
610
603
  return `→ ${name}${target}...`;
611
604
  }
605
+ /** `#ab526 · 2h ago · 12 msgs`: what tells two sessions with the same title apart. */
606
+ function sessionDetail(s) {
607
+ const count = s.messages.filter((m) => m.role !== 'system').length;
608
+ return `#${s.id.split('_').pop()} · ${formatSessionTime(s.updatedAt)} · ${t('resume.msgs', { n: count })}`;
609
+ }
612
610
  async function handleSessionDelete(bannerMode) {
613
611
  while (true) {
614
612
  const sessions = await SessionManager.listSessions();
@@ -617,36 +615,18 @@ async function handleSessionDelete(bannerMode) {
617
615
  log.warn('No saved sessions found.');
618
616
  break;
619
617
  }
620
- let chosenSessionId;
621
- try {
622
- chosenSessionId = await search({
623
- message: `Select session to DELETE (${sessions.length} sessions, Esc to finish):`,
624
- source: async (term) => {
625
- const q = (term || '').trim().toLowerCase();
626
- const list = [
627
- { name: pc.yellow('← Done / Cancel'), value: '__cancel__', description: 'Return to chat' },
628
- { name: pc.red('× Delete ALL sessions'), value: '__all__', description: `Remove all ${sessions.length} saved sessions` }
629
- ];
630
- for (const s of sessions) {
631
- const timeStr = formatSessionTime(s.updatedAt);
632
- const msgCount = s.messages.filter(m => m.role !== 'system').length;
633
- const shortId = s.id.split('_').pop();
634
- list.push({
635
- name: `${pc.red('× ')} ${pc.dim(`[#${shortId}]`)} ${s.title}`,
636
- value: s.id,
637
- description: `${timeStr} • ${msgCount} msgs • ${s.model || 'default'}`
638
- });
639
- }
640
- if (!q)
641
- return list;
642
- return list.filter(item => item.name.toLowerCase().includes(q) || item.value.toLowerCase().includes(q));
643
- },
644
- pageSize: 10
645
- });
646
- }
647
- catch {
648
- break;
649
- }
618
+ const DELETE_ALL = '__all__';
619
+ const chosen = await runFinder({
620
+ title: t('sessions.deleteTitle'),
621
+ subtitle: `${sessions.length}`,
622
+ placeholder: t('resume.placeholder'),
623
+ labelWidth: 36,
624
+ items: [
625
+ { value: DELETE_ALL, label: t('sessions.deleteAll', { n: sessions.length }), pinned: true },
626
+ ...sessions.map((s) => ({ value: s.id, label: s.title, detail: sessionDetail(s), keywords: `${s.id} ${s.model || ''}` })),
627
+ ],
628
+ });
629
+ const chosenSessionId = chosen ?? '__cancel__';
650
630
  if (!chosenSessionId || chosenSessionId === '__cancel__') {
651
631
  drawLogo(bannerMode);
652
632
  log.info('Session deletion finished.');
@@ -675,38 +655,17 @@ async function handleSessionDelete(bannerMode) {
675
655
  }
676
656
  }
677
657
  async function handleThemeSelect(config) {
678
- const currentTh = getCurrentTheme();
679
- const themeChoices = listThemes().map(t => ({
680
- theme: t,
681
- choice: {
682
- name: `${renderSwatch(t)} ${t.boldFn(t.name.padEnd(18))} ${pc.dim(t.desc)} ${t.id === currentTh.id ? pc.green('(Active)') : ''}`,
683
- value: t.id,
684
- description: `${renderThemePreview(t)}\n\n${pc.dim(`${t.name} ${t.hex}`)}`
685
- }
686
- }));
687
- try {
688
- const selected = await search({
689
- message: `${pc.bold('Select UI Theme / Выберите цветовую тему')} ${pc.dim(`(${themeChoices.length} themes, type to filter):`)}`,
690
- pageSize: 8,
691
- source: async (term) => {
692
- const q = (term || '').toLowerCase().trim();
693
- return themeChoices
694
- .filter(({ theme: t }) => !q || `${t.id} ${t.name} ${t.desc}`.toLowerCase().includes(q))
695
- .map(({ choice }) => choice);
696
- }
697
- });
698
- if (selected) {
699
- config.theme = selected;
700
- await saveConfig(config);
701
- const th = setActiveTheme(selected);
702
- // No redraw of the screen: that would wipe the conversation. New output uses the new colours.
703
- if (config.ui?.layout === 'classic')
704
- log.success(th.boldFn(`Theme switched to ${th.colorFn(th.symbol)} ${th.name}!`));
705
- else
706
- console.log(renderNotice('ok', `theme ${th.name}`));
707
- }
658
+ const selected = await chooseTheme(config.theme);
659
+ if (selected) {
660
+ config.theme = selected;
661
+ await saveConfig(config);
662
+ const th = setActiveTheme(selected);
663
+ // No redraw of the screen: that would wipe the conversation. New output uses the new colours.
664
+ if (config.ui?.layout === 'classic')
665
+ log.success(th.boldFn(`Theme switched to ${th.colorFn(th.symbol)} ${th.name}!`));
666
+ else
667
+ console.log(renderNotice('ok', `theme ${th.name}`));
708
668
  }
709
- catch { }
710
669
  }
711
670
  async function handleSettings(config) {
712
671
  const initial = {
@@ -863,31 +822,11 @@ export async function main() {
863
822
  };
864
823
  };
865
824
  if (!config.onboarded) {
866
- const themeChoices = listThemes().map(t => ({
867
- theme: t,
868
- choice: {
869
- name: `${renderSwatch(t)} ${t.boldFn(t.name.padEnd(18))} ${pc.dim(t.desc)}`,
870
- value: t.id,
871
- description: `${renderThemePreview(t)}\n\n${pc.dim('You can change this anytime with /theme')}`
872
- }
873
- }));
874
- try {
875
- clearTerminalScreen();
876
- const selected = await search({
877
- message: `${pc.bold('Pick a theme to personalize your workspace:')}`,
878
- source: async (term) => {
879
- const q = (term || '').toLowerCase().trim();
880
- return themeChoices
881
- .filter(({ theme: t }) => !q || `${t.id} ${t.name} ${t.desc}`.toLowerCase().includes(q))
882
- .map(({ choice }) => choice);
883
- },
884
- pageSize: 8
885
- });
886
- if (selected) {
887
- config.theme = selected;
888
- }
889
- }
890
- catch { }
825
+ // A config from before the first-run setup had a theme step: ask it once.
826
+ clearTerminalScreen();
827
+ const selected = await chooseTheme(config.theme);
828
+ if (selected)
829
+ config.theme = selected;
891
830
  config.onboarded = true;
892
831
  await saveConfig(config);
893
832
  }
@@ -1148,21 +1087,17 @@ export async function main() {
1148
1087
  ...customSlashItems
1149
1088
  ];
1150
1089
  try {
1151
- const picked = await search({
1152
- message: 'Commands (type to search or select):',
1153
- source: async (term) => {
1154
- const q = (term || '').trim().toLowerCase();
1155
- const list = [
1156
- { name: pc.yellow('← Cancel'), value: '__cancel__' },
1157
- ...SLASH_COMMANDS
1158
- ];
1159
- if (!q)
1160
- return list;
1161
- return list.filter(item => item.name.toLowerCase().includes(q) || item.value.toLowerCase().includes(q));
1162
- },
1163
- pageSize: 12
1090
+ const picked = await runFinder({
1091
+ title: t('commands.title'),
1092
+ subtitle: `${SLASH_COMMANDS.length}`,
1093
+ placeholder: t('commands.placeholder'),
1094
+ labelWidth: 14,
1095
+ items: SLASH_COMMANDS.map((c) => {
1096
+ const [name, ...rest] = c.name.split(/\s+-\s+/);
1097
+ return { value: c.value, label: name.trim(), detail: rest.join(' - ') };
1098
+ }),
1164
1099
  });
1165
- if (!picked || picked === '__cancel__') {
1100
+ if (!picked) {
1166
1101
  continue;
1167
1102
  }
1168
1103
  if (picked === '/session del') {
@@ -1238,43 +1173,21 @@ export async function main() {
1238
1173
  log.warn('No saved sessions found.');
1239
1174
  continue;
1240
1175
  }
1241
- let chosenSessionId;
1242
- try {
1243
- chosenSessionId = await search({
1244
- message: 'Select a session to resume (type to filter, Esc to cancel):',
1245
- source: async (term) => {
1246
- const q = (term || '').trim().toLowerCase();
1247
- const list = [
1248
- { name: pc.yellow('← Back / Cancel'), value: '__cancel__', description: 'Return to chat' },
1249
- { name: pc.red('× Delete sessions... (/session del)'), value: '__delete_mode__', description: 'Selectively delete saved sessions' }
1250
- ];
1251
- for (const s of sessions) {
1252
- const timeStr = formatSessionTime(s.updatedAt);
1253
- const msgCount = s.messages.filter(m => m.role !== 'system').length;
1254
- const shortId = s.id.split('_').pop();
1255
- list.push({
1256
- name: `${pc.dim(`[#${shortId}]`)} ${s.title}`,
1257
- value: s.id,
1258
- description: `${timeStr} • ${msgCount} msgs • ${s.model || 'default'}`
1259
- });
1260
- }
1261
- if (!q)
1262
- return list;
1263
- return list.filter(item => item.name.toLowerCase().includes(q) || item.value.toLowerCase().includes(q));
1264
- },
1265
- pageSize: 10
1266
- });
1267
- }
1268
- catch {
1269
- continue;
1270
- }
1271
- if (chosenSessionId === '__cancel__') {
1272
- continue;
1273
- }
1274
- if (chosenSessionId === '__delete_mode__') {
1275
- await handleSessionDelete(config.banner || 'full');
1176
+ const picked = await runFinder({
1177
+ title: t('resume.title'),
1178
+ subtitle: `${sessions.length}`,
1179
+ placeholder: t('resume.placeholder'),
1180
+ labelWidth: 36,
1181
+ items: sessions.map((s) => ({
1182
+ value: s.id,
1183
+ label: s.title,
1184
+ detail: sessionDetail(s),
1185
+ keywords: `${s.id} ${s.model || ''}`,
1186
+ })),
1187
+ });
1188
+ if (picked === undefined)
1276
1189
  continue;
1277
- }
1190
+ const chosenSessionId = picked;
1278
1191
  const loaded = await SessionManager.loadSession(chosenSessionId);
1279
1192
  if (loaded) {
1280
1193
  sessionManager.setLoadedSession(loaded);
@@ -1514,9 +1427,12 @@ export async function main() {
1514
1427
  // A user message, not an assistant one: the Anthropic format cannot start with the assistant.
1515
1428
  history.addMessage({
1516
1429
  role: 'user',
1517
- content: `[CONVERSATION COMPACTED] Summary of our conversation so far:\n${res.content}`
1430
+ content: `${COMPACT_MARK} Summary of our conversation so far:\n${res.content}`
1518
1431
  });
1519
- log.success(pc.bold(pc.green('Context successfully compacted! Freed up tokens while retaining task summary.')));
1432
+ const item = { kind: 'compact', summary: res.content };
1433
+ transcript.push(item);
1434
+ console.log();
1435
+ console.log(renderItem(item, { cols: process.stdout.columns || 80, thinkingExpanded, outputExpanded }).join('\n'));
1520
1436
  }
1521
1437
  catch (err) {
1522
1438
  s.stop();