termsearch 0.3.3 → 0.3.5

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/bin/termsearch.js CHANGED
@@ -209,7 +209,7 @@ async function cmdStart(flags) {
209
209
  }
210
210
 
211
211
  writeFileSync(paths.pid, String(child.pid));
212
- ok(`Started (PID ${child.pid})`);
212
+ ok(`TermSearch v${VERSION} started (PID ${child.pid})`);
213
213
  info(`${BOLD}${getUrl()}${RESET}`);
214
214
  info(`Logs: ${paths.log}`);
215
215
  }
@@ -242,10 +242,11 @@ async function cmdRestart(flags) {
242
242
  await cmdStart(flags);
243
243
  }
244
244
 
245
- function cmdStatus() {
245
+ async function cmdStatus() {
246
246
  const { running, pid } = getStatus();
247
247
  const paths = getPaths();
248
248
  console.log('');
249
+ console.log(` ${BOLD}TermSearch v${VERSION}${RESET}`);
249
250
  if (running) {
250
251
  ok(`${BOLD}Running${RESET} (PID ${pid})`);
251
252
  info(`${getUrl()}`);
@@ -255,6 +256,7 @@ function cmdStatus() {
255
256
  warn('Stopped');
256
257
  info(`Run ${BOLD}termsearch start${RESET} to start`);
257
258
  }
259
+ await printUpdateHint();
258
260
  console.log('');
259
261
  }
260
262
 
@@ -333,6 +335,9 @@ async function cmdDoctor() {
333
335
  } catch (e) { err(`HTTP: cannot reach ${getUrl()} — ${e.message}`); allOk = false; }
334
336
  }
335
337
 
338
+ // npm update check
339
+ await printUpdateHint();
340
+
336
341
  console.log('');
337
342
  if (allOk) { ok(`${GREEN}All checks passed${RESET}`); }
338
343
  else { warn('Some checks failed — see above'); }
@@ -428,6 +433,39 @@ ${BOLD}URL:${RESET} http://localhost:3000
428
433
  `);
429
434
  }
430
435
 
436
+ // ─── Update check ─────────────────────────────────────────────────────────
437
+
438
+ async function checkNpmUpdate() {
439
+ try {
440
+ const ac = new AbortController();
441
+ setTimeout(() => ac.abort(), 4000);
442
+ const r = await fetch('https://registry.npmjs.org/termsearch/latest', { signal: ac.signal });
443
+ if (!r.ok) return null;
444
+ const data = await r.json();
445
+ const latest = data.version;
446
+ if (!latest) return null;
447
+ if (latest === VERSION) return { upToDate: true, latest };
448
+ // Simple semver compare: split, compare numerically
449
+ const cur = VERSION.split('.').map(Number);
450
+ const lat = latest.split('.').map(Number);
451
+ const newer = lat[0] > cur[0] || (lat[0] === cur[0] && lat[1] > cur[1]) || (lat[0] === cur[0] && lat[1] === cur[1] && lat[2] > cur[2]);
452
+ return { upToDate: !newer, latest };
453
+ } catch {
454
+ return null;
455
+ }
456
+ }
457
+
458
+ async function printUpdateHint() {
459
+ const update = await checkNpmUpdate();
460
+ if (!update) return;
461
+ if (update.upToDate) {
462
+ ok(`Up to date (v${VERSION})`);
463
+ } else {
464
+ warn(`Update available: v${VERSION} → v${update.latest}`);
465
+ info(`Run ${BOLD}npm install -g termsearch${RESET} to update`);
466
+ }
467
+ }
468
+
431
469
  // ─── Utilities ────────────────────────────────────────────────────────────
432
470
 
433
471
  function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
@@ -15,6 +15,7 @@ const state = {
15
15
  aiExpanded: false,
16
16
  aiStartTime: null,
17
17
  aiLatencyMs: null,
18
+ aiSession: [], // { q, r }[] — ultimi 4 summary (passati all'AI come contesto)
18
19
  aiLastQuery: null,
19
20
  aiLastResults: null,
20
21
  aiLastLang: null,
@@ -44,6 +45,47 @@ const state = {
44
45
  })(),
45
46
  };
46
47
 
48
+ let mobileBarCleanup = null;
49
+
50
+ function clearMobileBarLayout() {
51
+ if (typeof mobileBarCleanup === 'function') {
52
+ mobileBarCleanup();
53
+ }
54
+ mobileBarCleanup = null;
55
+ document.documentElement.style.setProperty('--mobile-bar-height', '0px');
56
+ }
57
+
58
+ function bindMobileBarLayout(mobileBar) {
59
+ clearMobileBarLayout();
60
+ if (!mobileBar) return;
61
+
62
+ const media = window.matchMedia('(max-width: 640px)');
63
+ const root = document.documentElement;
64
+ const update = () => {
65
+ if (!media.matches || !mobileBar.isConnected) {
66
+ root.style.setProperty('--mobile-bar-height', '0px');
67
+ return;
68
+ }
69
+ const h = Math.ceil(mobileBar.getBoundingClientRect().height);
70
+ root.style.setProperty('--mobile-bar-height', `${h}px`);
71
+ };
72
+
73
+ const onResize = () => update();
74
+ window.addEventListener('resize', onResize);
75
+
76
+ let observer = null;
77
+ if (typeof ResizeObserver !== 'undefined') {
78
+ observer = new ResizeObserver(update);
79
+ observer.observe(mobileBar);
80
+ }
81
+
82
+ requestAnimationFrame(update);
83
+ mobileBarCleanup = () => {
84
+ window.removeEventListener('resize', onResize);
85
+ if (observer) observer.disconnect();
86
+ };
87
+ }
88
+
47
89
  function buildSearchHash(query, category = 'web') {
48
90
  const params = new URLSearchParams();
49
91
  if (query) params.set('q', query);
@@ -225,7 +267,6 @@ const AI_PRESETS = [
225
267
  { id: 'chutes', label: 'Chutes.ai TEE', api_base: 'https://llm.chutes.ai/v1', keyRequired: true, defaultModel: 'deepseek-ai/DeepSeek-V3.2-TEE' },
226
268
  { id: 'anthropic',label: 'Anthropic', api_base: 'https://api.anthropic.com/v1', keyRequired: true, defaultModel: 'claude-3-5-haiku-latest' },
227
269
  { id: 'openai', label: 'OpenAI', api_base: 'https://api.openai.com/v1', keyRequired: true, defaultModel: 'gpt-4o-mini' },
228
- { id: 'openrouter', label: 'OpenRoute/OpenRouter', api_base: 'https://openrouter.ai/api/v1', keyRequired: true, defaultModel: 'openai/gpt-4o-mini' },
229
270
  ];
230
271
 
231
272
  const ENGINE_GROUPS = [
@@ -240,7 +281,7 @@ const ENGINE_GROUPS = [
240
281
 
241
282
  const ENGINE_PRESETS = [
242
283
  { id: 'all', label: 'All', engines: [] },
243
- { id: 'balanced', label: 'Balanced', engines: ['duckduckgo', 'wikipedia', 'bing', 'startpage', 'github', 'reddit', 'youtube'] },
284
+ { id: 'balanced', label: 'Balanced', engines: ['duckduckgo', 'wikipedia', 'bing', 'brave', 'github', 'reddit', 'youtube'] },
244
285
  { id: 'github', label: 'GitHub Focus', engines: ['github-api', 'github', 'duckduckgo', 'wikipedia'] },
245
286
  ];
246
287
 
@@ -443,32 +484,41 @@ function renderAiPanel() {
443
484
  const isDone = state.aiStatus === 'done';
444
485
  const isError = state.aiStatus === 'error';
445
486
  const dotsClass = isDone ? 'done' : isError ? 'error' : '';
446
- const statusText = state.aiStatus === 'loading' ? 'Thinking…'
447
- : state.aiStatus === 'streaming' ? 'Generating…'
448
- : isDone ? 'AI Summary' : 'Error';
449
487
 
450
- // Dots
488
+ // Row 1: dots + "AI" (always) + model + latency
451
489
  const dotsEl = el('div', { className: 'ai-dots' });
452
490
  ['violet', 'indigo', 'dim'].forEach(c => {
453
491
  dotsEl.append(el('div', { className: `ai-dot ${dotsClass || c}` }));
454
492
  });
455
-
456
- // Latency
457
493
  const latMs = state.aiLatencyMs;
458
494
  const latLabel = latMs != null ? (latMs < 1000 ? `${latMs}ms` : `${(latMs / 1000).toFixed(1)}s`) : null;
459
-
460
- // Header
461
- const headerLeft = el('div', { className: 'panel-header-left' },
495
+ const row1 = el('div', { className: 'panel-header-left' },
462
496
  dotsEl,
463
- el('span', { className: 'panel-label' }, statusText),
497
+ el('span', { className: 'panel-label' }, 'AI'),
464
498
  state.aiMeta?.model ? el('span', { className: 'ai-model-label' }, state.aiMeta.model) : null,
465
499
  latLabel ? el('span', { className: 'ai-latency-label' }, `· ${latLabel}`) : null,
466
500
  );
501
+
502
+ // Row 2: status text (violet) + step/fetch meta
503
+ const statusText = isError ? 'Error'
504
+ : isDone ? 'Done'
505
+ : state.aiStatus === 'loading' ? 'Thinking…' : 'Generating…';
506
+ const statusColor = isError ? '#f87171' : isDone ? '#a78bfa' : '#a78bfa';
507
+ const lastStep = state.aiSteps.length > 0 ? state.aiSteps[state.aiSteps.length - 1] : null;
508
+ const metaText = isDone && state.aiMeta?.fetchedCount ? `· ${state.aiMeta.fetchedCount} pages read`
509
+ : isLoading && lastStep ? `· ${lastStep}` : '';
510
+ const row2 = el('div', { className: 'ai-status-row' },
511
+ el('span', { className: 'ai-status-text', style: `color:${statusColor}` }, statusText),
512
+ metaText ? el('span', { className: 'ai-status-meta' }, metaText) : null,
513
+ );
514
+
467
515
  const chevronPath = state.aiExpanded ? '<polyline points="18 15 12 9 6 15"/>' : '<polyline points="6 9 12 15 18 9"/>';
468
516
  const expandBtn = el('button', { className: 'ai-expand-btn', type: 'button', title: state.aiExpanded ? 'Collapse' : 'Expand' });
469
517
  expandBtn.innerHTML = svg(chevronPath, 14);
470
518
  expandBtn.onclick = () => { state.aiExpanded = !state.aiExpanded; renderAiPanel(); };
471
- const header = el('div', { className: 'panel-header' }, headerLeft, expandBtn);
519
+
520
+ const headerInner = el('div', { className: 'ai-header-inner' }, row1, row2);
521
+ const header = el('div', { className: 'panel-header' }, headerInner, expandBtn);
472
522
 
473
523
  // Progress bar
474
524
  const showProgress = isLoading && state.aiProgress > 0;
@@ -509,6 +559,19 @@ function renderAiPanel() {
509
559
  }),
510
560
  ) : null;
511
561
 
562
+ // Session memory (shown when expanded + more than 1 entry, all except current)
563
+ const prevSession = state.aiSession.slice(0, -1);
564
+ const sessionEl = (state.aiExpanded && prevSession.length > 0) ? el('div', { className: 'ai-session' },
565
+ el('p', { className: 'ai-session-label' }, 'Session'),
566
+ ...prevSession.map((item, i) =>
567
+ el('div', { className: 'ai-session-item' },
568
+ el('span', { className: 'ai-session-num' }, `${i + 1}.`),
569
+ el('span', { className: 'ai-session-q' }, item.q),
570
+ el('span', { className: 'ai-session-r' }, `→ ${item.r}`),
571
+ )
572
+ ),
573
+ ) : null;
574
+
512
575
  // Footer: retry + expand/collapse
513
576
  const retryBtn = el('button', { className: 'ai-retry-btn', type: 'button' }, 'Retry');
514
577
  retryBtn.onclick = () => {
@@ -526,6 +589,7 @@ function renderAiPanel() {
526
589
  if (stepsEl) panel.append(stepsEl);
527
590
  panel.append(contentEl);
528
591
  if (sourcesEl) panel.append(sourcesEl);
592
+ if (sessionEl) panel.append(sessionEl);
529
593
  panel.append(footer);
530
594
 
531
595
  if (state.aiStatus === 'streaming' && state.aiSummary.length < 60) {
@@ -887,7 +951,7 @@ async function startAiSummary(query, results, lang) {
887
951
  const r = await fetch('/api/ai-summary', {
888
952
  method: 'POST',
889
953
  headers: { 'Content-Type': 'application/json' },
890
- body: JSON.stringify({ query, lang, results: results.slice(0, 10), stream: true }),
954
+ body: JSON.stringify({ query, lang, results: results.slice(0, 10), stream: true, session: state.aiSession }),
891
955
  });
892
956
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
893
957
 
@@ -917,6 +981,12 @@ async function startAiSummary(query, results, lang) {
917
981
  state.aiSources = Array.isArray(d.sites) ? d.sites.map(sanitizeHttpUrl).filter(Boolean) : [];
918
982
  state.aiMeta = { fetchedCount: d.fetchedCount, model: d.model };
919
983
  state.aiLatencyMs = Date.now() - state.aiStartTime;
984
+ // Save to session memory (max 4 entries, skip if already saved for this query)
985
+ const lastEntry = state.aiSession[state.aiSession.length - 1];
986
+ if (state.aiSummary && (!lastEntry || lastEntry.q !== query)) {
987
+ const r = state.aiSummary.split(/[.!?\n]/)[0].slice(0, 100);
988
+ state.aiSession = [...state.aiSession.slice(-3), { q: query, r }];
989
+ }
920
990
  renderAiPanel();
921
991
  }
922
992
  } catch { /* ignore */ }
@@ -937,12 +1007,13 @@ function renderApp() {
937
1007
  app.innerHTML = '';
938
1008
 
939
1009
  if (!state.query) {
1010
+ clearMobileBarLayout();
940
1011
  renderHome(app);
941
1012
  return;
942
1013
  }
943
1014
 
944
1015
  // Results page
945
- const header = el('div', { className: 'header' },
1016
+ const header = el('div', { className: 'header hide-mobile' },
946
1017
  el('div', { className: 'logo-text', onClick: () => { state.query = ''; state.category = 'web'; navigate('#/'); renderApp(); } },
947
1018
  'Term', el('strong', {}, 'Search'),
948
1019
  ),
@@ -953,26 +1024,47 @@ function renderApp() {
953
1024
  el('button', { className: 'btn-icon', title: 'Toggle theme', onClick: toggleTheme }, iconEl('theme')),
954
1025
  ),
955
1026
  );
956
- const categoryBar = el('div', { className: 'category-tabs' });
1027
+
1028
+ const categoryBar = el('div', { className: 'category-tabs hide-mobile' });
957
1029
  const categories = [
958
1030
  { id: 'web', label: 'Web' },
959
1031
  { id: 'images', label: 'Images' },
960
1032
  { id: 'news', label: 'News' },
961
1033
  ];
962
- categories.forEach((cat) => {
963
- categoryBar.append(el('button', {
964
- className: `cat-tab ${state.category === cat.id ? 'active' : ''}`,
965
- onClick: () => {
966
- if (state.category === cat.id) return;
967
- state.category = cat.id;
968
- navigate(buildSearchHash(state.query, state.category));
969
- if (state.query) doSearch(state.query, state.category);
970
- },
971
- type: 'button',
972
- }, cat.label));
973
- });
1034
+ const buildCatTabs = (container) => {
1035
+ categories.forEach((cat) => {
1036
+ container.append(el('button', {
1037
+ className: `cat-tab ${state.category === cat.id ? 'active' : ''}`,
1038
+ onClick: () => {
1039
+ if (state.category === cat.id) return;
1040
+ state.category = cat.id;
1041
+ navigate(buildSearchHash(state.query, state.category));
1042
+ if (state.query) doSearch(state.query, state.category);
1043
+ },
1044
+ type: 'button',
1045
+ }, cat.label));
1046
+ });
1047
+ };
1048
+ buildCatTabs(categoryBar);
974
1049
  categoryBar.append(EnginePicker());
975
1050
 
1051
+ const mobileTabs = el('div', { className: 'mobile-bar-tabs' });
1052
+ buildCatTabs(mobileTabs);
1053
+ const mobileBar = el('div', { className: 'mobile-bar' },
1054
+ el('div', { className: 'mobile-bar-search' }, SearchForm(state.query, (q, cat) => { state.query = q; doSearch(q, cat); })),
1055
+ mobileTabs,
1056
+ el('div', { className: 'mobile-bar-row' },
1057
+ el('div', {
1058
+ className: 'mobile-logo',
1059
+ onClick: () => { state.query = ''; state.category = 'web'; navigate('#/'); renderApp(); },
1060
+ }, 'Term', el('strong', {}, 'Search')),
1061
+ EnginePicker(),
1062
+ LangPicker(),
1063
+ el('button', { className: 'btn-icon', title: 'Settings', onClick: () => navigate('#/settings') }, iconEl('settings')),
1064
+ el('button', { className: 'btn-icon', title: 'Toggle theme', onClick: toggleTheme }, iconEl('theme')),
1065
+ ),
1066
+ );
1067
+
976
1068
  const main = el('div', { className: 'main' });
977
1069
 
978
1070
  // AI panel placeholder
@@ -1016,7 +1108,8 @@ function renderApp() {
1016
1108
  if (socPanel) main.append(socPanel);
1017
1109
  }
1018
1110
 
1019
- app.append(header, categoryBar, main);
1111
+ app.append(header, categoryBar, main, mobileBar);
1112
+ bindMobileBarLayout(mobileBar);
1020
1113
  renderAiPanel();
1021
1114
  }
1022
1115
 
@@ -1051,6 +1144,7 @@ async function renderSettings() {
1051
1144
  const app = document.getElementById('app');
1052
1145
  if (!app) return;
1053
1146
  app.innerHTML = '';
1147
+ clearMobileBarLayout();
1054
1148
 
1055
1149
  let cfg = state.config;
1056
1150
  if (!cfg) {
@@ -1066,6 +1160,9 @@ async function renderSettings() {
1066
1160
  const brave = cfg.brave || {};
1067
1161
  const mojeek = cfg.mojeek || {};
1068
1162
  const searxng = cfg.searxng || {};
1163
+ const yandexCfg = cfg.yandex || {};
1164
+ const ahmiaCfg = cfg.ahmia || {};
1165
+ const marginaliaCfg = cfg.marginalia || {};
1069
1166
  const detectedPreset = detectPresetFromBase(ai.api_base);
1070
1167
 
1071
1168
  const header = el('div', { className: 'header' },
@@ -1243,6 +1340,9 @@ async function renderSettings() {
1243
1340
  brave: { enabled: isChecked('brave-enabled') },
1244
1341
  mojeek: { enabled: isChecked('mojeek-enabled') },
1245
1342
  searxng:{ url: val('searxng-url'), enabled: isChecked('searxng-enabled') },
1343
+ yandex: { enabled: isChecked('yandex-enabled') },
1344
+ ahmia: { enabled: isChecked('ahmia-enabled') },
1345
+ marginalia: { enabled: isChecked('marginalia-enabled') },
1246
1346
  };
1247
1347
  if (aiKey) update.ai.api_key = aiKey;
1248
1348
  if (braveKey) update.brave.api_key = braveKey;
@@ -1375,7 +1475,7 @@ async function renderSettings() {
1375
1475
  el('label', { className: 'form-label', for: 'ai-base' }, 'API Endpoint'),
1376
1476
  makeInput('ai-base', ai.api_base, 'http://localhost:11434/v1'),
1377
1477
  el('div', { className: 'form-hint' },
1378
- 'Included presets: LocalHost (Ollama · LM Studio · llama.cpp) · Chutes.ai TEE · Anthropic · OpenAI · OpenRoute/OpenRouter',
1478
+ 'Included presets: LocalHost (Ollama · LM Studio · llama.cpp) · Chutes.ai TEE · Anthropic · OpenAI',
1379
1479
  el('br', {}),
1380
1480
  'You can also keep custom OpenAI-compatible endpoints.',
1381
1481
  ),
@@ -1468,7 +1568,7 @@ async function renderSettings() {
1468
1568
  ),
1469
1569
 
1470
1570
  // SearXNG
1471
- el('div', { style: 'padding:10px 0' },
1571
+ el('div', { style: 'padding:10px 0;border-bottom:1px solid var(--border2)' },
1472
1572
  el('div', { className: 'toggle-row' },
1473
1573
  el('span', { className: 'toggle-label' }, 'SearXNG (self-hosted)'),
1474
1574
  el('label', { className: 'toggle' },
@@ -1483,6 +1583,33 @@ async function renderSettings() {
1483
1583
  el('div', { id: 'provider-test-searxng', style: 'display:none' }),
1484
1584
  ),
1485
1585
 
1586
+ // Uncensored / Alternative
1587
+ el('div', { style: 'padding:10px 0' },
1588
+ el('div', { style: 'font-size:11px;color:var(--text2);margin-bottom:8px;letter-spacing:0.04em;text-transform:uppercase' }, 'Uncensored / Alternative'),
1589
+ el('div', { className: 'toggle-row' },
1590
+ el('span', { className: 'toggle-label' }, 'Yandex (HTML scraper, no key)'),
1591
+ el('label', { className: 'toggle' },
1592
+ el('input', { type: 'checkbox', id: 'yandex-enabled', ...(yandexCfg.enabled !== false ? { checked: '' } : {}) }),
1593
+ el('span', { className: 'toggle-slider' }),
1594
+ ),
1595
+ ),
1596
+ el('div', { className: 'toggle-row', style: 'margin-top:6px' },
1597
+ el('span', { className: 'toggle-label' }, 'Ahmia (Tor index, no key)'),
1598
+ el('label', { className: 'toggle' },
1599
+ el('input', { type: 'checkbox', id: 'ahmia-enabled', ...(ahmiaCfg.enabled !== false ? { checked: '' } : {}) }),
1600
+ el('span', { className: 'toggle-slider' }),
1601
+ ),
1602
+ ),
1603
+ el('div', { className: 'toggle-row', style: 'margin-top:6px' },
1604
+ el('span', { className: 'toggle-label' }, 'Marginalia (indie index, no key)'),
1605
+ el('label', { className: 'toggle' },
1606
+ el('input', { type: 'checkbox', id: 'marginalia-enabled', ...(marginaliaCfg.enabled !== false ? { checked: '' } : {}) }),
1607
+ el('span', { className: 'toggle-slider' }),
1608
+ ),
1609
+ ),
1610
+ el('div', { className: 'form-hint', style: 'margin-top:6px' }, 'Zero-config scraper engines. May be blocked by CAPTCHA under heavy use.'),
1611
+ ),
1612
+
1486
1613
  el('div', { style: 'margin-top:12px;display:flex;align-items:center;gap:8px' },
1487
1614
  el('button', { className: 'btn btn-primary', onClick: saveSettings }, 'Save All'),
1488
1615
  saveAlertEl,
@@ -13,6 +13,7 @@
13
13
  --link: #8ab4f8;
14
14
  --link-h: #aecbfa;
15
15
  --url: #34a853;
16
+ --mobile-bar-height: 0px;
16
17
  --radius: 12px;
17
18
  --radius-sm: 8px;
18
19
  --radius-xs: 6px;
@@ -565,10 +566,14 @@ a:hover { color: var(--link-h); }
565
566
  .ai-dot.dim { background: #5b21b6; animation: pulse 1.4s ease-in-out 300ms infinite; }
566
567
  .ai-dot.done { background: #34d399; animation: none; }
567
568
  .ai-dot.error { background: #f87171; animation: none; }
568
- .panel-ai .panel-header { cursor: default; }
569
+ .panel-ai .panel-header { cursor: default; align-items: flex-start; }
570
+ .ai-header-inner { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
571
+ .ai-status-row { display: flex; align-items: center; gap: 6px; }
572
+ .ai-status-text { font-size: 14px; font-weight: 500; }
573
+ .ai-status-meta { font-size: 10px; color: #4b5563; }
569
574
  .ai-model-label { font-size: 10px; color: var(--text3); margin-left: 6px; }
570
575
  .ai-latency-label{ font-size: 10px; color: #4b5563; margin-left: 4px; }
571
- .ai-expand-btn { background: none; border: none; color: var(--text3); cursor: pointer; padding: 0; margin-left: auto; display: flex; align-items: center; }
576
+ .ai-expand-btn { background: none; border: none; color: var(--text3); cursor: pointer; padding: 0; margin-left: 8px; margin-top: 2px; display: flex; align-items: center; flex-shrink: 0; }
572
577
  .ai-expand-btn:hover { color: var(--text2); }
573
578
 
574
579
  /* Progress bar */
@@ -611,6 +616,14 @@ a:hover { color: var(--link-h); }
611
616
  }
612
617
  .ai-source-pill:hover { color: #a78bfa; border-color: #4c1d95; }
613
618
 
619
+ /* Session memory */
620
+ .ai-session { margin-top: 10px; padding-top: 8px; border-top: 1px solid #1b1b2d; }
621
+ .ai-session-label { font-size: 10px; color: #374151; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.05em; }
622
+ .ai-session-item { display: flex; align-items: baseline; gap: 5px; margin-bottom: 3px; flex-wrap: wrap; }
623
+ .ai-session-num { font-size: 9px; color: #374151; flex-shrink: 0; }
624
+ .ai-session-q { font-size: 10px; color: #4b5563; }
625
+ .ai-session-r { font-size: 10px; color: #374151; }
626
+
614
627
  /* Footer */
615
628
  .ai-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; }
616
629
  .ai-retry-btn { font-size: 12px; color: #a78bfa; background: none; border: none; cursor: pointer; padding: 0; }
@@ -904,14 +917,69 @@ a:hover { color: var(--link-h); }
904
917
  }
905
918
  .footer-link:hover { color: var(--text2); }
906
919
 
920
+ /* ─── Mobile bottom bar ───────────────────────────────────────────────────── */
921
+ .mobile-bar {
922
+ display: none;
923
+ }
924
+ .mobile-logo {
925
+ font-size: 15px;
926
+ font-weight: 300;
927
+ letter-spacing: -0.2px;
928
+ color: var(--text);
929
+ white-space: nowrap;
930
+ user-select: none;
931
+ cursor: pointer;
932
+ }
933
+ .mobile-logo strong {
934
+ font-weight: 700;
935
+ background: linear-gradient(90deg, #a78bfa, #818cf8);
936
+ -webkit-background-clip: text;
937
+ -webkit-text-fill-color: transparent;
938
+ background-clip: text;
939
+ }
940
+ @media (max-width: 640px) {
941
+ .mobile-bar {
942
+ display: flex;
943
+ flex-direction: column;
944
+ position: fixed;
945
+ bottom: 0; left: 0; right: 0;
946
+ background: var(--bg);
947
+ border-top: 1px solid var(--border2);
948
+ box-shadow: 0 -12px 28px rgba(0,0,0,0.45);
949
+ z-index: 220;
950
+ padding-bottom: max(6px, env(safe-area-inset-bottom, 0px));
951
+ transform: translateZ(0);
952
+ }
953
+ }
954
+ .mobile-bar-search { padding: 8px 12px 4px; }
955
+ .mobile-bar-search .search-form { width: 100%; }
956
+ .mobile-bar-tabs {
957
+ display: flex;
958
+ align-items: center;
959
+ gap: 4px;
960
+ padding: 2px 12px 4px;
961
+ overflow-x: auto;
962
+ scrollbar-width: none;
963
+ }
964
+ .mobile-bar-tabs::-webkit-scrollbar { display: none; }
965
+ .mobile-bar-row {
966
+ display: flex;
967
+ align-items: center;
968
+ gap: 6px;
969
+ padding: 4px 12px 2px;
970
+ }
971
+ .mobile-bar-row .engine-picker { flex-shrink: 0; }
972
+ .mobile-bar-row .lang-wrap { margin-left: auto; }
973
+
907
974
  /* ─── Responsive ──────────────────────────────────────────────────────────── */
908
975
  @media (max-width: 640px) {
909
- .header { padding: 8px 12px; gap: 8px; }
910
- .category-tabs { top: 48px; padding: 7px 12px 9px; gap: 5px; flex-wrap: wrap; }
976
+ /* Hide desktop header + category bar mobile uses bottom bar */
977
+ .header.hide-mobile, .category-tabs.hide-mobile { display: none !important; }
978
+ .main { padding-bottom: calc(20px + var(--mobile-bar-height, 0px) + env(safe-area-inset-bottom, 0px)); }
979
+
911
980
  .cat-tab { font-size: 10px; padding: 4px 8px; }
912
- .engine-picker { width: 100%; margin-left: 0; }
913
- .engine-picker-summary { width: 100%; justify-content: space-between; }
914
- .engine-picker-body { width: calc(100vw - 24px); right: -6px; }
981
+ .mobile-bar-row .engine-picker-summary { font-size: 11px; padding: 3px 6px; }
982
+ .engine-picker-body { width: calc(100vw - 24px); right: -6px; bottom: calc(100% + 8px); top: auto; }
915
983
  .logo-text { font-size: 15px; }
916
984
  .home-logo { font-size: 40px; }
917
985
  .home-tagline { letter-spacing: 0.08em; margin-bottom: 20px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termsearch",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Personal search engine for Termux/Linux/macOS — zero-config, privacy-first, AI-optional",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,9 +17,15 @@ function ok(msg) { console.log(` ${GREEN}✓${RESET} ${msg}`); }
17
17
  function warn(msg) { console.log(` ${YELLOW}⚠${RESET} ${msg}`); }
18
18
  function info(msg) { console.log(` ${CYAN}→${RESET} ${msg}`); }
19
19
 
20
+ let VERSION = '0.0.0';
21
+ try {
22
+ const pkgPath = new URL('../package.json', import.meta.url);
23
+ VERSION = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version || VERSION;
24
+ } catch { /* ignore */ }
25
+
20
26
  try {
21
27
  console.log('');
22
- console.log(`${BOLD} TermSearch — post-install check${RESET}`);
28
+ console.log(`${BOLD} TermSearch v${VERSION}${RESET} — post-install check`);
23
29
  console.log('');
24
30
 
25
31
  // ── Node.js version ──────────────────────────────────────────────────────
@@ -76,7 +82,7 @@ try {
76
82
  }
77
83
 
78
84
  console.log('');
79
- info(`Run ${BOLD}termsearch${RESET}${CYAN} then open http://localhost:3000`);
85
+ info(`Run ${BOLD}termsearch${RESET}${CYAN} to start v${VERSION} → http://localhost:3000`);
80
86
  console.log('');
81
87
 
82
88
  } catch {