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.
- package/index.js +951 -0
- package/package.json +46 -0
- 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');
|