aisessions 1.0.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -3,7 +3,7 @@ import { createServer } from '../src/server.js'
3
3
  import { parseArgs } from 'node:util'
4
4
  import { exec } from 'node:child_process'
5
5
 
6
- const VERSION = '1.0.0'
6
+ const VERSION = '1.1.2'
7
7
 
8
8
  const { values } = parseArgs({
9
9
  options: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aisessions",
3
- "version": "1.0.0",
3
+ "version": "1.1.2",
4
4
  "description": "Browse, manage and analyze your AI coding agent sessions — Claude Code, Codex, and more",
5
5
  "bin": {
6
6
  "aisessions": "bin/cli.js"
@@ -25,5 +25,8 @@
25
25
  "aisessions",
26
26
  "ccusage"
27
27
  ],
28
- "license": "MIT"
28
+ "license": "MIT",
29
+ "dependencies": {
30
+ "ccusage": "^20.0.6"
31
+ }
29
32
  }
package/src/router.js CHANGED
@@ -3,6 +3,7 @@ import { getAllSessions, getInstalledAgents } from './agents/index.js'
3
3
  import { getUsageData } from './usage/index.js'
4
4
  import { listTrash, moveToTrash, restoreFromTrash, purgeTrash } from './storage/trash.js'
5
5
  import { listBackups, createBackup, restoreBackup, deleteBackup } from './storage/backup.js'
6
+ import { getSessionDetail } from './session/detail.js'
6
7
 
7
8
  function sendJson(res, data, status = 200) {
8
9
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
@@ -60,6 +61,9 @@ export async function router(req, res) {
60
61
  const sessions = await getAllSessions()
61
62
  return sendJson(res, sessions.map(({ date, ...rest }) => rest))
62
63
  }
64
+ if (method === 'GET' && pathname === '/api/session') {
65
+ return sendJson(res, await getSessionDetail(query.path || ''))
66
+ }
63
67
 
64
68
  // ── Usage (supports ?agent=claude) ───────────────────────────────────────
65
69
  if (method === 'GET' && pathname === '/api/usage') {
@@ -0,0 +1,59 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { parseJsonl, readLimited } from '../agents/utils.js'
3
+
4
+ const MAX_DETAIL_BYTES = 2_000_000
5
+
6
+ function cleanText(v) {
7
+ return String(v || '').replace(/\s+/g, ' ').trim()
8
+ }
9
+
10
+ function extractText(v) {
11
+ if (typeof v === 'string') return [v]
12
+ if (Array.isArray(v)) return v.flatMap(extractText)
13
+ if (v && typeof v === 'object') {
14
+ const out = []
15
+ if (typeof v.text === 'string') out.push(v.text)
16
+ if (v.content) out.push(...extractText(v.content))
17
+ if (v.message) out.push(...extractText(v.message))
18
+ if (v.parts) out.push(...extractText(v.parts))
19
+ return out
20
+ }
21
+ return []
22
+ }
23
+
24
+ function roleOf(obj) {
25
+ const role = cleanText(obj?.role || obj?.message?.role || obj?.payload?.role).toLowerCase()
26
+ const type = cleanText(obj?.type || obj?.item_type).toLowerCase()
27
+ if (role) return role
28
+ if (type.includes('user')) return 'user'
29
+ if (type.includes('assistant') || type.includes('model')) return 'assistant'
30
+ if (type.includes('system')) return 'system'
31
+ return type || 'entry'
32
+ }
33
+
34
+ function messagesFromObjs(objs) {
35
+ return objs.map((obj, idx) => {
36
+ const text = extractText(obj).map(cleanText).filter(Boolean).join('\n\n')
37
+ return {
38
+ index: idx + 1,
39
+ role: roleOf(obj),
40
+ text: text || JSON.stringify(obj).slice(0, 4000),
41
+ }
42
+ }).filter(m => m.text)
43
+ }
44
+
45
+ export async function getSessionDetail(path) {
46
+ if (!path || !existsSync(path)) return { ok: false, error: 'session file not found' }
47
+ const st = statSync(path)
48
+ if (!st.isFile()) return { ok: false, error: 'session path is not a file' }
49
+ const text = readLimited(path, MAX_DETAIL_BYTES)
50
+ const objs = parseJsonl(text)
51
+ return {
52
+ ok: true,
53
+ path,
54
+ size: st.size,
55
+ truncated: st.size > MAX_DETAIL_BYTES,
56
+ messages: messagesFromObjs(objs),
57
+ raw: text,
58
+ }
59
+ }
@@ -1,12 +1,37 @@
1
1
  import { homedir } from 'node:os'
2
2
  import { existsSync, mkdirSync, readdirSync, writeFileSync, readFileSync, statSync, rmSync, cpSync } from 'node:fs'
3
- import { join, basename } from 'node:path'
3
+ import { dirname, join, basename } from 'node:path'
4
4
 
5
5
  const HOME = homedir()
6
6
  const BACKUP_DIR = join(HOME, '.aisessions', 'backups')
7
7
 
8
8
  function ensureBackup() { mkdirSync(BACKUP_DIR, { recursive: true }) }
9
9
 
10
+ function safePart(v, fallback = 'item') {
11
+ return String(v || fallback).replace(/[^a-z0-9._-]+/gi, '_').replace(/^_+|_+$/g, '').slice(0, 80) || fallback
12
+ }
13
+
14
+ function findMetaFiles(dir) {
15
+ const out = []
16
+ try {
17
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
18
+ const full = join(dir, e.name)
19
+ if (e.isDirectory()) out.push(...findMetaFiles(full))
20
+ else if (e.name.endsWith('.meta.json') || e.name === 'meta.json') out.push(full)
21
+ }
22
+ } catch {}
23
+ return out
24
+ }
25
+
26
+ function removeStoredItem(metaPath, payloadPath) {
27
+ if (basename(metaPath) === 'meta.json') {
28
+ rmSync(dirname(metaPath), { recursive: true, force: true })
29
+ return
30
+ }
31
+ if (payloadPath && existsSync(payloadPath)) rmSync(payloadPath, { recursive: true, force: true })
32
+ if (existsSync(metaPath)) rmSync(metaPath, { force: true })
33
+ }
34
+
10
35
  function normaliseItems(raw) {
11
36
  return (Array.isArray(raw) ? raw : [raw]).map(i =>
12
37
  typeof i === 'string' ? { path: i } : i
@@ -17,9 +42,7 @@ export async function listBackups(agent) {
17
42
  ensureBackup()
18
43
  const items = []
19
44
  try {
20
- for (const f of readdirSync(BACKUP_DIR)) {
21
- if (!f.endsWith('.meta.json')) continue
22
- const mp = join(BACKUP_DIR, f)
45
+ for (const mp of findMetaFiles(BACKUP_DIR)) {
23
46
  try {
24
47
  const meta = JSON.parse(readFileSync(mp, 'utf8'))
25
48
  if (agent && agent !== 'all' && meta.agent && meta.agent !== agent) continue
@@ -39,10 +62,13 @@ export async function createBackup(rawItems, note = '') {
39
62
  if (!existsSync(p)) { results.push({ path: p, ok: false, error: 'not found' }); continue }
40
63
  const ts = Date.now()
41
64
  const name = basename(p)
42
- const backupName = ts + '_' + name
43
- const backupPath = join(BACKUP_DIR, backupName)
44
- const metaPath = join(BACKUP_DIR, backupName + '.meta.json')
65
+ const agentId = safePart(item.agent || 'unknown')
66
+ const backupName = ts + '_' + safePart(item.title || name)
67
+ const itemDir = join(BACKUP_DIR, agentId, backupName)
68
+ const backupPath = join(itemDir, 'payload')
69
+ const metaPath = join(itemDir, 'meta.json')
45
70
  const st = statSync(p)
71
+ mkdirSync(itemDir, { recursive: true })
46
72
  if (st.isDirectory()) cpSync(p, backupPath, { recursive: true })
47
73
  else cpSync(p, backupPath)
48
74
  const meta = {
@@ -72,6 +98,7 @@ export async function restoreBackup(metaPath) {
72
98
  ? meta.originalPath.replace(/(\.[^.]+)?$/, '_restored_' + Date.now() + '$1')
73
99
  : meta.originalPath
74
100
  const st = statSync(meta.backupPath)
101
+ mkdirSync(dirname(dest), { recursive: true })
75
102
  if (st.isDirectory()) cpSync(meta.backupPath, dest, { recursive: true })
76
103
  else cpSync(meta.backupPath, dest)
77
104
  return { ok: true, restoredTo: dest }
@@ -84,8 +111,7 @@ export async function deleteBackup(metaPaths) {
84
111
  try {
85
112
  let backupPath = mp.replace(/\.meta\.json$/, '')
86
113
  try { const m = JSON.parse(readFileSync(mp, 'utf8')); backupPath = m.backupPath || backupPath } catch {}
87
- if (existsSync(backupPath)) rmSync(backupPath, { recursive: true, force: true })
88
- if (existsSync(mp)) rmSync(mp, { force: true })
114
+ removeStoredItem(mp, backupPath)
89
115
  results.push({ metaPath: mp, ok: true })
90
116
  } catch (e) { results.push({ metaPath: mp, ok: false, error: e.message }) }
91
117
  }
@@ -1,12 +1,37 @@
1
1
  import { homedir } from 'node:os'
2
2
  import { existsSync, mkdirSync, readdirSync, writeFileSync, readFileSync, statSync, rmSync, cpSync } from 'node:fs'
3
- import { join } from 'node:path'
3
+ import { basename, dirname, join } from 'node:path'
4
4
 
5
5
  const HOME = homedir()
6
6
  const TRASH_DIR = join(HOME, '.aisessions', 'trash')
7
7
 
8
8
  function ensureTrash() { mkdirSync(TRASH_DIR, { recursive: true }) }
9
9
 
10
+ function safePart(v, fallback = 'item') {
11
+ return String(v || fallback).replace(/[^a-z0-9._-]+/gi, '_').replace(/^_+|_+$/g, '').slice(0, 80) || fallback
12
+ }
13
+
14
+ function findMetaFiles(dir) {
15
+ const out = []
16
+ try {
17
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
18
+ const full = join(dir, e.name)
19
+ if (e.isDirectory()) out.push(...findMetaFiles(full))
20
+ else if (e.name.endsWith('.meta.json') || e.name === 'meta.json') out.push(full)
21
+ }
22
+ } catch {}
23
+ return out
24
+ }
25
+
26
+ function removeStoredItem(metaPath, payloadPath) {
27
+ if (basename(metaPath) === 'meta.json') {
28
+ rmSync(dirname(metaPath), { recursive: true, force: true })
29
+ return
30
+ }
31
+ if (payloadPath && existsSync(payloadPath)) rmSync(payloadPath, { recursive: true, force: true })
32
+ if (existsSync(metaPath)) rmSync(metaPath, { force: true })
33
+ }
34
+
10
35
  // items: array of { path, agent?, agentLabel?, project?, title? }
11
36
  // OR plain strings (backwards compat)
12
37
  function normaliseItems(raw) {
@@ -19,9 +44,7 @@ export async function listTrash(agent) {
19
44
  ensureTrash()
20
45
  const items = []
21
46
  try {
22
- for (const f of readdirSync(TRASH_DIR)) {
23
- if (!f.endsWith('.meta.json')) continue
24
- const mp = join(TRASH_DIR, f)
47
+ for (const mp of findMetaFiles(TRASH_DIR)) {
25
48
  try {
26
49
  const meta = JSON.parse(readFileSync(mp, 'utf8'))
27
50
  if (agent && agent !== 'all' && meta.agent && meta.agent !== agent) continue
@@ -40,10 +63,13 @@ export async function moveToTrash(rawItems) {
40
63
  try {
41
64
  if (!existsSync(p)) { results.push({ path: p, ok: false, error: 'not found' }); continue }
42
65
  const ts = Date.now()
43
- const trashName = ts + '_' + p.replace(/\//g, '_').replace(/^_+/, '').slice(-80)
44
- const trashPath = join(TRASH_DIR, trashName)
45
- const metaPath = join(TRASH_DIR, trashName + '.meta.json')
66
+ const agentId = safePart(item.agent || 'unknown')
67
+ const trashName = ts + '_' + safePart(item.title || p.split('/').pop())
68
+ const itemDir = join(TRASH_DIR, agentId, trashName)
69
+ const trashPath = join(itemDir, 'payload')
70
+ const metaPath = join(itemDir, 'meta.json')
46
71
  const st = statSync(p)
72
+ mkdirSync(itemDir, { recursive: true })
47
73
  if (st.isDirectory()) cpSync(p, trashPath, { recursive: true })
48
74
  else cpSync(p, trashPath)
49
75
  rmSync(p, { recursive: true, force: true })
@@ -70,10 +96,10 @@ export async function restoreFromTrash(metaPath) {
70
96
  if (!existsSync(meta.trashPath)) return { ok: false, error: 'trash file missing' }
71
97
  if (existsSync(meta.originalPath)) return { ok: false, error: 'destination already exists' }
72
98
  const st = statSync(meta.trashPath)
99
+ mkdirSync(dirname(meta.originalPath), { recursive: true })
73
100
  if (st.isDirectory()) cpSync(meta.trashPath, meta.originalPath, { recursive: true })
74
101
  else cpSync(meta.trashPath, meta.originalPath)
75
- rmSync(meta.trashPath, { recursive: true, force: true })
76
- rmSync(metaPath, { force: true })
102
+ removeStoredItem(metaPath, meta.trashPath)
77
103
  return { ok: true, restoredTo: meta.originalPath }
78
104
  } catch (e) { return { ok: false, error: e.message } }
79
105
  }
@@ -84,8 +110,7 @@ export async function purgeTrash(metaPaths) {
84
110
  try {
85
111
  let trashPath = mp.replace(/\.meta\.json$/, '')
86
112
  try { const m = JSON.parse(readFileSync(mp, 'utf8')); trashPath = m.trashPath || trashPath } catch {}
87
- if (existsSync(trashPath)) rmSync(trashPath, { recursive: true, force: true })
88
- if (existsSync(mp)) rmSync(mp, { force: true })
113
+ removeStoredItem(mp, trashPath)
89
114
  results.push({ metaPath: mp, ok: true })
90
115
  } catch (e) { results.push({ metaPath: mp, ok: false, error: e.message }) }
91
116
  }
package/src/ui/css.js CHANGED
@@ -15,6 +15,7 @@ export const PAGE_CSS = `
15
15
  --muted:#666;
16
16
  --dim:#3a3a3a;
17
17
  --accent:#ffffff;
18
+ --logo-shadow:0 0 10px rgba(255,255,255,.25);
18
19
  --danger:#ff3333;
19
20
  --success:#33cc33;
20
21
  --warn:#ccaa00;
@@ -28,6 +29,24 @@ export const PAGE_CSS = `
28
29
  --logo: 'Press Start 2P', monospace;
29
30
  }
30
31
 
32
+ html[data-theme="light"]{
33
+ --bg:#f4f1e8;
34
+ --sb:#ebe6d8;
35
+ --surf:#f8f5ec;
36
+ --surf2:#ede8dc;
37
+ --surf3:#e1dacb;
38
+ --border:rgba(24,24,20,0.12);
39
+ --border2:rgba(24,24,20,0.24);
40
+ --text:#181814;
41
+ --muted:#6f695d;
42
+ --dim:#a49c8d;
43
+ --accent:#11110f;
44
+ --logo-shadow:none;
45
+ --danger:#b42323;
46
+ --success:#197a33;
47
+ --warn:#8a6900;
48
+ }
49
+
31
50
  html,body{height:100%;background:var(--bg);color:var(--text);overflow:hidden;
32
51
  font-family:var(--pixel);font-size:20px;line-height:1.2;
33
52
  -webkit-font-smoothing:none;font-smooth:never;image-rendering:pixelated}
@@ -66,7 +85,7 @@ html,body{height:100%;background:var(--bg);color:var(--text);overflow:hidden;
66
85
  #logo{
67
86
  font-family:var(--logo);font-size:11px;
68
87
  color:var(--accent);letter-spacing:1px;
69
- text-shadow:0 0 10px rgba(255,255,255,.25);
88
+ text-shadow:var(--logo-shadow);
70
89
  white-space:nowrap;
71
90
  }
72
91
  #hdr-sub{font-size:16px;color:var(--muted);letter-spacing:.5px}
@@ -79,6 +98,23 @@ html,body{height:100%;background:var(--bg);color:var(--text);overflow:hidden;
79
98
  }
80
99
  #search:focus{border-color:var(--border2);background:var(--surf2)}
81
100
  #search::placeholder{color:var(--dim)}
101
+ .theme-toggle{
102
+ width:74px;
103
+ height:39px;
104
+ border:2px solid var(--border);
105
+ background:var(--surf);
106
+ color:var(--muted);
107
+ font-family:var(--pixel);font-size:16px;
108
+ letter-spacing:.8px;
109
+ cursor:pointer;
110
+ -webkit-font-smoothing:none;
111
+ }
112
+ .theme-toggle:hover,.theme-toggle:focus{
113
+ color:var(--accent);
114
+ border-color:var(--border2);
115
+ background:var(--surf2);
116
+ outline:none;
117
+ }
82
118
 
83
119
  /* ── SIDEBAR ─────────────────────────────────────────────────────────────────── */
84
120
  .sb-section{padding:10px 0}
@@ -171,6 +207,26 @@ html,body{height:100%;background:var(--bg);color:var(--text);overflow:hidden;
171
207
  font-size:12px;color:rgba(255,255,255,.26);letter-spacing:1px;
172
208
  text-transform:uppercase;
173
209
  }
210
+ html[data-theme="light"] .credit-kicker{color:rgba(24,24,20,.42)}
211
+ html[data-theme="light"] .credit-frame{
212
+ border-color:rgba(24,24,20,.18);
213
+ background:rgba(24,24,20,.025);
214
+ }
215
+ html[data-theme="light"] .credit-frame::before,
216
+ html[data-theme="light"] .credit-frame::after{background:var(--accent)}
217
+ html[data-theme="light"] .credit-link{
218
+ color:rgba(24,24,20,.58);
219
+ background:rgba(24,24,20,.02);
220
+ }
221
+ html[data-theme="light"] .credit-link:hover{
222
+ color:var(--accent);
223
+ background:rgba(24,24,20,.04);
224
+ }
225
+ html[data-theme="light"] .credit-link-icon{
226
+ border-color:rgba(24,24,20,.18);
227
+ color:rgba(24,24,20,.78);
228
+ }
229
+ html[data-theme="light"] .credit-pkg{color:rgba(24,24,20,.32)}
174
230
 
175
231
  /* ── TAB BAR — hidden; sidebar drives navigation ────────────────────────────── */
176
232
  #tab-bar{display:none}
@@ -222,7 +278,7 @@ html,body{height:100%;background:var(--bg);color:var(--text);overflow:hidden;
222
278
 
223
279
  .tbl-hdr,.tbl-row{
224
280
  display:grid;
225
- grid-template-columns:40px 80px 1fr 80px 80px 150px 82px;
281
+ grid-template-columns:40px 80px 1fr 80px 80px 150px 126px;
226
282
  align-items:center;gap:0 10px;padding:0 16px;
227
283
  }
228
284
  .tbl-hdr{
@@ -384,6 +440,52 @@ html,body{height:100%;background:var(--bg);color:var(--text);overflow:hidden;
384
440
  #toast.ok{border-color:rgba(51,204,51,.6);color:var(--success)}
385
441
  #toast.err{border-color:rgba(255,51,51,.6);color:var(--danger)}
386
442
 
443
+ /* ── MODAL / SESSION VIEWER ────────────────────────────────────────────────── */
444
+ .modal{
445
+ position:fixed;inset:0;z-index:100;
446
+ display:flex;align-items:center;justify-content:center;
447
+ background:rgba(0,0,0,.72);
448
+ padding:28px;
449
+ }
450
+ .modal[hidden]{display:none}
451
+ .modal-box{
452
+ width:min(980px,calc(100vw - 56px));
453
+ height:min(760px,calc(100vh - 56px));
454
+ display:flex;flex-direction:column;
455
+ border:2px solid var(--border2);
456
+ background:var(--surf);
457
+ }
458
+ .modal-head{
459
+ display:flex;align-items:center;justify-content:space-between;gap:16px;
460
+ padding:14px 16px;border-bottom:1px solid var(--border);
461
+ flex-shrink:0;
462
+ }
463
+ .modal-title{font-family:var(--logo);font-size:10px;color:var(--accent);letter-spacing:1px}
464
+ .modal-sub{font-size:15px;color:var(--muted);margin-top:5px;word-break:break-all}
465
+ .modal-body{flex:1;overflow:auto;padding:14px;background:var(--bg)}
466
+ .chat-msg{
467
+ border:1px solid var(--border);
468
+ background:var(--surf);
469
+ margin-bottom:10px;
470
+ }
471
+ .chat-role{
472
+ padding:7px 10px;border-bottom:1px solid var(--border);
473
+ color:var(--muted);font-size:14px;letter-spacing:1px;text-transform:uppercase;
474
+ }
475
+ .chat-text{
476
+ padding:10px;
477
+ white-space:pre-wrap;
478
+ word-break:break-word;
479
+ font-size:17px;
480
+ line-height:1.35;
481
+ }
482
+ .chat-raw{
483
+ white-space:pre-wrap;
484
+ word-break:break-word;
485
+ font-size:15px;
486
+ line-height:1.35;
487
+ }
488
+
387
489
  /* ── SCROLLBAR ───────────────────────────────────────────────────────────────── */
388
490
  ::-webkit-scrollbar{width:8px;height:8px}
389
491
  ::-webkit-scrollbar-track{background:var(--bg)}
package/src/ui/js.js CHANGED
@@ -14,6 +14,7 @@ var collapsed = new Set();
14
14
  var PAGE = 0;
15
15
  var PER_PAGE = 100;
16
16
  var usageCache = {}; // keyed by agentFilter
17
+ var theme = 'dark';
17
18
 
18
19
  // ── utils ─────────────────────────────────────────────────────────────────────
19
20
  function $(id) { return document.getElementById(id); }
@@ -64,14 +65,17 @@ document.addEventListener('click', function (e) {
64
65
  var nav = btn.dataset.nav || '';
65
66
  if (a === 'filter-agent') { setAgentFilter(ag); return; }
66
67
  if (a === 'switch-tab') { switchTab(nav); return; }
67
- if (a === 'agent-tab') { switchTab(nav); return; }
68
+ if (a === 'agent-tab') { switchAgentTab(ag, nav); return; }
68
69
  if (a === 'switch-tab-global') { setAgentFilter('all'); switchTab(nav); return; }
70
+ if (a === 'toggle-theme') { toggleTheme(); return; }
69
71
  if (a === 'trash-one') { trashOne(p); return; }
70
72
  if (a === 'backup-one') { backupOne(p); return; }
71
73
  if (a === 'restore-trash') { restoreTrash(meta); return; }
72
74
  if (a === 'purge-trash') { purgeTrash(meta); return; }
73
75
  if (a === 'restore-backup') { restoreBackup(meta); return; }
74
76
  if (a === 'delete-backup') { deleteBackup(meta); return; }
77
+ if (a === 'view-session') { viewSession(p); return; }
78
+ if (a === 'close-modal') { closeModal(); return; }
75
79
  return;
76
80
  }
77
81
  var row = e.target.closest('.tbl-row');
@@ -95,8 +99,34 @@ document.addEventListener('click', function (e) {
95
99
  if (gh) { var k = gh.dataset.gkey; collapsed.has(k) ? collapsed.delete(k) : collapsed.add(k); renderSessions(); }
96
100
  });
97
101
 
102
+ // ── THEME ─────────────────────────────────────────────────────────────────────
103
+ function getSavedTheme() {
104
+ try { return localStorage.getItem('aisessions-theme') || 'dark'; }
105
+ catch { return 'dark'; }
106
+ }
107
+
108
+ function saveTheme(next) {
109
+ try { localStorage.setItem('aisessions-theme', next); } catch {}
110
+ }
111
+
112
+ function applyTheme(next) {
113
+ theme = next === 'light' ? 'light' : 'dark';
114
+ document.documentElement.dataset.theme = theme;
115
+ var btn = $('theme-toggle');
116
+ if (btn) {
117
+ btn.textContent = theme === 'light' ? 'DARK' : 'LIGHT';
118
+ btn.title = 'Switch to ' + (theme === 'light' ? 'dark' : 'light') + ' mode';
119
+ }
120
+ }
121
+
122
+ function toggleTheme() {
123
+ applyTheme(theme === 'light' ? 'dark' : 'light');
124
+ saveTheme(theme);
125
+ }
126
+
98
127
  // ── INIT ──────────────────────────────────────────────────────────────────────
99
128
  async function init() {
129
+ applyTheme(getSavedTheme());
100
130
  var tblBody = $('tbl-body');
101
131
  if (!tblBody) return;
102
132
  tblBody.innerHTML = '<div class="loading"><span class="loading-dots">LOADING SESSIONS</span></div>';
@@ -128,10 +158,10 @@ function buildSidebar(agents, sessions) {
128
158
  '<span class="agent-dot" style="background:' + esc(a.color) + '"></span>' +
129
159
  esc(a.label) + '<span class="sb-count">' + (counts[a.id] || 0) + '</span></button>' +
130
160
  '<div class="sb-sub" id="sub-' + aid + '">' +
131
- '<button class="sb-sub-item" data-action="agent-tab" data-nav="sessions">[=] SESSIONS</button>' +
132
- '<button class="sb-sub-item" data-action="agent-tab" data-nav="usage">[$] USAGE</button>' +
133
- '<button class="sb-sub-item" data-action="agent-tab" data-nav="trash">[T] TRASH</button>' +
134
- '<button class="sb-sub-item" data-action="agent-tab" data-nav="backups">[B] BACKUPS</button>' +
161
+ '<button class="sb-sub-item" data-action="agent-tab" data-agent="' + aid + '" data-nav="sessions">[=] SESSIONS</button>' +
162
+ '<button class="sb-sub-item" data-action="agent-tab" data-agent="' + aid + '" data-nav="usage">[$] USAGE</button>' +
163
+ '<button class="sb-sub-item" data-action="agent-tab" data-agent="' + aid + '" data-nav="trash">[T] TRASH</button>' +
164
+ '<button class="sb-sub-item" data-action="agent-tab" data-agent="' + aid + '" data-nav="backups">[B] BACKUPS</button>' +
135
165
  '</div>';
136
166
  }).join('');
137
167
  }
@@ -159,6 +189,18 @@ function setAgentFilter(id) {
159
189
  switchTab('sessions');
160
190
  }
161
191
 
192
+ function switchAgentTab(id, tab) {
193
+ if (id && id !== agentFilter) {
194
+ agentFilter = id; PAGE = 0;
195
+ setSidebarActive(id);
196
+ document.querySelectorAll('.sb-sub').forEach(function(el) {
197
+ el.classList.toggle('show', el.id === 'sub-' + id);
198
+ });
199
+ updateContextLabel();
200
+ }
201
+ switchTab(tab || 'sessions');
202
+ }
203
+
162
204
  // ── CONTEXT LABEL (shows current filter scope in each panel) ──────────────────
163
205
  function updateContextLabel() {
164
206
  var label = agentFilter === 'all' ? 'ALL AGENTS' : agentLabel(agentFilter);
@@ -256,6 +298,7 @@ function rowHtml(s) {
256
298
  '<span class="col-size">' + esc(s.sizeLabel || '') + '</span>' +
257
299
  '<span class="col-date">' + esc(s.dateLabel || '') + '</span>' +
258
300
  '<span class="row-actions">' +
301
+ '<button class="act-btn" data-action="view-session" data-path="' + p + '" title="View chat">VIEW</button>' +
259
302
  '<button class="act-btn" data-action="backup-one" data-path="' + p + '" title="Backup">BAK</button>' +
260
303
  '<button class="act-btn del" data-action="trash-one" data-path="' + p + '" title="Trash">DEL</button>' +
261
304
  '</span></div>';
@@ -281,12 +324,15 @@ wireBtn('btn-trash', async function() {
281
324
  var ok = res.filter(function(r) { return r.ok; }).map(function(r) { return r.path; });
282
325
  ok.forEach(function(p) { ALL = ALL.filter(function(s) { return s.path !== p; }); selected.delete(p); });
283
326
  applyFilter(); toast(ok.length + ' MOVED TO TRASH', 'ok');
327
+ if (ok.length) switchTab('trash');
284
328
  });
285
329
  wireBtn('btn-backup', async function() {
286
330
  if (!selected.size) { toast('SELECT SESSIONS FIRST', 'err'); return; }
287
331
  var items = buildItems(Array.from(selected));
288
332
  var res = await post('/api/backup/create', { items: items, note: '' });
289
- toast(res.filter(function(r) { return r.ok; }).length + ' BACKUP(S) CREATED', 'ok');
333
+ var ok = res.filter(function(r) { return r.ok; }).length;
334
+ toast(ok + ' BACKUP(S) CREATED', 'ok');
335
+ if (ok) switchTab('backups');
290
336
  });
291
337
 
292
338
  // Enrich paths with session metadata for storage
@@ -304,12 +350,13 @@ function switchTab(tab) {
304
350
  document.querySelectorAll('.panel').forEach(function(p) { p.classList.toggle('active', p.id === tab + '-panel'); });
305
351
  // Mark active sub-item (agent-specific nav)
306
352
  document.querySelectorAll('.sb-sub-item').forEach(function(el) {
307
- el.classList.toggle('active', el.dataset.nav === tab);
353
+ el.classList.toggle('active', el.dataset.nav === tab && el.dataset.agent === agentFilter);
308
354
  });
309
355
  // Mark global tools active only when agentFilter is all
310
356
  document.querySelectorAll('.sb-item[data-action="switch-tab-global"]').forEach(function(el) {
311
357
  el.classList.toggle('active', el.dataset.nav === tab && agentFilter === 'all');
312
358
  });
359
+ if (tab === 'sessions') applyFilter();
313
360
  if (tab === 'usage') loadUsage(false);
314
361
  if (tab === 'trash') loadTrash(false);
315
362
  if (tab === 'backups') loadBackups(false);
@@ -325,17 +372,55 @@ async function trashOne(path) {
325
372
  var res = await post('/api/trash/move', { items: items });
326
373
  if (res[0] && res[0].ok) {
327
374
  ALL = ALL.filter(function(s) { return s.path !== path; }); selected.delete(path);
328
- applyFilter(); toast('MOVED TO TRASH', 'ok');
375
+ applyFilter(); toast('MOVED TO TRASH', 'ok'); switchTab('trash');
329
376
  } else toast('ERROR: ' + ((res[0] && res[0].error) || '?'), 'err');
330
377
  }
331
378
  async function backupOne(path) {
332
379
  if (!path) return;
333
380
  var items = buildItems([path]);
334
381
  var res = await post('/api/backup/create', { items: items, note: '' });
335
- if (res[0] && res[0].ok) toast('BACKUP CREATED', 'ok');
382
+ if (res[0] && res[0].ok) { toast('BACKUP CREATED', 'ok'); switchTab('backups'); }
336
383
  else toast('ERROR: ' + ((res[0] && res[0].error) || '?'), 'err');
337
384
  }
338
385
 
386
+ // ── SESSION VIEWER ───────────────────────────────────────────────────────────
387
+ function closeModal() {
388
+ var m = $('modal');
389
+ if (m) m.hidden = true;
390
+ }
391
+
392
+ async function viewSession(path) {
393
+ if (!path) return;
394
+ var modal = $('modal'), title = $('modal-title'), sub = $('modal-sub'), body = $('modal-body');
395
+ if (!modal || !body) return;
396
+ modal.hidden = false;
397
+ if (title) title.textContent = 'LOADING SESSION';
398
+ if (sub) sub.textContent = path;
399
+ body.innerHTML = '<div class="loading"><span class="loading-dots">LOADING CHAT</span></div>';
400
+ try {
401
+ var data = await fetch('/api/session?path=' + encodeURIComponent(path)).then(function(r) { return r.json(); });
402
+ if (!data.ok) {
403
+ body.innerHTML = '<div class="empty"><div class="e-icon">[!]</div><div>' + esc(data.error || 'Could not load session') + '</div></div>';
404
+ return;
405
+ }
406
+ if (title) title.textContent = 'SESSION HISTORY';
407
+ if (sub) sub.textContent = data.path + (data.truncated ? ' / preview truncated' : '');
408
+ var messages = data.messages || [];
409
+ if (messages.length) {
410
+ body.innerHTML = messages.slice(0, 200).map(function(m) {
411
+ return '<div class="chat-msg">' +
412
+ '<div class="chat-role">' + esc(m.index + ' / ' + m.role) + '</div>' +
413
+ '<div class="chat-text">' + esc(m.text) + '</div>' +
414
+ '</div>';
415
+ }).join('') + (messages.length > 200 ? '<div class="empty"><div>Showing first 200 entries</div></div>' : '');
416
+ } else {
417
+ body.innerHTML = '<pre class="chat-raw">' + esc(data.raw || '') + '</pre>';
418
+ }
419
+ } catch (e) {
420
+ body.innerHTML = '<div class="empty"><div class="e-icon">[!]</div><div>' + esc(e.message) + '</div></div>';
421
+ }
422
+ }
423
+
339
424
  // ── USAGE TAB ─────────────────────────────────────────────────────────────────
340
425
  async function loadUsage(force) {
341
426
  var key = agentFilter;
@@ -468,7 +553,7 @@ async function loadTrash(force) {
468
553
 
469
554
  async function restoreTrash(mp) {
470
555
  var res = await post('/api/trash/restore', { metaPath: mp });
471
- if (res.ok) { toast('RESTORED TO ' + res.restoredTo, 'ok'); loadTrash(true); }
556
+ if (res.ok) { toast('RESTORED TO ' + res.restoredTo, 'ok'); loadTrash(true); reloadSessions(); }
472
557
  else toast('ERROR: ' + (res.error || '?'), 'err');
473
558
  }
474
559
  async function purgeTrash(mp) {
@@ -511,7 +596,7 @@ async function loadBackups(force) {
511
596
 
512
597
  async function restoreBackup(mp) {
513
598
  var res = await post('/api/backup/restore', { metaPath: mp });
514
- if (res.ok) toast('RESTORED TO ' + res.restoredTo, 'ok');
599
+ if (res.ok) { toast('RESTORED TO ' + res.restoredTo, 'ok'); reloadSessions(); }
515
600
  else toast('ERROR: ' + (res.error || '?'), 'err');
516
601
  }
517
602
  async function deleteBackup(mp) {
@@ -521,6 +606,14 @@ async function deleteBackup(mp) {
521
606
  else toast('ERROR: ' + ((res[0] && res[0].error) || '?'), 'err');
522
607
  }
523
608
 
609
+ async function reloadSessions() {
610
+ try {
611
+ var sessions = await fetch('/api/sessions').then(function(r) { return r.json(); });
612
+ ALL = sessions;
613
+ applyFilter();
614
+ } catch {}
615
+ }
616
+
524
617
  // ── BOOT ──────────────────────────────────────────────────────────────────────
525
618
  init().catch(function(e) { console.error('[AM] fatal:', e); });
526
619
 
package/src/ui/render.js CHANGED
@@ -22,6 +22,7 @@ export function renderPage() {
22
22
  <span id="hdr-sub">// AI SESSION MANAGER</span>
23
23
  <div id="hdr-right">
24
24
  <input id="search" type="search" placeholder="SEARCH SESSIONS...">
25
+ <button id="theme-toggle" class="theme-toggle" data-action="toggle-theme" type="button" title="Toggle light/dark mode">LIGHT</button>
25
26
  </div>
26
27
  </header>
27
28
 
@@ -64,7 +65,7 @@ export function renderPage() {
64
65
  <span>X.com</span>
65
66
  </a>
66
67
  </div>
67
- <div class="credit-pkg">aisessions v1.0</div>
68
+ <div class="credit-pkg">aisessions v1.1.2</div>
68
69
  </div>
69
70
  </nav>
70
71
 
@@ -124,7 +125,7 @@ export function renderPage() {
124
125
 
125
126
  <!-- FOOTER -->
126
127
  <footer id="foot">
127
- <span>AISESSIONS v1.0</span>
128
+ <span>AISESSIONS v1.1.2</span>
128
129
  <span>&nbsp;|&nbsp;</span>
129
130
  <span id="foot-info">LOADING...</span>
130
131
  </footer>
@@ -132,6 +133,18 @@ export function renderPage() {
132
133
  </div><!-- /shell -->
133
134
 
134
135
  <div id="toast"></div>
136
+ <div id="modal" class="modal" hidden>
137
+ <div class="modal-box">
138
+ <div class="modal-head">
139
+ <div>
140
+ <div id="modal-title" class="modal-title">SESSION</div>
141
+ <div id="modal-sub" class="modal-sub"></div>
142
+ </div>
143
+ <button class="btn" data-action="close-modal">CLOSE</button>
144
+ </div>
145
+ <div id="modal-body" class="modal-body"></div>
146
+ </div>
147
+ </div>
135
148
  <script>${PAGE_JS}</script>
136
149
  </body>
137
150
  </html>`
@@ -1,7 +1,21 @@
1
1
  import { spawnSync } from 'node:child_process'
2
+ import { createRequire } from 'node:module'
3
+
4
+ const require = createRequire(import.meta.url)
5
+
6
+ function ccusageCliPath() {
7
+ try {
8
+ return require.resolve('ccusage/dist/cli.js')
9
+ } catch {
10
+ return null
11
+ }
12
+ }
2
13
 
3
14
  function run(args) {
4
- const r = spawnSync('npx', ['--yes', 'ccusage@latest', ...args, '--json'], {
15
+ const cli = ccusageCliPath()
16
+ if (!cli) return null
17
+
18
+ const r = spawnSync(process.execPath, [cli, ...args, '--json'], {
5
19
  timeout: 30_000, maxBuffer: 16 * 1024 * 1024, encoding: 'utf8',
6
20
  env: { ...process.env, NO_COLOR: '1' },
7
21
  })
@@ -9,17 +23,6 @@ function run(args) {
9
23
  try { return JSON.parse(r.stdout) } catch { return null }
10
24
  }
11
25
 
12
- function runLocal(args) {
13
- try {
14
- const r = spawnSync('ccusage', [...args, '--json'], {
15
- timeout: 30_000, maxBuffer: 16 * 1024 * 1024, encoding: 'utf8',
16
- env: { ...process.env, NO_COLOR: '1' },
17
- })
18
- if (r.status !== 0 || !r.stdout) return null
19
- return JSON.parse(r.stdout)
20
- } catch { return null }
21
- }
22
-
23
26
  // Normalise the two very different per-agent response shapes into one schema.
24
27
  function normalizeEntries(entries, agentId) {
25
28
  return (entries || []).map(d => {
@@ -87,22 +90,22 @@ export async function getUsageData({ since, until, agent } = {}) {
87
90
 
88
91
  let raw
89
92
  if (agent && agent !== 'all') {
90
- // Per-agent command: npx ccusage claude daily --json
93
+ // Per-agent command shape: ccusage claude daily --json
91
94
  const agentArgs = [agent, 'daily', ...dateArgs]
92
- raw = runLocal(agentArgs) ?? run(agentArgs)
95
+ raw = run(agentArgs)
93
96
  if (!raw) {
94
97
  // Fallback: run global and filter by metadata.agents
95
98
  const globalArgs = ['daily', ...dateArgs]
96
- const g = runLocal(globalArgs) ?? run(globalArgs)
99
+ const g = run(globalArgs)
97
100
  if (g && Array.isArray(g.daily)) {
98
101
  raw = { daily: g.daily.filter(d => (d.metadata?.agents || []).includes(agent)) }
99
102
  }
100
103
  }
101
104
  } else {
102
- raw = runLocal(['daily', ...dateArgs]) ?? run(['daily', ...dateArgs])
105
+ raw = run(['daily', ...dateArgs])
103
106
  }
104
107
 
105
- if (!raw) return { available: false, error: 'ccusage not found run: npm i -g ccusage' }
108
+ if (!raw) return { available: false, error: 'Bundled ccusage is unavailable or could not read local usage data' }
106
109
 
107
110
  const entries = normalizeEntries(raw.daily || (Array.isArray(raw) ? raw : []), agent || 'all')
108
111
  entries.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))