tpc-explorer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.js +951 -0
  2. package/package.json +46 -0
  3. package/setup.js +67 -0
package/index.js ADDED
@@ -0,0 +1,951 @@
1
+ #!/usr/bin/env node
2
+ const blessed = require('blessed');
3
+ const contrib = require('blessed-contrib');
4
+ const fs = require('fs-extra');
5
+ const path = require('path');
6
+ const highlight = require('cli-highlight').highlight;
7
+ const pty = require('node-pty');
8
+ const { execSync } = require('child_process');
9
+ const os = require('os');
10
+
11
+ // ── Tokyo Night Storm Theme ─────────────────────────────────────────────
12
+ const theme = {
13
+ bg: '#1a1b26',
14
+ sidebar: '#16161e',
15
+ accent: '#7aa2f7',
16
+ accentDim: '#3d59a1',
17
+ text: '#a9b1d6',
18
+ textDim: '#565f89',
19
+ textBright: '#c0caf5',
20
+ border: '#292e42',
21
+ borderFocus: '#7aa2f7',
22
+ green: '#9ece6a',
23
+ red: '#f7768e',
24
+ yellow: '#e0af68',
25
+ purple: '#bb9af7',
26
+ cyan: '#7dcfff',
27
+ orange: '#ff9e64'
28
+ };
29
+
30
+ // ── Emoji Icons ─────────────────────────────────────────────────────────
31
+ const fileIconMap = {
32
+ js: '⬡ ', jsx: '⬡ ', mjs: '⬡ ', cjs: '⬡ ',
33
+ ts: '◇ ', tsx: '◇ ', mts: '◇ ',
34
+ html: '◈ ', htm: '◈ ',
35
+ css: '◉ ', scss: '◉ ', sass: '◉ ', less: '◉ ',
36
+ vue: '▲ ', svelte: '◆ ',
37
+ json: '{ }', yaml: '≡ ', yml: '≡ ', toml: '≡ ',
38
+ xml: '⟨⟩', csv: '⊞ ', env: '⚿ ', ini: '⚙ ',
39
+ py: '🐍', rb: '💎', go: '◆ ', rs: '⚙ ',
40
+ java: '☕', kt: '◆ ', scala: '◆ ',
41
+ c: 'C ', cpp: '⊕ ', h: 'H ', hpp: '⊕ ',
42
+ cs: '# ', swift: '◇ ', php: '⟠ ', lua: '☾ ', r: 'R ',
43
+ sh: '▶ ', bash: '▶ ', zsh: '▶ ', fish: '▶ ', ps1: '▶ ',
44
+ md: '⊹ ', mdx: '⊹ ', txt: '⊡ ', rst: '⊹ ',
45
+ pdf: '◰ ', doc: '◰ ', docx: '◰ ',
46
+ png: '◧ ', jpg: '◧ ', jpeg: '◧ ', gif: '◧ ',
47
+ svg: '◈ ', ico: '◧ ', webp: '◧ ',
48
+ lock: '⊘ ', dockerfile: '🐳', gitignore: '⊙ ',
49
+ };
50
+ const fileColorMap = {
51
+ js: theme.yellow, jsx: theme.yellow, mjs: theme.yellow, cjs: theme.yellow,
52
+ ts: theme.cyan, tsx: theme.cyan, mts: theme.cyan,
53
+ html: theme.orange, htm: theme.orange,
54
+ css: theme.purple, scss: theme.purple, sass: theme.purple,
55
+ py: theme.green, go: theme.cyan, rs: theme.orange,
56
+ rb: theme.red, java: theme.orange,
57
+ json: theme.yellow, yaml: theme.green, yml: theme.green,
58
+ md: theme.cyan, mdx: theme.cyan,
59
+ sh: theme.green, bash: theme.green, zsh: theme.green,
60
+ lock: theme.textDim, svg: theme.purple, vue: theme.green,
61
+ c: theme.cyan, cpp: theme.cyan, h: theme.purple,
62
+ };
63
+
64
+ function hexToAnsi(hex) {
65
+ if (!hex || hex[0] !== '#') return '';
66
+ const r = parseInt(hex.slice(1, 3), 16);
67
+ const g = parseInt(hex.slice(3, 5), 16);
68
+ const b = parseInt(hex.slice(5, 7), 16);
69
+ return `\x1b[38;2;${r};${g};${b}m`;
70
+ }
71
+
72
+ function getFileIcon(filename, isDir) {
73
+ if (isDir) return `${hexToAnsi(theme.accent)}▸ \x1b[0m`;
74
+ const ext = path.extname(filename).slice(1).toLowerCase();
75
+ const baseName = filename.toLowerCase();
76
+ if (baseName === 'dockerfile') return `${hexToAnsi(theme.cyan)}🐳\x1b[0m`;
77
+ if (baseName === '.gitignore') return `${hexToAnsi(theme.orange)}⊙ \x1b[0m`;
78
+ if (baseName === 'package.json') return `${hexToAnsi(theme.green)}⬢ \x1b[0m`;
79
+ if (baseName === 'tsconfig.json') return `${hexToAnsi(theme.cyan)}◇ \x1b[0m`;
80
+ const icon = fileIconMap[ext] || '⊡ ';
81
+ const color = fileColorMap[ext] || theme.textDim;
82
+ return `${hexToAnsi(color)}${icon}\x1b[0m`;
83
+ }
84
+
85
+ // ── Session History Loading ─────────────────────────────────────────────
86
+ function loadSessions() {
87
+ const cwd = process.cwd();
88
+ const sessions = [];
89
+
90
+ // Claude sessions
91
+ const claudeProjectKey = cwd.replace(/\//g, '-');
92
+ const claudeDir = path.join(os.homedir(), '.claude', 'projects', claudeProjectKey);
93
+ if (fs.existsSync(claudeDir)) {
94
+ const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.jsonl'));
95
+ for (const f of files) {
96
+ try {
97
+ const fullPath = path.join(claudeDir, f);
98
+ const stat = fs.statSync(fullPath);
99
+ const lines = fs.readFileSync(fullPath, 'utf-8').trim().split('\n');
100
+ let firstMsg = '';
101
+ for (const line of lines) {
102
+ try {
103
+ const obj = JSON.parse(line);
104
+ if (obj.type === 'user' && obj.message?.content && !firstMsg) {
105
+ const c = typeof obj.message.content === 'string' ? obj.message.content : '';
106
+ firstMsg = c.replace(/<[^>]+>/g, '').trim().slice(0, 50) || '(command)';
107
+ }
108
+ } catch (e) {}
109
+ }
110
+ sessions.push({
111
+ type: 'claude',
112
+ id: f.replace('.jsonl', ''),
113
+ modified: stat.mtime,
114
+ summary: firstMsg || '(empty)',
115
+ messages: lines.length,
116
+ });
117
+ } catch (e) {}
118
+ }
119
+ }
120
+
121
+ // Codex sessions — scan for sessions whose cwd matches
122
+ const codexBaseDir = path.join(os.homedir(), '.codex', 'sessions');
123
+ if (fs.existsSync(codexBaseDir)) {
124
+ try {
125
+ const walkCodex = (dir) => {
126
+ const entries = fs.readdirSync(dir);
127
+ for (const entry of entries) {
128
+ const full = path.join(dir, entry);
129
+ const st = fs.statSync(full);
130
+ if (st.isDirectory()) { walkCodex(full); continue; }
131
+ if (!entry.endsWith('.jsonl')) continue;
132
+ try {
133
+ const firstLine = fs.readFileSync(full, 'utf-8').split('\n')[0];
134
+ const meta = JSON.parse(firstLine);
135
+ if (meta.type === 'session_meta' && meta.payload?.cwd === cwd) {
136
+ sessions.push({
137
+ type: 'codex',
138
+ id: meta.payload.id,
139
+ modified: new Date(meta.payload.timestamp || st.mtime),
140
+ summary: `Codex ${meta.payload.cli_version || ''}`.trim(),
141
+ messages: 0,
142
+ filePath: full,
143
+ });
144
+ }
145
+ } catch (e) {}
146
+ }
147
+ };
148
+ walkCodex(codexBaseDir);
149
+ } catch (e) {}
150
+ }
151
+
152
+ // Sort newest first
153
+ sessions.sort((a, b) => b.modified - a.modified);
154
+ return sessions;
155
+ }
156
+
157
+ function formatSessionItem(s) {
158
+ const icon = s.type === 'claude' ? `${hexToAnsi(theme.orange)}◆\x1b[0m` : `${hexToAnsi(theme.green)}⊞\x1b[0m`;
159
+ const age = formatAge(s.modified);
160
+ const dim = hexToAnsi(theme.textDim);
161
+ const text = s.summary.slice(0, 25);
162
+ return `${icon} ${text} ${dim}${age}\x1b[0m`;
163
+ }
164
+
165
+ function formatAge(date) {
166
+ const ms = Date.now() - date.getTime();
167
+ const mins = Math.floor(ms / 60000);
168
+ if (mins < 1) return 'now';
169
+ if (mins < 60) return `${mins}m`;
170
+ const hrs = Math.floor(mins / 60);
171
+ if (hrs < 24) return `${hrs}h`;
172
+ const days = Math.floor(hrs / 24);
173
+ return `${days}d`;
174
+ }
175
+
176
+ // ── AI Session Pool State (declared early for use in updateStatusBar) ───
177
+ let liveSessions = [];
178
+ let activeSessionIdx = -1;
179
+ let viewMode = 'file';
180
+
181
+ // ── Screen Setup ────────────────────────────────────────────────────────
182
+ const screen = blessed.screen({
183
+ smartCSR: true,
184
+ title: 'TPC Explorer Pro',
185
+ fullUnicode: true,
186
+ mouse: true,
187
+ style: { bg: theme.bg }
188
+ });
189
+
190
+ const grid = new contrib.grid({ rows: 12, cols: 12, screen: screen });
191
+
192
+ // ── Widgets ─────────────────────────────────────────────────────────────
193
+ const tree = grid.set(0, 0, 7, 3, contrib.tree, {
194
+ label: ' ⊟ EXPLORER ',
195
+ mouse: true,
196
+ style: {
197
+ fg: theme.text,
198
+ bg: theme.sidebar,
199
+ selected: { bg: theme.accentDim, fg: theme.textBright },
200
+ label: { fg: theme.accent }
201
+ },
202
+ border: { type: 'line', fg: theme.border },
203
+ template: { extend: ' ', retract: ' ' },
204
+ padding: { left: 1 }
205
+ });
206
+
207
+ const historyList = grid.set(7, 0, 4, 3, blessed.list, {
208
+ label: ' ◆ SESSIONS ',
209
+ mouse: true,
210
+ keys: true,
211
+ tags: false,
212
+ scrollable: true,
213
+ alwaysScroll: true,
214
+ scrollbar: { ch: '▐', style: { fg: theme.accentDim, bg: theme.sidebar } },
215
+ style: {
216
+ fg: theme.text,
217
+ bg: theme.sidebar,
218
+ selected: { bg: theme.accentDim, fg: theme.textBright },
219
+ label: { fg: theme.purple },
220
+ border: { fg: theme.border }
221
+ },
222
+ border: { type: 'line', fg: theme.border },
223
+ padding: { left: 1 }
224
+ });
225
+
226
+ const viewer = grid.set(0, 3, 11, 9, blessed.box, {
227
+ label: ' ◈ PREVIEW ',
228
+ mouse: true,
229
+ keys: true,
230
+ tags: false,
231
+ alwaysScroll: true,
232
+ scrollable: true,
233
+ scrollbar: { ch: '▐', style: { fg: theme.accentDim, bg: theme.bg } },
234
+ style: {
235
+ fg: theme.text,
236
+ bg: theme.bg,
237
+ label: { fg: theme.accent }
238
+ },
239
+ border: { type: 'line', fg: theme.border },
240
+ padding: { left: 1 }
241
+ });
242
+
243
+ const statusBar = grid.set(11, 0, 1, 12, blessed.box, {
244
+ tags: false,
245
+ style: { bg: theme.sidebar, fg: theme.text },
246
+ padding: { left: 1 }
247
+ });
248
+ updateStatusBar('normal');
249
+
250
+ const prompt = blessed.prompt({
251
+ parent: screen, top: 'center', left: 'center', width: '50%', height: 'shrink',
252
+ border: 'line', label: ' Input ',
253
+ style: { border: { fg: theme.accent }, bg: theme.sidebar, fg: theme.text },
254
+ hidden: true
255
+ });
256
+ const question = blessed.question({
257
+ parent: screen, top: 'center', left: 'center', width: '50%', height: 'shrink',
258
+ border: 'line', label: ' Confirm ',
259
+ style: { border: { fg: theme.red }, bg: theme.sidebar, fg: theme.text },
260
+ hidden: true
261
+ });
262
+
263
+ // ── AI Picker ───────────────────────────────────────────────────────────
264
+ const aiPicker = blessed.list({
265
+ parent: screen,
266
+ top: 'center',
267
+ left: 'center',
268
+ width: 42,
269
+ height: 8,
270
+ label: ` ◆ Launch AI Assistant `,
271
+ mouse: true,
272
+ keys: true,
273
+ tags: false,
274
+ border: { type: 'line', fg: theme.purple },
275
+ style: {
276
+ fg: theme.text,
277
+ bg: theme.sidebar,
278
+ selected: { bg: theme.accentDim, fg: theme.textBright },
279
+ label: { fg: theme.purple },
280
+ border: { fg: theme.purple }
281
+ },
282
+ padding: { left: 2, right: 2 },
283
+ items: [
284
+ `⬡ Claude Code`,
285
+ `⊞ OpenAI Codex`,
286
+ ],
287
+ hidden: true
288
+ });
289
+
290
+ // ── Session History ─────────────────────────────────────────────────────
291
+ let sessionData = []; // Array of session objects
292
+ let historyLineMap = []; // Maps list index → session object or null (for headers/empty)
293
+ let sessionsLoaded = false; // Cache: only load from disk once per app run
294
+
295
+ function refreshHistory(forceReload) {
296
+ const prevSelected = historyList.selected || 0;
297
+ if (!sessionsLoaded || forceReload) {
298
+ sessionData = loadSessions();
299
+ sessionsLoaded = true;
300
+ }
301
+ const claudeSessions = sessionData.filter(s => s.type === 'claude');
302
+ const codexSessions = sessionData.filter(s => s.type === 'codex');
303
+
304
+ const items = [];
305
+ historyLineMap = [];
306
+
307
+ // Claude section
308
+ items.push(`${hexToAnsi(theme.orange)}\x1b[1m◆ Claude\x1b[0m`);
309
+ historyLineMap.push(null);
310
+ if (claudeSessions.length > 0) {
311
+ for (const s of claudeSessions) {
312
+ items.push(` ${formatSessionItem(s)}`);
313
+ historyLineMap.push(s);
314
+ }
315
+ } else {
316
+ items.push(` ${hexToAnsi(theme.textDim)}(none)\x1b[0m`);
317
+ historyLineMap.push(null);
318
+ }
319
+
320
+ // Codex section
321
+ items.push(`${hexToAnsi(theme.green)}\x1b[1m⊞ Codex\x1b[0m`);
322
+ historyLineMap.push(null);
323
+ if (codexSessions.length > 0) {
324
+ for (const s of codexSessions) {
325
+ items.push(` ${formatSessionItem(s)}`);
326
+ historyLineMap.push(s);
327
+ }
328
+ } else {
329
+ items.push(` ${hexToAnsi(theme.textDim)}(none)\x1b[0m`);
330
+ historyLineMap.push(null);
331
+ }
332
+
333
+ historyList.setItems(items);
334
+ historyList.select(Math.min(prevSelected, items.length - 1));
335
+ screen.render();
336
+ }
337
+
338
+ // Resume a session from the history list
339
+ historyList.on('select', (item, index) => {
340
+ const session = historyLineMap[index];
341
+ if (!session) return; // header or (none) line
342
+ if (session.type === 'claude') {
343
+ launchAIWithArgs('claude', ['--resume', session.id], 'Claude Code');
344
+ } else {
345
+ launchAIWithArgs('codex', ['resume', session.id], 'OpenAI Codex');
346
+ }
347
+ });
348
+
349
+ // ── File Tree Logic ─────────────────────────────────────────────────────
350
+ function getFiles(dir) {
351
+ const result = { name: path.basename(dir) || dir, children: {}, path: dir, extended: true };
352
+ try {
353
+ const items = fs.readdirSync(dir);
354
+ items.sort((a, b) => {
355
+ try {
356
+ const aIsDir = fs.statSync(path.join(dir, a)).isDirectory();
357
+ const bIsDir = fs.statSync(path.join(dir, b)).isDirectory();
358
+ if (aIsDir && !bIsDir) return -1;
359
+ if (!aIsDir && bIsDir) return 1;
360
+ } catch (e) {}
361
+ return a.localeCompare(b);
362
+ }).forEach(item => {
363
+ if (item.startsWith('.') || item === 'node_modules' || item === 'dist' || item === '.git') return;
364
+ const fullPath = path.join(dir, item);
365
+ let isDir = false;
366
+ try { isDir = fs.statSync(fullPath).isDirectory(); } catch (e) { return; }
367
+ const displayName = `${getFileIcon(item, isDir)} ${item}`;
368
+
369
+ if (isDir) {
370
+ result.children[displayName] = { name: displayName, path: fullPath, children: {} };
371
+ try {
372
+ const subItems = fs.readdirSync(fullPath);
373
+ subItems.forEach(sub => {
374
+ if (!sub.startsWith('.')) {
375
+ try {
376
+ const subStat = fs.statSync(path.join(fullPath, sub));
377
+ const subDisplayName = `${getFileIcon(sub, subStat.isDirectory())} ${sub}`;
378
+ result.children[displayName].children[subDisplayName] = { name: subDisplayName, path: path.join(fullPath, sub) };
379
+ } catch (e) {}
380
+ }
381
+ });
382
+ } catch (e) {}
383
+ } else {
384
+ result.children[displayName] = { name: displayName, path: fullPath };
385
+ }
386
+ });
387
+ } catch (err) {}
388
+ return result;
389
+ }
390
+
391
+ let isRefreshing = false;
392
+ function refreshTree() {
393
+ isRefreshing = true;
394
+ const data = getFiles(process.cwd());
395
+ tree.setData(data);
396
+ isRefreshing = false;
397
+ screen.render();
398
+ }
399
+
400
+ // ── Line Numbers ────────────────────────────────────────────────────────
401
+ function addLineNumbers(text) {
402
+ const lines = text.split('\n');
403
+ const gutterWidth = String(lines.length).length;
404
+ const dim = hexToAnsi('#565f89');
405
+ const sep = hexToAnsi('#292e42');
406
+ return lines.map((line, i) => {
407
+ const num = String(i + 1).padStart(gutterWidth);
408
+ return `${dim}${num} ${sep}│\x1b[0m ${line}`;
409
+ }).join('\n');
410
+ }
411
+
412
+ // ── Viewer Update ───────────────────────────────────────────────────────
413
+ let lastPath = null;
414
+ function updateViewer(nodePath) {
415
+ if (!nodePath || !fs.existsSync(nodePath) || nodePath === lastPath) return;
416
+ lastPath = nodePath;
417
+
418
+ viewer.setContent('');
419
+ viewer.scrollTo(0);
420
+ screen.realloc();
421
+
422
+ try {
423
+ const stat = fs.statSync(nodePath);
424
+ if (stat.isFile()) {
425
+ const sizeKB = (stat.size / 1024).toFixed(1);
426
+ const content = fs.readFileSync(nodePath, 'utf-8');
427
+ const ext = path.extname(nodePath).slice(1);
428
+ const lineCount = content.split('\n').length;
429
+ const highlighted = highlight(content, { language: ext, ignoreIllegals: true });
430
+ viewer.setContent(addLineNumbers(highlighted));
431
+ const icon = getFileIcon(path.basename(nodePath), false);
432
+ const dim = hexToAnsi('#565f89');
433
+ viewer.setLabel(` ${icon} ${path.basename(nodePath)} ${dim}${lineCount} lines │ ${sizeKB} KB\x1b[0m `);
434
+ } else if (stat.isDirectory()) {
435
+ const items = fs.readdirSync(nodePath);
436
+ const dirIcon = `${hexToAnsi(theme.accent)}▾ \x1b[0m`;
437
+ let content = `\n ${dirIcon}${hexToAnsi(theme.accent)}\x1b[1m${path.basename(nodePath)}\x1b[0m\n`;
438
+ content += ` ${hexToAnsi('#292e42')}${'─'.repeat(Math.min(50, path.basename(nodePath).length + 10))}\x1b[0m\n\n`;
439
+ const dirItems = items.filter(i => !i.startsWith('.')).sort((a, b) => {
440
+ try {
441
+ const aIsDir = fs.statSync(path.join(nodePath, a)).isDirectory();
442
+ const bIsDir = fs.statSync(path.join(nodePath, b)).isDirectory();
443
+ if (aIsDir && !bIsDir) return -1;
444
+ if (!aIsDir && bIsDir) return 1;
445
+ } catch (e) {}
446
+ return a.localeCompare(b);
447
+ });
448
+ dirItems.forEach(item => {
449
+ let subIsDir = false;
450
+ try { subIsDir = fs.statSync(path.join(nodePath, item)).isDirectory(); } catch (e) {}
451
+ content += ` ${getFileIcon(item, subIsDir)} ${item}\n`;
452
+ });
453
+ if (dirItems.length === 0) content += ` ${hexToAnsi('#565f89')}(Empty directory)\x1b[0m`;
454
+ viewer.setContent(content);
455
+ const dim = hexToAnsi('#565f89');
456
+ viewer.setLabel(` ${dirIcon}${path.basename(nodePath)} ${dim}${dirItems.length} items\x1b[0m `);
457
+ }
458
+ } catch (err) {
459
+ viewer.setContent(`${hexToAnsi(theme.red)}⊘ Error:\x1b[0m ${err.message}`);
460
+ }
461
+
462
+ screen.render();
463
+ }
464
+
465
+ // ── Status Bar ──────────────────────────────────────────────────────────
466
+ function updateStatusBar(mode) {
467
+ const sep = `${hexToAnsi('#292e42')}│\x1b[0m`;
468
+ const key = (k) => `\x1b[1m${hexToAnsi(theme.accent)}${k}\x1b[0m`;
469
+ const sessionCount = liveSessions.length;
470
+ const sessionHint = sessionCount > 0 ? ` ${sep} ${key('Ctrl-A')} Sessions (${sessionCount})` : '';
471
+ const quit = `${key('Ctrl-D')} ${hexToAnsi(theme.red)}Quit All\x1b[0m`;
472
+ if (mode === 'ai') {
473
+ statusBar.setContent(
474
+ ` ${key('Ctrl-C')} Stop ${sep} ${key('Ctrl-T')} Switch ${sep} ${key('Ctrl-A')} Sessions (${sessionCount}) ${sep} ${quit}`
475
+ );
476
+ } else {
477
+ statusBar.setContent(
478
+ ` ${key('n')} New ${sep} ${key('f')} Folder ${sep} ${key('d')} Del ${sep} ${key('r')} Refresh ${sep} ${key('Ctrl-T')} Switch ${sep} ${key('a')} AI${sessionHint} ${sep} ${quit}`
479
+ );
480
+ }
481
+ }
482
+
483
+ // ── xterm-headless ──────────────────────────────────────────────────────
484
+ function cleanForBlessed(text) {
485
+ return text
486
+ .replace(/\x1b\[(\d+)C/g, (_, n) => ' '.repeat(parseInt(n)))
487
+ .replace(/\x1b\[\d*[ABDEFGHJKSTXZdf]/g, '')
488
+ .replace(/\x1b\[\?[0-9;]*[hl]/g, '')
489
+ .replace(/\x1b\[>[0-9;]*[a-zA-Z]/g, '')
490
+ .replace(/\x1b\[![a-zA-Z]/g, '')
491
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
492
+ .replace(/\x1b[()][0-9A-B]/g, '')
493
+ .replace(/\x1b[=>78DEHM]/g, '')
494
+ .replace(/\r/g, '');
495
+ }
496
+
497
+ let xtermModules = null;
498
+ function getXterm() {
499
+ if (!xtermModules) {
500
+ xtermModules = Promise.all([
501
+ import('@xterm/headless'),
502
+ import('@xterm/addon-serialize')
503
+ ]).then(([xtermMod, serMod]) => ({
504
+ Terminal: xtermMod.default.Terminal,
505
+ SerializeAddon: serMod.default.SerializeAddon
506
+ }));
507
+ }
508
+ return xtermModules;
509
+ }
510
+ getXterm();
511
+
512
+ // ── AI Session Pool ─────────────────────────────────────────────────────
513
+ // Each live session: { id, cmd, label, color, pty, xterm, serialize, ready, renderTimer }
514
+
515
+ function getActiveSession() {
516
+ if (activeSessionIdx >= 0 && activeSessionIdx < liveSessions.length) return liveSessions[activeSessionIdx];
517
+ return null;
518
+ }
519
+
520
+ function showAIPicker() {
521
+ aiPicker.show();
522
+ aiPicker.focus();
523
+ screen.render();
524
+ }
525
+
526
+ function hideAIPicker() {
527
+ aiPicker.hide();
528
+ tree.rows.focus();
529
+ screen.render();
530
+ }
531
+
532
+ // Switch viewer to show a live session by index
533
+ function switchToSession(idx) {
534
+ if (idx < 0 || idx >= liveSessions.length) return;
535
+ activeSessionIdx = idx;
536
+ viewMode = 'ai';
537
+ lastPath = null;
538
+ const s = liveSessions[idx];
539
+ viewer.setLabel(` ${hexToAnsi(s.color)}◆ ${s.label}\x1b[0m `);
540
+ viewer.style.border.fg = s.color;
541
+ if (s.xterm && s.serialize) {
542
+ viewer.setContent(cleanForBlessed(s.serialize.serialize()));
543
+ }
544
+ updateStatusBar('ai');
545
+ focusIndex = panels.indexOf('viewer');
546
+ tree.style.border.fg = theme.border;
547
+ historyList.style.border.fg = theme.border;
548
+ viewer.focus();
549
+ screen.render();
550
+ }
551
+
552
+ // ── Session Switcher Popup (Ctrl+A) ────────────────────────────────────
553
+ const sessionSwitcher = blessed.list({
554
+ parent: screen,
555
+ top: 'center',
556
+ left: 'center',
557
+ width: 50,
558
+ height: 10,
559
+ label: ` ◆ Active Sessions `,
560
+ mouse: true,
561
+ keys: true,
562
+ tags: false,
563
+ border: { type: 'line', fg: theme.accent },
564
+ style: {
565
+ fg: theme.text,
566
+ bg: theme.sidebar,
567
+ selected: { bg: theme.accentDim, fg: theme.textBright },
568
+ label: { fg: theme.accent },
569
+ border: { fg: theme.accent }
570
+ },
571
+ padding: { left: 2, right: 2 },
572
+ hidden: true
573
+ });
574
+
575
+ function showSessionSwitcher() {
576
+ if (liveSessions.length === 0) return;
577
+ const items = liveSessions.map((s, i) => {
578
+ const marker = i === activeSessionIdx && viewMode === 'ai' ? `${hexToAnsi(theme.green)}▸\x1b[0m` : ' ';
579
+ return `${marker} ${hexToAnsi(s.color)}◆\x1b[0m ${s.label}`;
580
+ });
581
+ sessionSwitcher.setItems(items);
582
+ sessionSwitcher.select(activeSessionIdx >= 0 ? activeSessionIdx : 0);
583
+ sessionSwitcher.show();
584
+ sessionSwitcher.focus();
585
+ screen.render();
586
+ }
587
+
588
+ sessionSwitcher.on('select', (item, index) => {
589
+ sessionSwitcher.hide();
590
+ switchToSession(index);
591
+ });
592
+
593
+ sessionSwitcher.key(['escape', 'q', 'C-a'], () => {
594
+ sessionSwitcher.hide();
595
+ screen.render();
596
+ });
597
+
598
+ // Launch AI: new session
599
+ async function launchAI(choice) {
600
+ hideAIPicker();
601
+ const cmd = choice === 0 ? 'claude' : 'codex';
602
+ const label = choice === 0 ? 'Claude Code' : 'OpenAI Codex';
603
+ await launchAIWithArgs(cmd, [], label);
604
+ }
605
+
606
+ // Launch AI: with arbitrary args (used for both new and resume)
607
+ async function launchAIWithArgs(cmd, args, label) {
608
+ const cwd = process.cwd();
609
+
610
+ viewMode = 'ai';
611
+ lastPath = null;
612
+
613
+ viewer.setContent('');
614
+ viewer.scrollTo(0);
615
+ screen.realloc();
616
+
617
+ const headerColor = cmd === 'claude' ? theme.orange : theme.green;
618
+ viewer.setLabel(` ${hexToAnsi(headerColor)}◆ ${label}\x1b[0m `);
619
+ viewer.style.border.fg = headerColor;
620
+ updateStatusBar('ai');
621
+ screen.render();
622
+
623
+ const cols = Math.max(viewer.width - viewer.iwidth - 2, 40);
624
+ const rows = Math.max(viewer.height - viewer.iheight, 10);
625
+
626
+ const { Terminal, SerializeAddon } = await getXterm();
627
+ const xterm = new Terminal({ cols, rows, allowProposedApi: true });
628
+ const serialize = new SerializeAddon();
629
+ xterm.loadAddon(serialize);
630
+
631
+ let fullCmd;
632
+ try {
633
+ fullCmd = execSync(`which ${cmd}`, { encoding: 'utf-8' }).trim();
634
+ } catch (e) {
635
+ viewer.setContent(`\n ${hexToAnsi(theme.red)}⊘ Error: "${cmd}" not found in PATH.\x1b[0m\n\n Install it first, then try again.\n`);
636
+ viewMode = 'file';
637
+ xterm.dispose();
638
+ updateStatusBar('normal');
639
+ screen.render();
640
+ return;
641
+ }
642
+
643
+ let ptyProc;
644
+ try {
645
+ ptyProc = pty.spawn(fullCmd, args, {
646
+ name: 'xterm-256color',
647
+ cols, rows,
648
+ cwd: cwd,
649
+ env: { ...process.env, TERM: 'xterm-256color' }
650
+ });
651
+ } catch (e) {
652
+ viewer.setContent(`\n ${hexToAnsi(theme.red)}⊘ Failed to launch "${cmd}":\x1b[0m\n\n ${e.message}\n`);
653
+ viewMode = 'file';
654
+ xterm.dispose();
655
+ updateStatusBar('normal');
656
+ screen.render();
657
+ return;
658
+ }
659
+
660
+ // Create session object
661
+ const session = {
662
+ id: `${cmd}-${Date.now()}`,
663
+ cmd, label, color: headerColor,
664
+ pty: ptyProc, xterm, serialize,
665
+ ready: false, renderTimer: null
666
+ };
667
+ liveSessions.push(session);
668
+ activeSessionIdx = liveSessions.length - 1;
669
+
670
+ function renderXterm() {
671
+ if (!session.xterm || !session.serialize) return;
672
+ // Only update viewer if this session is the active one being shown
673
+ if (viewMode !== 'ai' || liveSessions[activeSessionIdx] !== session) return;
674
+ const raw = session.serialize.serialize();
675
+ viewer.setContent(cleanForBlessed(raw));
676
+ screen.render();
677
+ }
678
+
679
+ ptyProc.onData((data) => {
680
+ if (!session.ready) session.ready = true;
681
+ session.xterm.write(data, () => {
682
+ if (!session.renderTimer) {
683
+ session.renderTimer = setTimeout(() => {
684
+ session.renderTimer = null;
685
+ renderXterm();
686
+ }, 16);
687
+ }
688
+ });
689
+ });
690
+
691
+ ptyProc.onExit(({ exitCode }) => {
692
+ if (session.renderTimer) { clearTimeout(session.renderTimer); session.renderTimer = null; }
693
+
694
+ // Remove from live sessions
695
+ const idx = liveSessions.indexOf(session);
696
+ if (idx !== -1) {
697
+ liveSessions.splice(idx, 1);
698
+ // Adjust activeSessionIdx
699
+ if (liveSessions.length === 0) {
700
+ activeSessionIdx = -1;
701
+ viewMode = 'file';
702
+ viewer.style.border.fg = theme.border;
703
+ viewer.setLabel(' ◈ PREVIEW ');
704
+ updateStatusBar('normal');
705
+ } else if (activeSessionIdx >= liveSessions.length) {
706
+ activeSessionIdx = liveSessions.length - 1;
707
+ }
708
+ }
709
+
710
+ // Show final output if this was the displayed session
711
+ if (viewMode === 'ai' && activeSessionIdx >= 0) {
712
+ switchToSession(activeSessionIdx);
713
+ }
714
+
715
+ session.xterm.dispose();
716
+ session.xterm = null;
717
+ session.serialize = null;
718
+ session.pty = null;
719
+
720
+ refreshHistory();
721
+ screen.render();
722
+ });
723
+
724
+ viewer.focus();
725
+ focusIndex = panels.indexOf('viewer');
726
+ viewer.style.border.fg = headerColor;
727
+ tree.style.border.fg = theme.border;
728
+ historyList.style.border.fg = theme.border;
729
+ screen.render();
730
+ }
731
+
732
+ function stopActiveSession() {
733
+ const s = getActiveSession();
734
+ if (s && s.pty) {
735
+ s.pty.kill();
736
+ }
737
+ }
738
+
739
+ // Forward keyboard input to active AI session
740
+ viewer.on('keypress', (ch, key) => {
741
+ const s = getActiveSession();
742
+ if (!s || !s.pty || !s.ready || viewMode !== 'ai') return;
743
+ if (key) {
744
+ if (key.name === 'return') { s.pty.write('\r'); }
745
+ else if (key.name === 'backspace') { s.pty.write('\x7f'); }
746
+ else if (key.name === 'escape') { s.pty.write('\x1b'); }
747
+ else if (key.name === 'up') { s.pty.write('\x1b[A'); }
748
+ else if (key.name === 'down') { s.pty.write('\x1b[B'); }
749
+ else if (key.name === 'right') { s.pty.write('\x1b[C'); }
750
+ else if (key.name === 'left') { s.pty.write('\x1b[D'); }
751
+ else if (key.ctrl && key.name === 'c') { s.pty.write('\x03'); }
752
+ else if (key.ctrl && key.name === 'd') { s.pty.write('\x04'); }
753
+ else if (key.ctrl && key.name === 'l') { s.pty.write('\x0c'); }
754
+ else if (key.ctrl && key.name === 'z') { s.pty.write('\x1a'); }
755
+ else if (ch) { s.pty.write(ch); }
756
+ } else if (ch) {
757
+ s.pty.write(ch);
758
+ }
759
+ });
760
+
761
+ aiPicker.on('select', (item, index) => {
762
+ launchAI(index);
763
+ });
764
+
765
+ aiPicker.key(['escape', 'q'], () => {
766
+ hideAIPicker();
767
+ });
768
+
769
+ // ── Event Handlers ──────────────────────────────────────────────────────
770
+ tree.on('select', (node) => {
771
+ if (node.path) {
772
+ viewMode = 'file';
773
+ updateViewer(node.path);
774
+ }
775
+ });
776
+
777
+ tree.rows.on('scroll', () => {
778
+ if (isRefreshing) return;
779
+ const node = tree.nodeLines[tree.rows.selected];
780
+ if (node && node.path) {
781
+ viewMode = 'file';
782
+ updateViewer(node.path);
783
+ }
784
+ });
785
+
786
+ tree.rows.key(['n'], () => {
787
+ prompt.input('New file name:', '', (err, value) => {
788
+ if (value) { try { fs.ensureFileSync(path.join(process.cwd(), value)); refreshTree(); } catch (e) {} }
789
+ tree.rows.focus();
790
+ screen.render();
791
+ });
792
+ });
793
+
794
+ tree.rows.key(['f'], () => {
795
+ prompt.input('New folder name:', '', (err, value) => {
796
+ if (value) { try { fs.ensureDirSync(path.join(process.cwd(), value)); refreshTree(); } catch (e) {} }
797
+ tree.rows.focus();
798
+ screen.render();
799
+ });
800
+ });
801
+
802
+ tree.rows.key(['d', 'delete'], () => {
803
+ const node = tree.nodeLines[tree.rows.selected];
804
+ if (node && node.path) {
805
+ question.ask(`Delete ${path.basename(node.path)}? (y/n)`, (err, data) => {
806
+ if (data) { try { fs.removeSync(node.path); lastPath = null; viewer.setContent(''); refreshTree(); } catch (e) {} }
807
+ tree.rows.focus();
808
+ screen.render();
809
+ });
810
+ }
811
+ });
812
+
813
+ screen.key(['a'], () => {
814
+ if (!aiPicker.hidden || !sessionSwitcher.hidden) return;
815
+ if (panels[focusIndex] === 'viewer' && viewMode === 'ai') return; // don't trigger while typing in AI
816
+ showAIPicker();
817
+ });
818
+
819
+ screen.key(['C-a'], () => {
820
+ if (!aiPicker.hidden) return;
821
+ if (!sessionSwitcher.hidden) { sessionSwitcher.hide(); screen.render(); return; }
822
+ if (liveSessions.length > 0) {
823
+ showSessionSwitcher();
824
+ }
825
+ });
826
+
827
+ screen.key(['r'], () => {
828
+ if (!aiPicker.hidden || !sessionSwitcher.hidden) return;
829
+ if (panels[focusIndex] === 'viewer' && viewMode === 'ai') return;
830
+ lastPath = null;
831
+ screen.realloc();
832
+ refreshTree();
833
+ refreshHistory(true);
834
+ });
835
+
836
+ // Tab cycles: tree → history → viewer → tree
837
+ const panels = ['tree', 'history', 'viewer'];
838
+ let focusIndex = 0;
839
+
840
+ function setFocusPanel(idx) {
841
+ focusIndex = idx;
842
+ // Reset all borders
843
+ tree.style.border.fg = theme.border;
844
+ historyList.style.border.fg = theme.border;
845
+ viewer.style.border.fg = theme.border;
846
+
847
+ if (panels[idx] === 'tree') {
848
+ tree.rows.focus();
849
+ tree.style.border.fg = theme.borderFocus;
850
+ updateStatusBar('normal');
851
+ } else if (panels[idx] === 'history') {
852
+ refreshHistory(true); // reload from disk to catch external sessions
853
+ historyList.focus();
854
+ historyList.style.border.fg = theme.purple;
855
+ updateStatusBar('normal');
856
+ } else {
857
+ viewer.focus();
858
+ const s = getActiveSession();
859
+ if (s && s.pty) {
860
+ // Restore AI terminal view
861
+ viewMode = 'ai';
862
+ lastPath = null;
863
+ viewer.style.border.fg = s.color;
864
+ viewer.setLabel(` ${hexToAnsi(s.color)}◆ ${s.label}\x1b[0m `);
865
+ if (s.xterm && s.serialize) {
866
+ viewer.setContent(cleanForBlessed(s.serialize.serialize()));
867
+ }
868
+ updateStatusBar('ai');
869
+ } else {
870
+ viewer.style.border.fg = theme.borderFocus;
871
+ updateStatusBar('normal');
872
+ }
873
+ }
874
+ screen.render();
875
+ }
876
+
877
+ screen.key(['C-t'], () => {
878
+ if (!aiPicker.hidden || !sessionSwitcher.hidden) return;
879
+ focusIndex = (focusIndex + 1) % panels.length;
880
+ setFocusPanel(focusIndex);
881
+ });
882
+
883
+ screen.key(['tab'], () => {
884
+ if (!aiPicker.hidden || !sessionSwitcher.hidden) return;
885
+ // When AI is active and viewer is focused, forward tab to pty
886
+ const s = getActiveSession();
887
+ if (s && s.pty && s.ready && viewMode === 'ai' && panels[focusIndex] === 'viewer') {
888
+ s.pty.write('\t');
889
+ return;
890
+ }
891
+ focusIndex = (focusIndex + 1) % panels.length;
892
+ setFocusPanel(focusIndex);
893
+ });
894
+
895
+ screen.key(['escape'], () => {
896
+ if (!sessionSwitcher.hidden) { sessionSwitcher.hide(); screen.render(); return; }
897
+ if (!aiPicker.hidden) { hideAIPicker(); return; }
898
+ const s = getActiveSession();
899
+ if (s && s.pty && viewMode === 'ai' && panels[focusIndex] === 'viewer') { s.pty.write('\x1b'); return; }
900
+ process.exit(0);
901
+ });
902
+
903
+ screen.key(['q'], () => {
904
+ if (!aiPicker.hidden || !sessionSwitcher.hidden) return;
905
+ if (viewMode === 'ai' && panels[focusIndex] === 'viewer') return;
906
+ process.exit(0);
907
+ });
908
+
909
+ screen.key(['C-c'], () => {
910
+ const s = getActiveSession();
911
+ if (s && s.pty && viewMode === 'ai' && panels[focusIndex] === 'viewer') { s.pty.write('\x03'); return; }
912
+ process.exit(0);
913
+ });
914
+
915
+ // Ctrl-D: kill ALL live sessions and quit immediately
916
+ screen.key(['C-d'], () => {
917
+ for (const s of liveSessions) {
918
+ if (s.pty) { try { s.pty.kill(); } catch (e) {} }
919
+ if (s.xterm) { try { s.xterm.dispose(); } catch (e) {} }
920
+ }
921
+ liveSessions = [];
922
+ process.exit(0);
923
+ });
924
+
925
+ // Double Ctrl-C to force quit active AI session
926
+ let lastCtrlC = 0;
927
+ screen.on('keypress', (ch, key) => {
928
+ const s = getActiveSession();
929
+ if (key && key.ctrl && key.name === 'c' && s && s.pty && viewMode === 'ai' && panels[focusIndex] === 'viewer') {
930
+ const now = Date.now();
931
+ if (now - lastCtrlC < 500) {
932
+ stopActiveSession();
933
+ }
934
+ lastCtrlC = now;
935
+ }
936
+ });
937
+
938
+ screen.on('resize', () => {
939
+ const cols = Math.max(viewer.width - viewer.iwidth - 2, 40);
940
+ const rows = Math.max(viewer.height - viewer.iheight, 10);
941
+ // Resize ALL live sessions
942
+ for (const s of liveSessions) {
943
+ if (s.pty) s.pty.resize(cols, rows);
944
+ if (s.xterm) s.xterm.resize(cols, rows);
945
+ }
946
+ });
947
+
948
+ // ── Init ────────────────────────────────────────────────────────────────
949
+ refreshTree();
950
+ refreshHistory();
951
+ setFocusPanel(0);
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "tpc-explorer",
3
+ "version": "1.0.0",
4
+ "description": "Terminal file explorer with embedded AI assistants (Claude Code / OpenAI Codex)",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "tpc": "./index.js"
8
+ },
9
+ "keywords": [
10
+ "terminal",
11
+ "file-explorer",
12
+ "tui",
13
+ "claude",
14
+ "codex",
15
+ "ai",
16
+ "cli"
17
+ ],
18
+ "author": "",
19
+ "license": "ISC",
20
+ "type": "commonjs",
21
+ "os": [
22
+ "darwin",
23
+ "linux",
24
+ "win32"
25
+ ],
26
+ "engines": {
27
+ "node": ">=18.0.0"
28
+ },
29
+ "files": [
30
+ "index.js",
31
+ "setup.js"
32
+ ],
33
+ "dependencies": {
34
+ "@xterm/addon-serialize": "^0.14.0",
35
+ "@xterm/headless": "^6.0.0",
36
+ "blessed": "^0.1.81",
37
+ "blessed-contrib": "^4.11.0",
38
+ "cli-highlight": "^2.1.11",
39
+ "fs-extra": "^11.3.4",
40
+ "node-pty": "^1.1.0"
41
+ },
42
+ "scripts": {
43
+ "postinstall": "node setup.js",
44
+ "test": "echo \"No tests\" && exit 0"
45
+ }
46
+ }
package/setup.js ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ const os = require('os');
3
+ const { execSync } = require('child_process');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+
7
+ const platform = os.platform();
8
+ const arch = os.arch();
9
+
10
+ console.log(`\n TPC Explorer Pro - Setup`);
11
+ console.log(` Platform: ${platform} (${arch})`);
12
+ console.log(` Node: ${process.version}\n`);
13
+
14
+ // Check node version
15
+ const major = parseInt(process.version.slice(1).split('.')[0]);
16
+ if (major < 18) {
17
+ console.error(' Error: Node.js >= 18 is required.');
18
+ process.exit(1);
19
+ }
20
+
21
+ // Platform-specific prerequisites check
22
+ if (platform === 'darwin') {
23
+ // macOS: need Xcode CLI tools for node-gyp (node-pty)
24
+ try {
25
+ execSync('xcode-select -p', { stdio: 'ignore' });
26
+ console.log(' [ok] Xcode Command Line Tools found');
27
+ } catch (e) {
28
+ console.log(' [!] Xcode Command Line Tools not found.');
29
+ console.log(' Run: xcode-select --install');
30
+ console.log(' Then re-run: npx tpc-explorer');
31
+ }
32
+ } else if (platform === 'linux') {
33
+ // Linux: need build-essential and python for node-gyp
34
+ const checks = [
35
+ { cmd: 'gcc --version', name: 'gcc', install: 'sudo apt install build-essential' },
36
+ { cmd: 'make --version', name: 'make', install: 'sudo apt install build-essential' },
37
+ ];
38
+ for (const c of checks) {
39
+ try {
40
+ execSync(c.cmd, { stdio: 'ignore' });
41
+ console.log(` [ok] ${c.name} found`);
42
+ } catch (e) {
43
+ console.log(` [!] ${c.name} not found. Install: ${c.install}`);
44
+ }
45
+ }
46
+ } else if (platform === 'win32') {
47
+ console.log(' [info] Windows detected.');
48
+ console.log(' [info] node-pty requires "windows-build-tools".');
49
+ console.log(' [info] If build fails, run: npm install -g windows-build-tools');
50
+ }
51
+
52
+ // Rebuild node-pty for the current platform/arch
53
+ console.log('\n Building native modules (node-pty)...');
54
+ try {
55
+ const nodePtyDir = path.dirname(require.resolve('node-pty/package.json'));
56
+ execSync('npx node-gyp rebuild', {
57
+ cwd: nodePtyDir,
58
+ stdio: 'inherit',
59
+ env: { ...process.env, HOME: os.homedir() }
60
+ });
61
+ console.log(' [ok] node-pty built successfully\n');
62
+ } catch (e) {
63
+ console.log(' [!] node-pty build failed. AI terminal features may not work.');
64
+ console.log(' Try: cd node_modules/node-pty && npx node-gyp rebuild\n');
65
+ }
66
+
67
+ console.log(' Ready! Run "tpc" to start the explorer.\n');