tpc-explorer 1.1.1 → 2.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/README.md +33 -11
  2. package/index.js +477 -230
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # TPC Explorer Pro
2
2
 
3
- A terminal file explorer with embedded AI assistants (Claude Code / OpenAI Codex).
3
+ A terminal file explorer with embedded AI assistants, command palette, git integration, and multi-session support.
4
4
 
5
5
  ![Node.js](https://img.shields.io/badge/node-%3E%3D18-green) ![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-blue)
6
6
 
@@ -19,17 +19,24 @@ npx tpc-explorer
19
19
 
20
20
  ## Features
21
21
 
22
- - File tree with syntax-highlighted preview and line numbers
23
- - Embedded AI terminal launch Claude Code or OpenAI Codex inside the explorer
24
- - Multiple AI sessions running simultaneously, switch between them with `Ctrl+A`
25
- - Session history browse and resume past Claude/Codex conversations
22
+ - **File tree** with syntax-highlighted preview, line numbers, and breadcrumb navigation
23
+ - **Git integration**branch name in status bar, M/A/U/D indicators on changed files
24
+ - **Embedded AI terminal** launch Claude Code or OpenAI Codex inside the explorer
25
+ - **Multi-session**run multiple AI sessions simultaneously, switch with `Ctrl+A`
26
+ - **Session history** — browse and resume past Claude/Codex conversations
27
+ - **Command palette** (`Ctrl+B`) — git status, diff, log, branches, disk usage, package info, and more
28
+ - **File search** (`Ctrl+F`) — fuzzy find files across the project
29
+ - **Welcome screen** — ASCII logo with shortcuts and project info on startup
30
+ - **Loading spinner** when AI sessions start
31
+ - **Copy path** and **Open in $EDITOR** from the tree
26
32
  - Tokyo Night Storm theme with emoji file-type icons (no Nerd Font needed)
27
- - File management: create files/folders, delete
28
33
 
29
34
  ## Keyboard Shortcuts
30
35
 
31
36
  | Key | Action |
32
37
  |-----|--------|
38
+ | `Ctrl+B` | Command palette (git, views, actions) |
39
+ | `Ctrl+F` | Search files |
33
40
  | `Ctrl+T` | Switch panel (Tree / Sessions / Viewer) |
34
41
  | `Ctrl+A` | Switch between active AI sessions |
35
42
  | `Ctrl+D` | Kill all sessions and quit |
@@ -37,7 +44,9 @@ npx tpc-explorer
37
44
  | `n` | New file |
38
45
  | `f` | New folder |
39
46
  | `d` | Delete file/folder |
40
- | `r` | Refresh tree and sessions |
47
+ | `c` | Copy file path to clipboard |
48
+ | `e` | Open in $EDITOR |
49
+ | `r` | Refresh tree, git status, and sessions |
41
50
  | `q` / `Esc` | Quit |
42
51
 
43
52
  ### Inside AI session
@@ -47,21 +56,34 @@ npx tpc-explorer
47
56
  | `Ctrl+C` | Send interrupt to AI |
48
57
  | `Ctrl+C` x2 | Force kill active session |
49
58
  | `Ctrl+T` | Switch to another panel (AI keeps running) |
59
+ | `Ctrl+B` | Open command palette |
50
60
 
51
61
  ## Layout
52
62
 
53
63
  ```
54
64
  ┌─ EXPLORER ──┬─────────── PREVIEW ───────────┐
55
- │ file tree │ syntax-highlighted file view
65
+ │ file tree │ breadcrumb path
66
+ │ with git │ ───────────────────── │
67
+ │ indicators │ syntax-highlighted content │
56
68
  │ │ or embedded AI terminal │
57
69
  ├─ SESSIONS ──┤ │
58
- │ Claude │ │
59
- │ Codex │ │
70
+ Claude │ │
71
+ Codex │ │
60
72
  ├─────────────┴────────────────────────────────┤
61
- status bar Ctrl-D Quit
73
+ Ctrl-T Switch │ Ctrl-B Cmds ⊙ main Ctrl-D│
62
74
  └──────────────────────────────────────────────┘
63
75
  ```
64
76
 
77
+ ## Command Palette
78
+
79
+ Press `Ctrl+B` to open. Available actions:
80
+
81
+ **Actions** — Launch AI, Switch Sessions, Search Files, New File/Folder, Delete, Copy Path, Open in Editor
82
+
83
+ **Git** — Status, Diff, Log (last 20), Branches, Stash List
84
+
85
+ **View** — Disk Usage, Package Info (parsed package.json with scripts, deps)
86
+
65
87
  ## Requirements
66
88
 
67
89
  - Node.js >= 18
package/index.js CHANGED
@@ -68,6 +68,13 @@ function hexToAnsi(hex) {
68
68
  const b = parseInt(hex.slice(5, 7), 16);
69
69
  return `\x1b[38;2;${r};${g};${b}m`;
70
70
  }
71
+ function hexToBgAnsi(hex) {
72
+ if (!hex || hex[0] !== '#') return '';
73
+ const r = parseInt(hex.slice(1, 3), 16);
74
+ const g = parseInt(hex.slice(3, 5), 16);
75
+ const b = parseInt(hex.slice(5, 7), 16);
76
+ return `\x1b[48;2;${r};${g};${b}m`;
77
+ }
71
78
 
72
79
  function getFileIcon(filename, isDir) {
73
80
  if (isDir) return `${hexToAnsi(theme.accent)}▸ \x1b[0m`;
@@ -82,12 +89,53 @@ function getFileIcon(filename, isDir) {
82
89
  return `${hexToAnsi(color)}${icon}\x1b[0m`;
83
90
  }
84
91
 
92
+ // ── Git Helpers ─────────────────────────────────────────────────────────
93
+ let isGitRepo = false;
94
+ let gitStatusMap = {}; // relativePath → status char (M, A, ?, D, etc.)
95
+ let gitBranch = '';
96
+
97
+ function refreshGitStatus() {
98
+ try {
99
+ execSync('git rev-parse --is-inside-work-tree', { cwd: process.cwd(), stdio: 'ignore' });
100
+ isGitRepo = true;
101
+ } catch (e) { isGitRepo = false; gitStatusMap = {}; gitBranch = ''; return; }
102
+ try {
103
+ gitBranch = execSync('git branch --show-current', { cwd: process.cwd(), encoding: 'utf-8' }).trim();
104
+ } catch (e) { gitBranch = ''; }
105
+ gitStatusMap = {};
106
+ try {
107
+ const out = execSync('git status --porcelain', { cwd: process.cwd(), encoding: 'utf-8' });
108
+ for (const line of out.split('\n')) {
109
+ if (line.length < 4) continue;
110
+ const xy = line.slice(0, 2);
111
+ const file = line.slice(3);
112
+ const first = file.split('/')[0];
113
+ // Simplified: M=modified, A=added, ?=untracked, D=deleted
114
+ if (xy.includes('?')) { gitStatusMap[file] = '?'; gitStatusMap[first] = gitStatusMap[first] || '?'; }
115
+ else if (xy.includes('A')) { gitStatusMap[file] = 'A'; gitStatusMap[first] = gitStatusMap[first] || 'A'; }
116
+ else if (xy.includes('D')) { gitStatusMap[file] = 'D'; gitStatusMap[first] = gitStatusMap[first] || 'D'; }
117
+ else if (xy.includes('M') || xy.includes('U')) { gitStatusMap[file] = 'M'; gitStatusMap[first] = 'M'; }
118
+ }
119
+ } catch (e) {}
120
+ }
121
+
122
+ function getGitIndicator(filePath) {
123
+ if (!isGitRepo) return '';
124
+ const rel = path.relative(process.cwd(), filePath);
125
+ const status = gitStatusMap[rel];
126
+ if (!status) return '';
127
+ if (status === 'M') return ` ${hexToAnsi(theme.yellow)}M\x1b[0m`;
128
+ if (status === 'A') return ` ${hexToAnsi(theme.green)}A\x1b[0m`;
129
+ if (status === '?') return ` ${hexToAnsi(theme.textDim)}U\x1b[0m`;
130
+ if (status === 'D') return ` ${hexToAnsi(theme.red)}D\x1b[0m`;
131
+ return '';
132
+ }
133
+
85
134
  // ── Session History Loading ─────────────────────────────────────────────
86
135
  function loadSessions() {
87
136
  const cwd = process.cwd();
88
137
  const sessions = [];
89
138
 
90
- // Claude sessions
91
139
  const claudeProjectKey = cwd.replace(/\//g, '-');
92
140
  const claudeDir = path.join(os.homedir(), '.claude', 'projects', claudeProjectKey);
93
141
  if (fs.existsSync(claudeDir)) {
@@ -107,18 +155,11 @@ function loadSessions() {
107
155
  }
108
156
  } catch (e) {}
109
157
  }
110
- sessions.push({
111
- type: 'claude',
112
- id: f.replace('.jsonl', ''),
113
- modified: stat.mtime,
114
- summary: firstMsg || '(empty)',
115
- messages: lines.length,
116
- });
158
+ sessions.push({ type: 'claude', id: f.replace('.jsonl', ''), modified: stat.mtime, summary: firstMsg || '(empty)', messages: lines.length });
117
159
  } catch (e) {}
118
160
  }
119
161
  }
120
162
 
121
- // Codex sessions — scan for sessions whose cwd matches
122
163
  const codexBaseDir = path.join(os.homedir(), '.codex', 'sessions');
123
164
  if (fs.existsSync(codexBaseDir)) {
124
165
  try {
@@ -133,14 +174,7 @@ function loadSessions() {
133
174
  const firstLine = fs.readFileSync(full, 'utf-8').split('\n')[0];
134
175
  const meta = JSON.parse(firstLine);
135
176
  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
- });
177
+ sessions.push({ type: 'codex', id: meta.payload.id, modified: new Date(meta.payload.timestamp || st.mtime), summary: `Codex ${meta.payload.cli_version || ''}`.trim(), messages: 0, filePath: full });
144
178
  }
145
179
  } catch (e) {}
146
180
  }
@@ -149,7 +183,6 @@ function loadSessions() {
149
183
  } catch (e) {}
150
184
  }
151
185
 
152
- // Sort newest first
153
186
  sessions.sort((a, b) => b.modified - a.modified);
154
187
  return sessions;
155
188
  }
@@ -173,7 +206,7 @@ function formatAge(date) {
173
206
  return `${days}d`;
174
207
  }
175
208
 
176
- // ── AI Session Pool State (declared early for use in updateStatusBar) ───
209
+ // ── AI Session Pool State ───────────────────────────────────────────────
177
210
  let liveSessions = [];
178
211
  let activeSessionIdx = -1;
179
212
  let viewMode = 'file';
@@ -194,8 +227,7 @@ const tree = grid.set(0, 0, 7, 3, contrib.tree, {
194
227
  label: ' ⊟ EXPLORER ',
195
228
  mouse: true,
196
229
  style: {
197
- fg: theme.text,
198
- bg: theme.sidebar,
230
+ fg: theme.text, bg: theme.sidebar,
199
231
  selected: { bg: theme.accentDim, fg: theme.textBright },
200
232
  label: { fg: theme.accent }
201
233
  },
@@ -206,18 +238,13 @@ const tree = grid.set(0, 0, 7, 3, contrib.tree, {
206
238
 
207
239
  const historyList = grid.set(7, 0, 4, 3, blessed.list, {
208
240
  label: ' ◆ SESSIONS ',
209
- mouse: true,
210
- keys: true,
211
- tags: false,
212
- scrollable: true,
213
- alwaysScroll: true,
241
+ mouse: true, keys: true, tags: false,
242
+ scrollable: true, alwaysScroll: true,
214
243
  scrollbar: { ch: '▐', style: { fg: theme.accentDim, bg: theme.sidebar } },
215
244
  style: {
216
- fg: theme.text,
217
- bg: theme.sidebar,
245
+ fg: theme.text, bg: theme.sidebar,
218
246
  selected: { bg: theme.accentDim, fg: theme.textBright },
219
- label: { fg: theme.purple },
220
- border: { fg: theme.border }
247
+ label: { fg: theme.purple }, border: { fg: theme.border }
221
248
  },
222
249
  border: { type: 'line', fg: theme.border },
223
250
  padding: { left: 1 }
@@ -225,17 +252,10 @@ const historyList = grid.set(7, 0, 4, 3, blessed.list, {
225
252
 
226
253
  const viewer = grid.set(0, 3, 11, 9, blessed.box, {
227
254
  label: ' ◈ PREVIEW ',
228
- mouse: true,
229
- keys: true,
230
- tags: false,
231
- alwaysScroll: true,
232
- scrollable: true,
255
+ mouse: true, keys: true, tags: false,
256
+ alwaysScroll: true, scrollable: true,
233
257
  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
- },
258
+ style: { fg: theme.text, bg: theme.bg, label: { fg: theme.accent } },
239
259
  border: { type: 'line', fg: theme.border },
240
260
  padding: { left: 1 }
241
261
  });
@@ -262,35 +282,271 @@ const question = blessed.question({
262
282
 
263
283
  // ── AI Picker ───────────────────────────────────────────────────────────
264
284
  const aiPicker = blessed.list({
265
- parent: screen,
266
- top: 'center',
267
- left: 'center',
268
- width: 42,
269
- height: 8,
285
+ parent: screen, top: 'center', left: 'center', width: 42, height: 8,
270
286
  label: ` ◆ Launch AI Assistant `,
271
- mouse: true,
272
- keys: true,
273
- tags: false,
287
+ mouse: true, keys: true, tags: false,
274
288
  border: { type: 'line', fg: theme.purple },
275
289
  style: {
276
- fg: theme.text,
277
- bg: theme.sidebar,
290
+ fg: theme.text, bg: theme.sidebar,
278
291
  selected: { bg: theme.accentDim, fg: theme.textBright },
279
- label: { fg: theme.purple },
280
- border: { fg: theme.purple }
292
+ label: { fg: theme.purple }, border: { fg: theme.purple }
281
293
  },
282
294
  padding: { left: 2, right: 2 },
283
- items: [
284
- `⬡ Claude Code`,
285
- `⊞ OpenAI Codex`,
286
- ],
295
+ items: [`⬡ Claude Code`, `⊞ OpenAI Codex`],
287
296
  hidden: true
288
297
  });
289
298
 
299
+ // ── Command Palette (Ctrl+B) ────────────────────────────────────────────
300
+ const cmdPalette = blessed.list({
301
+ parent: screen, top: 'center', left: 'center', width: 56, height: 20,
302
+ label: ` ◆ Command Palette `,
303
+ mouse: true, keys: true, tags: false,
304
+ border: { type: 'line', fg: theme.accent },
305
+ style: {
306
+ fg: theme.text, bg: theme.sidebar,
307
+ selected: { bg: theme.accentDim, fg: theme.textBright },
308
+ label: { fg: theme.accent }, border: { fg: theme.accent }
309
+ },
310
+ padding: { left: 2, right: 2 },
311
+ hidden: true
312
+ });
313
+
314
+ const cmdActions = [
315
+ { label: `${hexToAnsi(theme.purple)}◆\x1b[0m Launch AI Assistant`, key: 'a', action: () => showAIPicker() },
316
+ { label: `${hexToAnsi(theme.accent)}◆\x1b[0m Switch Active Session`, key: 'Ctrl+A', action: () => { if (liveSessions.length > 0) showSessionSwitcher(); } },
317
+ { label: `${hexToAnsi(theme.cyan)}⊹\x1b[0m Search Files`, key: 'Ctrl+F', action: () => showSearch() },
318
+ { label: `${hexToAnsi(theme.green)}⊡\x1b[0m New File`, key: 'n', action: () => doNewFile() },
319
+ { label: `${hexToAnsi(theme.green)}▸\x1b[0m New Folder`, key: 'f', action: () => doNewFolder() },
320
+ { label: `${hexToAnsi(theme.red)}⊘\x1b[0m Delete Selected`, key: 'd', action: () => doDelete() },
321
+ { label: `${hexToAnsi(theme.yellow)}↻\x1b[0m Refresh All`, key: 'r', action: () => doRefresh() },
322
+ { label: `${hexToAnsi(theme.cyan)}⎘\x1b[0m Copy File Path`, key: 'c', action: () => doCopyPath() },
323
+ { label: `${hexToAnsi(theme.orange)}⊡\x1b[0m Open in $EDITOR`, key: 'e', action: () => doOpenEditor() },
324
+ { label: `${hexToAnsi('#292e42')}─────────── Git ───────────\x1b[0m`, action: null },
325
+ { label: `${hexToAnsi(theme.green)}⊙\x1b[0m Git Status`, action: () => runGitCommand('git status') },
326
+ { label: `${hexToAnsi(theme.yellow)}⊙\x1b[0m Git Diff`, action: () => runGitCommand('git diff --stat') },
327
+ { label: `${hexToAnsi(theme.cyan)}⊙\x1b[0m Git Log (last 20)`, action: () => runGitCommand('git log --oneline -20') },
328
+ { label: `${hexToAnsi(theme.purple)}⊙\x1b[0m Git Branches`, action: () => runGitCommand('git branch -a') },
329
+ { label: `${hexToAnsi(theme.orange)}⊙\x1b[0m Git Stash List`, action: () => runGitCommand('git stash list') },
330
+ { label: `${hexToAnsi('#292e42')}─────────── View ──────────\x1b[0m`, action: null },
331
+ { label: `${hexToAnsi(theme.cyan)}⊞\x1b[0m Disk Usage (du)`, action: () => runGitCommand('du -sh * | sort -rh | head -20') },
332
+ { label: `${hexToAnsi(theme.green)}⊞\x1b[0m Package Info`, action: () => showPackageInfo() },
333
+ ];
334
+
335
+ function showCmdPalette() {
336
+ cmdPalette.setItems(cmdActions.map(a => ` ${a.label}`));
337
+ cmdPalette.select(0);
338
+ cmdPalette.show();
339
+ cmdPalette.focus();
340
+ screen.render();
341
+ }
342
+
343
+ cmdPalette.on('select', (item, index) => {
344
+ cmdPalette.hide();
345
+ screen.render();
346
+ const action = cmdActions[index];
347
+ if (action && action.action) action.action();
348
+ });
349
+
350
+ cmdPalette.key(['escape', 'q', 'C-b'], () => {
351
+ cmdPalette.hide();
352
+ screen.render();
353
+ });
354
+
355
+ // ── Command Palette Actions ─────────────────────────────────────────────
356
+ function runGitCommand(cmd) {
357
+ viewMode = 'file';
358
+ lastPath = null;
359
+ viewer.scrollTo(0);
360
+ screen.realloc();
361
+ try {
362
+ const out = execSync(cmd, { cwd: process.cwd(), encoding: 'utf-8', timeout: 5000 });
363
+ const highlighted = cmd.startsWith('git diff') ? out : out;
364
+ viewer.setContent(addLineNumbers(highlighted));
365
+ viewer.setLabel(` ${hexToAnsi(theme.green)}⊙\x1b[0m ${cmd} `);
366
+ } catch (e) {
367
+ viewer.setContent(`${hexToAnsi(theme.red)}⊘ Error:\x1b[0m ${e.message}`);
368
+ viewer.setLabel(` ${hexToAnsi(theme.red)}⊘\x1b[0m ${cmd} `);
369
+ }
370
+ screen.render();
371
+ }
372
+
373
+ function showPackageInfo() {
374
+ viewMode = 'file';
375
+ lastPath = null;
376
+ viewer.scrollTo(0);
377
+ screen.realloc();
378
+ const pkgPath = path.join(process.cwd(), 'package.json');
379
+ if (!fs.existsSync(pkgPath)) {
380
+ viewer.setContent(`\n ${hexToAnsi(theme.textDim)}No package.json found\x1b[0m`);
381
+ viewer.setLabel(` ${hexToAnsi(theme.textDim)}⊡\x1b[0m No package.json `);
382
+ screen.render();
383
+ return;
384
+ }
385
+ try {
386
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
387
+ const dim = hexToAnsi(theme.textDim);
388
+ const accent = hexToAnsi(theme.accent);
389
+ const cyan = hexToAnsi(theme.cyan);
390
+ const green = hexToAnsi(theme.green);
391
+ const yellow = hexToAnsi(theme.yellow);
392
+ let content = `\n ${accent}\x1b[1m${pkg.name || '(unnamed)'}\x1b[0m ${dim}v${pkg.version || '0.0.0'}\x1b[0m\n`;
393
+ if (pkg.description) content += ` ${dim}${pkg.description}\x1b[0m\n`;
394
+ content += `\n`;
395
+ if (pkg.scripts) {
396
+ content += ` ${cyan}\x1b[1mScripts:\x1b[0m\n`;
397
+ for (const [k, v] of Object.entries(pkg.scripts)) {
398
+ content += ` ${green}${k}\x1b[0m ${dim}→ ${v}\x1b[0m\n`;
399
+ }
400
+ content += '\n';
401
+ }
402
+ if (pkg.dependencies) {
403
+ content += ` ${yellow}\x1b[1mDependencies (${Object.keys(pkg.dependencies).length}):\x1b[0m\n`;
404
+ for (const [k, v] of Object.entries(pkg.dependencies)) {
405
+ content += ` ${k} ${dim}${v}\x1b[0m\n`;
406
+ }
407
+ content += '\n';
408
+ }
409
+ if (pkg.devDependencies) {
410
+ content += ` ${dim}\x1b[1mDev Dependencies (${Object.keys(pkg.devDependencies).length}):\x1b[0m\n`;
411
+ for (const [k, v] of Object.entries(pkg.devDependencies)) {
412
+ content += ` ${dim}${k} ${v}\x1b[0m\n`;
413
+ }
414
+ }
415
+ viewer.setContent(content);
416
+ viewer.setLabel(` ${hexToAnsi(theme.green)}⬢\x1b[0m ${pkg.name} `);
417
+ } catch (e) {
418
+ viewer.setContent(`${hexToAnsi(theme.red)}⊘ Error:\x1b[0m ${e.message}`);
419
+ }
420
+ screen.render();
421
+ }
422
+
423
+ function doCopyPath() {
424
+ const node = tree.nodeLines && tree.nodeLines[tree.rows.selected];
425
+ if (!node || !node.path) return;
426
+ try {
427
+ const platform = os.platform();
428
+ if (platform === 'darwin') execSync(`echo -n "${node.path}" | pbcopy`);
429
+ else if (platform === 'linux') execSync(`echo -n "${node.path}" | xclip -selection clipboard 2>/dev/null || echo -n "${node.path}" | xsel --clipboard 2>/dev/null`);
430
+ viewer.setLabel(` ${hexToAnsi(theme.green)}⎘ Copied!\x1b[0m `);
431
+ setTimeout(() => { if (lastPath) updateViewer(lastPath); screen.render(); }, 1500);
432
+ } catch (e) {}
433
+ screen.render();
434
+ }
435
+
436
+ function doOpenEditor() {
437
+ const node = tree.nodeLines && tree.nodeLines[tree.rows.selected];
438
+ if (!node || !node.path) return;
439
+ const editor = process.env.EDITOR || process.env.VISUAL || 'vi';
440
+ try {
441
+ screen.destroy();
442
+ execSync(`${editor} "${node.path}"`, { stdio: 'inherit' });
443
+ } catch (e) {}
444
+ // Restart screen after editor closes
445
+ process.exit(0);
446
+ }
447
+
448
+ function doNewFile() {
449
+ prompt.input('New file name:', '', (err, value) => {
450
+ if (value) { try { fs.ensureFileSync(path.join(process.cwd(), value)); refreshTree(); } catch (e) {} }
451
+ tree.rows.focus();
452
+ screen.render();
453
+ });
454
+ }
455
+
456
+ function doNewFolder() {
457
+ prompt.input('New folder name:', '', (err, value) => {
458
+ if (value) { try { fs.ensureDirSync(path.join(process.cwd(), value)); refreshTree(); } catch (e) {} }
459
+ tree.rows.focus();
460
+ screen.render();
461
+ });
462
+ }
463
+
464
+ function doDelete() {
465
+ const node = tree.nodeLines && tree.nodeLines[tree.rows.selected];
466
+ if (node && node.path) {
467
+ question.ask(`Delete ${path.basename(node.path)}? (y/n)`, (err, data) => {
468
+ if (data) { try { fs.removeSync(node.path); lastPath = null; viewer.setContent(''); refreshTree(); } catch (e) {} }
469
+ tree.rows.focus();
470
+ screen.render();
471
+ });
472
+ }
473
+ }
474
+
475
+ function doRefresh() {
476
+ lastPath = null;
477
+ screen.realloc();
478
+ refreshGitStatus();
479
+ refreshTree();
480
+ refreshHistory(true);
481
+ }
482
+
483
+ // ── Search (Ctrl+F) ────────────────────────────────────────────────────
484
+ function showSearch() {
485
+ prompt.input('Search files:', '', (err, query) => {
486
+ if (!query) { tree.rows.focus(); screen.render(); return; }
487
+ const results = [];
488
+ const q = query.toLowerCase();
489
+ function walk(dir, depth) {
490
+ if (depth > 5) return;
491
+ try {
492
+ const items = fs.readdirSync(dir);
493
+ for (const item of items) {
494
+ if (item.startsWith('.') || item === 'node_modules' || item === 'dist') continue;
495
+ const full = path.join(dir, item);
496
+ if (item.toLowerCase().includes(q)) results.push(full);
497
+ try { if (fs.statSync(full).isDirectory()) walk(full, depth + 1); } catch (e) {}
498
+ }
499
+ } catch (e) {}
500
+ }
501
+ walk(process.cwd(), 0);
502
+
503
+ if (results.length === 0) {
504
+ viewer.setContent(`\n ${hexToAnsi(theme.textDim)}No results for "${query}"\x1b[0m`);
505
+ viewer.setLabel(` ${hexToAnsi(theme.cyan)}⊹\x1b[0m Search: ${query} `);
506
+ screen.render();
507
+ return;
508
+ }
509
+
510
+ // Show results in a picker
511
+ const searchPicker = blessed.list({
512
+ parent: screen, top: 'center', left: 'center',
513
+ width: '60%', height: Math.min(results.length + 4, 20),
514
+ label: ` ⊹ ${results.length} results for "${query}" `,
515
+ mouse: true, keys: true, tags: false,
516
+ border: { type: 'line', fg: theme.cyan },
517
+ style: {
518
+ fg: theme.text, bg: theme.sidebar,
519
+ selected: { bg: theme.accentDim, fg: theme.textBright },
520
+ label: { fg: theme.cyan }, border: { fg: theme.cyan }
521
+ },
522
+ padding: { left: 1, right: 1 },
523
+ items: results.map(r => {
524
+ const rel = path.relative(process.cwd(), r);
525
+ const isDir = fs.statSync(r).isDirectory();
526
+ return `${getFileIcon(path.basename(r), isDir)} ${rel}`;
527
+ })
528
+ });
529
+ searchPicker.focus();
530
+ screen.render();
531
+
532
+ searchPicker.on('select', (item, idx) => {
533
+ searchPicker.destroy();
534
+ viewMode = 'file';
535
+ updateViewer(results[idx]);
536
+ screen.render();
537
+ });
538
+ searchPicker.key(['escape', 'q'], () => {
539
+ searchPicker.destroy();
540
+ tree.rows.focus();
541
+ screen.render();
542
+ });
543
+ });
544
+ }
545
+
290
546
  // ── 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
547
+ let sessionData = [];
548
+ let historyLineMap = [];
549
+ let sessionsLoaded = false;
294
550
 
295
551
  function refreshHistory(forceReload) {
296
552
  const prevSelected = historyList.selected || 0;
@@ -304,30 +560,20 @@ function refreshHistory(forceReload) {
304
560
  const items = [];
305
561
  historyLineMap = [];
306
562
 
307
- // Claude section
308
563
  items.push(`${hexToAnsi(theme.orange)}\x1b[1m◆ Claude\x1b[0m`);
309
564
  historyLineMap.push(null);
310
565
  if (claudeSessions.length > 0) {
311
- for (const s of claudeSessions) {
312
- items.push(` ${formatSessionItem(s)}`);
313
- historyLineMap.push(s);
314
- }
566
+ for (const s of claudeSessions) { items.push(` ${formatSessionItem(s)}`); historyLineMap.push(s); }
315
567
  } else {
316
- items.push(` ${hexToAnsi(theme.textDim)}(none)\x1b[0m`);
317
- historyLineMap.push(null);
568
+ items.push(` ${hexToAnsi(theme.textDim)}(none)\x1b[0m`); historyLineMap.push(null);
318
569
  }
319
570
 
320
- // Codex section
321
571
  items.push(`${hexToAnsi(theme.green)}\x1b[1m⊞ Codex\x1b[0m`);
322
572
  historyLineMap.push(null);
323
573
  if (codexSessions.length > 0) {
324
- for (const s of codexSessions) {
325
- items.push(` ${formatSessionItem(s)}`);
326
- historyLineMap.push(s);
327
- }
574
+ for (const s of codexSessions) { items.push(` ${formatSessionItem(s)}`); historyLineMap.push(s); }
328
575
  } else {
329
- items.push(` ${hexToAnsi(theme.textDim)}(none)\x1b[0m`);
330
- historyLineMap.push(null);
576
+ items.push(` ${hexToAnsi(theme.textDim)}(none)\x1b[0m`); historyLineMap.push(null);
331
577
  }
332
578
 
333
579
  historyList.setItems(items);
@@ -335,15 +581,11 @@ function refreshHistory(forceReload) {
335
581
  screen.render();
336
582
  }
337
583
 
338
- // Resume a session from the history list
339
584
  historyList.on('select', (item, index) => {
340
585
  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
- }
586
+ if (!session) return;
587
+ if (session.type === 'claude') launchAIWithArgs('claude', ['--resume', session.id], 'Claude Code');
588
+ else launchAIWithArgs('codex', ['resume', session.id], 'OpenAI Codex');
347
589
  });
348
590
 
349
591
  // ── File Tree Logic ─────────────────────────────────────────────────────
@@ -364,7 +606,8 @@ function getFiles(dir) {
364
606
  const fullPath = path.join(dir, item);
365
607
  let isDir = false;
366
608
  try { isDir = fs.statSync(fullPath).isDirectory(); } catch (e) { return; }
367
- const displayName = `${getFileIcon(item, isDir)} ${item}`;
609
+ const gitInd = getGitIndicator(fullPath);
610
+ const displayName = `${getFileIcon(item, isDir)} ${item}${gitInd}`;
368
611
 
369
612
  if (isDir) {
370
613
  result.children[displayName] = { name: displayName, path: fullPath, children: {} };
@@ -374,7 +617,8 @@ function getFiles(dir) {
374
617
  if (!sub.startsWith('.')) {
375
618
  try {
376
619
  const subStat = fs.statSync(path.join(fullPath, sub));
377
- const subDisplayName = `${getFileIcon(sub, subStat.isDirectory())} ${sub}`;
620
+ const subGit = getGitIndicator(path.join(fullPath, sub));
621
+ const subDisplayName = `${getFileIcon(sub, subStat.isDirectory())} ${sub}${subGit}`;
378
622
  result.children[displayName].children[subDisplayName] = { name: subDisplayName, path: path.join(fullPath, sub) };
379
623
  } catch (e) {}
380
624
  }
@@ -409,6 +653,19 @@ function addLineNumbers(text) {
409
653
  }).join('\n');
410
654
  }
411
655
 
656
+ // ── Breadcrumb ──────────────────────────────────────────────────────────
657
+ function getBreadcrumb(filePath) {
658
+ const rel = path.relative(process.cwd(), filePath);
659
+ const parts = rel.split(path.sep);
660
+ const dim = hexToAnsi(theme.textDim);
661
+ const accent = hexToAnsi(theme.accent);
662
+ const sep = `${dim} > \x1b[0m`;
663
+ return parts.map((p, i) => {
664
+ if (i === parts.length - 1) return `${accent}\x1b[1m${p}\x1b[0m`;
665
+ return `${dim}${p}\x1b[0m`;
666
+ }).join(sep);
667
+ }
668
+
412
669
  // ── Viewer Update ───────────────────────────────────────────────────────
413
670
  let lastPath = null;
414
671
  function updateViewer(nodePath) {
@@ -426,16 +683,18 @@ function updateViewer(nodePath) {
426
683
  const content = fs.readFileSync(nodePath, 'utf-8');
427
684
  const ext = path.extname(nodePath).slice(1);
428
685
  const lineCount = content.split('\n').length;
686
+ const breadcrumb = getBreadcrumb(nodePath);
429
687
  const highlighted = highlight(content, { language: ext, ignoreIllegals: true });
430
- viewer.setContent(addLineNumbers(highlighted));
688
+ viewer.setContent(`${breadcrumb}\n${hexToAnsi('#292e42')}${'─'.repeat(60)}\x1b[0m\n${addLineNumbers(highlighted)}`);
431
689
  const icon = getFileIcon(path.basename(nodePath), false);
432
690
  const dim = hexToAnsi('#565f89');
433
- viewer.setLabel(` ${icon} ${path.basename(nodePath)} ${dim}${lineCount} lines │ ${sizeKB} KB\x1b[0m `);
691
+ const gitInd = getGitIndicator(nodePath);
692
+ viewer.setLabel(` ${icon} ${path.basename(nodePath)}${gitInd} ${dim}${lineCount} lines │ ${sizeKB} KB\x1b[0m `);
434
693
  } else if (stat.isDirectory()) {
435
694
  const items = fs.readdirSync(nodePath);
436
695
  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`;
696
+ const breadcrumb = getBreadcrumb(nodePath);
697
+ let content = `${breadcrumb}\n${hexToAnsi('#292e42')}${'─'.repeat(60)}\x1b[0m\n\n`;
439
698
  const dirItems = items.filter(i => !i.startsWith('.')).sort((a, b) => {
440
699
  try {
441
700
  const aIsDir = fs.statSync(path.join(nodePath, a)).isDirectory();
@@ -448,7 +707,8 @@ function updateViewer(nodePath) {
448
707
  dirItems.forEach(item => {
449
708
  let subIsDir = false;
450
709
  try { subIsDir = fs.statSync(path.join(nodePath, item)).isDirectory(); } catch (e) {}
451
- content += ` ${getFileIcon(item, subIsDir)} ${item}\n`;
710
+ const gitInd = getGitIndicator(path.join(nodePath, item));
711
+ content += ` ${getFileIcon(item, subIsDir)} ${item}${gitInd}\n`;
452
712
  });
453
713
  if (dirItems.length === 0) content += ` ${hexToAnsi('#565f89')}(Empty directory)\x1b[0m`;
454
714
  viewer.setContent(content);
@@ -462,20 +722,72 @@ function updateViewer(nodePath) {
462
722
  screen.render();
463
723
  }
464
724
 
725
+ // ── Welcome Screen ──────────────────────────────────────────────────────
726
+ function showWelcome() {
727
+ const dim = hexToAnsi(theme.textDim);
728
+ const accent = hexToAnsi(theme.accent);
729
+ const cyan = hexToAnsi(theme.cyan);
730
+ const purple = hexToAnsi(theme.purple);
731
+ const green = hexToAnsi(theme.green);
732
+ const orange = hexToAnsi(theme.orange);
733
+ const yellow = hexToAnsi(theme.yellow);
734
+ const bright = hexToAnsi(theme.textBright);
735
+ const border = hexToAnsi('#292e42');
736
+
737
+ const logo = `
738
+ ${accent}████████╗${cyan}██████╗ ${purple}██████╗\x1b[0m
739
+ ${accent}╚══██╔══╝${cyan}██╔══██╗${purple}██╔════╝\x1b[0m
740
+ ${accent} ██║ ${cyan}██████╔╝${purple}██║ \x1b[0m
741
+ ${accent} ██║ ${cyan}██╔═══╝ ${purple}██║ \x1b[0m
742
+ ${accent} ██║ ${cyan}██║ ${purple}╚██████╗\x1b[0m
743
+ ${accent} ╚═╝ ${cyan}╚═╝ ${purple} ╚═════╝\x1b[0m
744
+
745
+ ${bright}\x1b[1mExplorer Pro\x1b[0m ${dim}v${require('./package.json').version}\x1b[0m
746
+ `;
747
+
748
+ const gitLine = isGitRepo ? ` ${green}⊙\x1b[0m Branch: ${accent}${gitBranch}\x1b[0m ${dim}│\x1b[0m ${Object.keys(gitStatusMap).length} changes` : ` ${dim}Not a git repository\x1b[0m`;
749
+
750
+ const shortcuts = `
751
+ ${border}${'─'.repeat(44)}\x1b[0m
752
+
753
+ ${bright}\x1b[1mQuick Start\x1b[0m
754
+
755
+ ${accent}Ctrl+T\x1b[0m Switch panels ${accent}a\x1b[0m Launch AI
756
+ ${accent}Ctrl+A\x1b[0m Switch AI sessions ${accent}Ctrl+F\x1b[0m Search files
757
+ ${accent}Ctrl+B\x1b[0m Command palette ${accent}Ctrl+D\x1b[0m Quit all
758
+
759
+ ${bright}\x1b[1mFiles\x1b[0m
760
+
761
+ ${green}n\x1b[0m New file ${green}f\x1b[0m New folder ${orange}d\x1b[0m Delete
762
+ ${cyan}e\x1b[0m Open in $EDITOR ${cyan}c\x1b[0m Copy path
763
+ ${yellow}r\x1b[0m Refresh
764
+
765
+ ${border}${'─'.repeat(44)}\x1b[0m
766
+
767
+ ${gitLine}
768
+ ${dim}${process.cwd()}\x1b[0m
769
+ `;
770
+
771
+ viewer.setContent(logo + shortcuts);
772
+ viewer.setLabel(` ${accent}◈ Welcome\x1b[0m `);
773
+ screen.render();
774
+ }
775
+
465
776
  // ── Status Bar ──────────────────────────────────────────────────────────
466
777
  function updateStatusBar(mode) {
467
778
  const sep = `${hexToAnsi('#292e42')}│\x1b[0m`;
468
779
  const key = (k) => `\x1b[1m${hexToAnsi(theme.accent)}${k}\x1b[0m`;
469
780
  const sessionCount = liveSessions.length;
470
781
  const sessionHint = sessionCount > 0 ? ` ${sep} ${key('Ctrl-A')} Sessions (${sessionCount})` : '';
471
- const quit = `${key('Ctrl-D')} ${hexToAnsi(theme.red)}Quit All\x1b[0m`;
782
+ const gitHint = isGitRepo ? ` ${sep} ${hexToAnsi(theme.green)} ${gitBranch}\x1b[0m` : '';
783
+ const quit = `${key('Ctrl-D')} ${hexToAnsi(theme.red)}Quit\x1b[0m`;
472
784
  if (mode === 'ai') {
473
785
  statusBar.setContent(
474
- ` ${key('Ctrl-C')} Stop ${sep} ${key('Ctrl-T')} Switch ${sep} ${key('Ctrl-A')} Sessions (${sessionCount}) ${sep} ${quit}`
786
+ ` ${key('Ctrl-C')} Stop ${sep} ${key('Ctrl-T')} Switch ${sep} ${key('Ctrl-A')} Sessions (${sessionCount}) ${sep} ${key('Ctrl-B')} Cmds ${sep} ${quit}`
475
787
  );
476
788
  } else {
477
789
  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}`
790
+ ` ${key('Ctrl-T')} Switch ${sep} ${key('Ctrl-B')} Cmds ${sep} ${key('Ctrl-F')} Find ${sep} ${key('a')} AI${sessionHint}${gitHint} ${sep} ${quit}`
479
791
  );
480
792
  }
481
793
  }
@@ -510,26 +822,15 @@ function getXterm() {
510
822
  getXterm();
511
823
 
512
824
  // ── AI Session Pool ─────────────────────────────────────────────────────
513
- // Each live session: { id, cmd, label, color, pty, xterm, serialize, ready, renderTimer }
514
825
 
515
826
  function getActiveSession() {
516
827
  if (activeSessionIdx >= 0 && activeSessionIdx < liveSessions.length) return liveSessions[activeSessionIdx];
517
828
  return null;
518
829
  }
519
830
 
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
- }
831
+ function showAIPicker() { aiPicker.show(); aiPicker.focus(); screen.render(); }
832
+ function hideAIPicker() { aiPicker.hide(); tree.rows.focus(); screen.render(); }
531
833
 
532
- // Switch viewer to show a live session by index
533
834
  function switchToSession(idx) {
534
835
  if (idx < 0 || idx >= liveSessions.length) return;
535
836
  activeSessionIdx = idx;
@@ -538,9 +839,7 @@ function switchToSession(idx) {
538
839
  const s = liveSessions[idx];
539
840
  viewer.setLabel(` ${hexToAnsi(s.color)}◆ ${s.label}\x1b[0m `);
540
841
  viewer.style.border.fg = s.color;
541
- if (s.xterm && s.serialize) {
542
- viewer.setContent(cleanForBlessed(s.serialize.serialize()));
543
- }
842
+ if (s.xterm && s.serialize) viewer.setContent(cleanForBlessed(s.serialize.serialize()));
544
843
  updateStatusBar('ai');
545
844
  focusIndex = panels.indexOf('viewer');
546
845
  tree.style.border.fg = theme.border;
@@ -551,22 +850,14 @@ function switchToSession(idx) {
551
850
 
552
851
  // ── Session Switcher Popup (Ctrl+A) ────────────────────────────────────
553
852
  const sessionSwitcher = blessed.list({
554
- parent: screen,
555
- top: 'center',
556
- left: 'center',
557
- width: 50,
558
- height: 10,
853
+ parent: screen, top: 'center', left: 'center', width: 50, height: 10,
559
854
  label: ` ◆ Active Sessions `,
560
- mouse: true,
561
- keys: true,
562
- tags: false,
855
+ mouse: true, keys: true, tags: false,
563
856
  border: { type: 'line', fg: theme.accent },
564
857
  style: {
565
- fg: theme.text,
566
- bg: theme.sidebar,
858
+ fg: theme.text, bg: theme.sidebar,
567
859
  selected: { bg: theme.accentDim, fg: theme.textBright },
568
- label: { fg: theme.accent },
569
- border: { fg: theme.accent }
860
+ label: { fg: theme.accent }, border: { fg: theme.accent }
570
861
  },
571
862
  padding: { left: 2, right: 2 },
572
863
  hidden: true
@@ -585,17 +876,9 @@ function showSessionSwitcher() {
585
876
  screen.render();
586
877
  }
587
878
 
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
- });
879
+ sessionSwitcher.on('select', (item, index) => { sessionSwitcher.hide(); switchToSession(index); });
880
+ sessionSwitcher.key(['escape', 'q', 'C-a'], () => { sessionSwitcher.hide(); screen.render(); });
597
881
 
598
- // Launch AI: new session
599
882
  async function launchAI(choice) {
600
883
  hideAIPicker();
601
884
  const cmd = choice === 0 ? 'claude' : 'codex';
@@ -603,10 +886,8 @@ async function launchAI(choice) {
603
886
  await launchAIWithArgs(cmd, [], label);
604
887
  }
605
888
 
606
- // Launch AI: with arbitrary args (used for both new and resume)
607
889
  async function launchAIWithArgs(cmd, args, label) {
608
890
  const cwd = process.cwd();
609
-
610
891
  viewMode = 'ai';
611
892
  lastPath = null;
612
893
 
@@ -615,8 +896,17 @@ async function launchAIWithArgs(cmd, args, label) {
615
896
  screen.realloc();
616
897
 
617
898
  const headerColor = cmd === 'claude' ? theme.orange : theme.green;
899
+
900
+ // Show spinner while loading
618
901
  viewer.setLabel(` ${hexToAnsi(headerColor)}◆ ${label}\x1b[0m `);
619
902
  viewer.style.border.fg = headerColor;
903
+ const spinChars = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
904
+ let spinIdx = 0;
905
+ const spinner = setInterval(() => {
906
+ viewer.setContent(`\n\n ${hexToAnsi(headerColor)}${spinChars[spinIdx++ % spinChars.length]}\x1b[0m Starting ${label}...`);
907
+ screen.render();
908
+ }, 80);
909
+
620
910
  updateStatusBar('ai');
621
911
  screen.render();
622
912
 
@@ -632,6 +922,7 @@ async function launchAIWithArgs(cmd, args, label) {
632
922
  try {
633
923
  fullCmd = execSync(`which ${cmd}`, { encoding: 'utf-8' }).trim();
634
924
  } catch (e) {
925
+ clearInterval(spinner);
635
926
  viewer.setContent(`\n ${hexToAnsi(theme.red)}⊘ Error: "${cmd}" not found in PATH.\x1b[0m\n\n Install it first, then try again.\n`);
636
927
  viewMode = 'file';
637
928
  xterm.dispose();
@@ -643,12 +934,11 @@ async function launchAIWithArgs(cmd, args, label) {
643
934
  let ptyProc;
644
935
  try {
645
936
  ptyProc = pty.spawn(fullCmd, args, {
646
- name: 'xterm-256color',
647
- cols, rows,
648
- cwd: cwd,
937
+ name: 'xterm-256color', cols, rows, cwd: cwd,
649
938
  env: { ...process.env, TERM: 'xterm-256color' }
650
939
  });
651
940
  } catch (e) {
941
+ clearInterval(spinner);
652
942
  viewer.setContent(`\n ${hexToAnsi(theme.red)}⊘ Failed to launch "${cmd}":\x1b[0m\n\n ${e.message}\n`);
653
943
  viewMode = 'file';
654
944
  xterm.dispose();
@@ -657,19 +947,15 @@ async function launchAIWithArgs(cmd, args, label) {
657
947
  return;
658
948
  }
659
949
 
660
- // Create session object
661
950
  const session = {
662
- id: `${cmd}-${Date.now()}`,
663
- cmd, label, color: headerColor,
664
- pty: ptyProc, xterm, serialize,
665
- ready: false, renderTimer: null
951
+ id: `${cmd}-${Date.now()}`, cmd, label, color: headerColor,
952
+ pty: ptyProc, xterm, serialize, ready: false, renderTimer: null
666
953
  };
667
954
  liveSessions.push(session);
668
955
  activeSessionIdx = liveSessions.length - 1;
669
956
 
670
957
  function renderXterm() {
671
958
  if (!session.xterm || !session.serialize) return;
672
- // Only update viewer if this session is the active one being shown
673
959
  if (viewMode !== 'ai' || liveSessions[activeSessionIdx] !== session) return;
674
960
  const raw = session.serialize.serialize();
675
961
  viewer.setContent(cleanForBlessed(raw));
@@ -677,25 +963,21 @@ async function launchAIWithArgs(cmd, args, label) {
677
963
  }
678
964
 
679
965
  ptyProc.onData((data) => {
680
- if (!session.ready) session.ready = true;
966
+ if (!session.ready) { session.ready = true; clearInterval(spinner); }
681
967
  session.xterm.write(data, () => {
682
968
  if (!session.renderTimer) {
683
- session.renderTimer = setTimeout(() => {
684
- session.renderTimer = null;
685
- renderXterm();
686
- }, 16);
969
+ session.renderTimer = setTimeout(() => { session.renderTimer = null; renderXterm(); }, 16);
687
970
  }
688
971
  });
689
972
  });
690
973
 
691
974
  ptyProc.onExit(({ exitCode }) => {
975
+ clearInterval(spinner);
692
976
  if (session.renderTimer) { clearTimeout(session.renderTimer); session.renderTimer = null; }
693
977
 
694
- // Remove from live sessions
695
978
  const idx = liveSessions.indexOf(session);
696
979
  if (idx !== -1) {
697
980
  liveSessions.splice(idx, 1);
698
- // Adjust activeSessionIdx
699
981
  if (liveSessions.length === 0) {
700
982
  activeSessionIdx = -1;
701
983
  viewMode = 'file';
@@ -707,10 +989,7 @@ async function launchAIWithArgs(cmd, args, label) {
707
989
  }
708
990
  }
709
991
 
710
- // Show final output if this was the displayed session
711
- if (viewMode === 'ai' && activeSessionIdx >= 0) {
712
- switchToSession(activeSessionIdx);
713
- }
992
+ if (viewMode === 'ai' && activeSessionIdx >= 0) switchToSession(activeSessionIdx);
714
993
 
715
994
  session.xterm.dispose();
716
995
  session.xterm = null;
@@ -731,9 +1010,7 @@ async function launchAIWithArgs(cmd, args, label) {
731
1010
 
732
1011
  function stopActiveSession() {
733
1012
  const s = getActiveSession();
734
- if (s && s.pty) {
735
- s.pty.kill();
736
- }
1013
+ if (s && s.pty) s.pty.kill();
737
1014
  }
738
1015
 
739
1016
  // Forward keyboard input to active AI session
@@ -741,96 +1018,71 @@ viewer.on('keypress', (ch, key) => {
741
1018
  const s = getActiveSession();
742
1019
  if (!s || !s.pty || !s.ready || viewMode !== 'ai') return;
743
1020
  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); }
1021
+ if (key.name === 'return') s.pty.write('\r');
1022
+ else if (key.name === 'backspace') s.pty.write('\x7f');
1023
+ else if (key.name === 'escape') s.pty.write('\x1b');
1024
+ else if (key.name === 'up') s.pty.write('\x1b[A');
1025
+ else if (key.name === 'down') s.pty.write('\x1b[B');
1026
+ else if (key.name === 'right') s.pty.write('\x1b[C');
1027
+ else if (key.name === 'left') s.pty.write('\x1b[D');
1028
+ else if (key.ctrl && key.name === 'c') s.pty.write('\x03');
1029
+ else if (key.ctrl && key.name === 'd') s.pty.write('\x04');
1030
+ else if (key.ctrl && key.name === 'l') s.pty.write('\x0c');
1031
+ else if (key.ctrl && key.name === 'z') s.pty.write('\x1a');
1032
+ else if (ch) s.pty.write(ch);
756
1033
  } else if (ch) {
757
1034
  s.pty.write(ch);
758
1035
  }
759
1036
  });
760
1037
 
761
- aiPicker.on('select', (item, index) => {
762
- launchAI(index);
763
- });
764
-
765
- aiPicker.key(['escape', 'q'], () => {
766
- hideAIPicker();
767
- });
1038
+ aiPicker.on('select', (item, index) => { launchAI(index); });
1039
+ aiPicker.key(['escape', 'q'], () => { hideAIPicker(); });
768
1040
 
769
1041
  // ── Event Handlers ──────────────────────────────────────────────────────
770
1042
  tree.on('select', (node) => {
771
- if (node.path) {
772
- viewMode = 'file';
773
- updateViewer(node.path);
774
- }
1043
+ if (node.path) { viewMode = 'file'; updateViewer(node.path); }
775
1044
  });
776
1045
 
777
1046
  tree.rows.on('scroll', () => {
778
1047
  if (isRefreshing) return;
779
1048
  const node = tree.nodeLines[tree.rows.selected];
780
- if (node && node.path) {
781
- viewMode = 'file';
782
- updateViewer(node.path);
783
- }
1049
+ if (node && node.path) { viewMode = 'file'; updateViewer(node.path); }
784
1050
  });
785
1051
 
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
- });
1052
+ tree.rows.key(['n'], () => doNewFile());
1053
+ tree.rows.key(['f'], () => doNewFolder());
1054
+ tree.rows.key(['d', 'delete'], () => doDelete());
1055
+ tree.rows.key(['c'], () => doCopyPath());
1056
+ tree.rows.key(['e'], () => doOpenEditor());
793
1057
 
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
- });
1058
+ screen.key(['a'], () => {
1059
+ if (!aiPicker.hidden || !sessionSwitcher.hidden || !cmdPalette.hidden) return;
1060
+ if (panels[focusIndex] === 'viewer' && viewMode === 'ai') return;
1061
+ showAIPicker();
800
1062
  });
801
1063
 
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
- }
1064
+ screen.key(['C-a'], () => {
1065
+ if (!aiPicker.hidden || !cmdPalette.hidden) return;
1066
+ if (!sessionSwitcher.hidden) { sessionSwitcher.hide(); screen.render(); return; }
1067
+ if (liveSessions.length > 0) showSessionSwitcher();
811
1068
  });
812
1069
 
813
- screen.key(['a'], () => {
1070
+ screen.key(['C-b'], () => {
814
1071
  if (!aiPicker.hidden || !sessionSwitcher.hidden) return;
815
- if (panels[focusIndex] === 'viewer' && viewMode === 'ai') return; // don't trigger while typing in AI
816
- showAIPicker();
1072
+ if (!cmdPalette.hidden) { cmdPalette.hide(); screen.render(); return; }
1073
+ showCmdPalette();
817
1074
  });
818
1075
 
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
- }
1076
+ screen.key(['C-f'], () => {
1077
+ if (!aiPicker.hidden || !sessionSwitcher.hidden || !cmdPalette.hidden) return;
1078
+ if (panels[focusIndex] === 'viewer' && viewMode === 'ai') return;
1079
+ showSearch();
825
1080
  });
826
1081
 
827
1082
  screen.key(['r'], () => {
828
- if (!aiPicker.hidden || !sessionSwitcher.hidden) return;
1083
+ if (!aiPicker.hidden || !sessionSwitcher.hidden || !cmdPalette.hidden) return;
829
1084
  if (panels[focusIndex] === 'viewer' && viewMode === 'ai') return;
830
- lastPath = null;
831
- screen.realloc();
832
- refreshTree();
833
- refreshHistory(true);
1085
+ doRefresh();
834
1086
  });
835
1087
 
836
1088
  // Tab cycles: tree → history → viewer → tree
@@ -839,7 +1091,6 @@ let focusIndex = 0;
839
1091
 
840
1092
  function setFocusPanel(idx) {
841
1093
  focusIndex = idx;
842
- // Reset all borders
843
1094
  tree.style.border.fg = theme.border;
844
1095
  historyList.style.border.fg = theme.border;
845
1096
  viewer.style.border.fg = theme.border;
@@ -849,7 +1100,7 @@ function setFocusPanel(idx) {
849
1100
  tree.style.border.fg = theme.borderFocus;
850
1101
  updateStatusBar('normal');
851
1102
  } else if (panels[idx] === 'history') {
852
- refreshHistory(true); // reload from disk to catch external sessions
1103
+ refreshHistory(true);
853
1104
  historyList.focus();
854
1105
  historyList.style.border.fg = theme.purple;
855
1106
  updateStatusBar('normal');
@@ -857,14 +1108,11 @@ function setFocusPanel(idx) {
857
1108
  viewer.focus();
858
1109
  const s = getActiveSession();
859
1110
  if (s && s.pty) {
860
- // Restore AI terminal view
861
1111
  viewMode = 'ai';
862
1112
  lastPath = null;
863
1113
  viewer.style.border.fg = s.color;
864
1114
  viewer.setLabel(` ${hexToAnsi(s.color)}◆ ${s.label}\x1b[0m `);
865
- if (s.xterm && s.serialize) {
866
- viewer.setContent(cleanForBlessed(s.serialize.serialize()));
867
- }
1115
+ if (s.xterm && s.serialize) viewer.setContent(cleanForBlessed(s.serialize.serialize()));
868
1116
  updateStatusBar('ai');
869
1117
  } else {
870
1118
  viewer.style.border.fg = theme.borderFocus;
@@ -875,14 +1123,13 @@ function setFocusPanel(idx) {
875
1123
  }
876
1124
 
877
1125
  screen.key(['C-t'], () => {
878
- if (!aiPicker.hidden || !sessionSwitcher.hidden) return;
1126
+ if (!aiPicker.hidden || !sessionSwitcher.hidden || !cmdPalette.hidden) return;
879
1127
  focusIndex = (focusIndex + 1) % panels.length;
880
1128
  setFocusPanel(focusIndex);
881
1129
  });
882
1130
 
883
1131
  screen.key(['tab'], () => {
884
- if (!aiPicker.hidden || !sessionSwitcher.hidden) return;
885
- // When AI is active and viewer is focused, forward tab to pty
1132
+ if (!aiPicker.hidden || !sessionSwitcher.hidden || !cmdPalette.hidden) return;
886
1133
  const s = getActiveSession();
887
1134
  if (s && s.pty && s.ready && viewMode === 'ai' && panels[focusIndex] === 'viewer') {
888
1135
  s.pty.write('\t');
@@ -893,6 +1140,7 @@ screen.key(['tab'], () => {
893
1140
  });
894
1141
 
895
1142
  screen.key(['escape'], () => {
1143
+ if (!cmdPalette.hidden) { cmdPalette.hide(); screen.render(); return; }
896
1144
  if (!sessionSwitcher.hidden) { sessionSwitcher.hide(); screen.render(); return; }
897
1145
  if (!aiPicker.hidden) { hideAIPicker(); return; }
898
1146
  const s = getActiveSession();
@@ -901,7 +1149,7 @@ screen.key(['escape'], () => {
901
1149
  });
902
1150
 
903
1151
  screen.key(['q'], () => {
904
- if (!aiPicker.hidden || !sessionSwitcher.hidden) return;
1152
+ if (!aiPicker.hidden || !sessionSwitcher.hidden || !cmdPalette.hidden) return;
905
1153
  if (viewMode === 'ai' && panels[focusIndex] === 'viewer') return;
906
1154
  process.exit(0);
907
1155
  });
@@ -928,9 +1176,7 @@ screen.on('keypress', (ch, key) => {
928
1176
  const s = getActiveSession();
929
1177
  if (key && key.ctrl && key.name === 'c' && s && s.pty && viewMode === 'ai' && panels[focusIndex] === 'viewer') {
930
1178
  const now = Date.now();
931
- if (now - lastCtrlC < 500) {
932
- stopActiveSession();
933
- }
1179
+ if (now - lastCtrlC < 500) stopActiveSession();
934
1180
  lastCtrlC = now;
935
1181
  }
936
1182
  });
@@ -938,7 +1184,6 @@ screen.on('keypress', (ch, key) => {
938
1184
  screen.on('resize', () => {
939
1185
  const cols = Math.max(viewer.width - viewer.iwidth - 2, 40);
940
1186
  const rows = Math.max(viewer.height - viewer.iheight, 10);
941
- // Resize ALL live sessions
942
1187
  for (const s of liveSessions) {
943
1188
  if (s.pty) s.pty.resize(cols, rows);
944
1189
  if (s.xterm) s.xterm.resize(cols, rows);
@@ -946,6 +1191,8 @@ screen.on('resize', () => {
946
1191
  });
947
1192
 
948
1193
  // ── Init ────────────────────────────────────────────────────────────────
1194
+ refreshGitStatus();
949
1195
  refreshTree();
950
1196
  refreshHistory();
1197
+ showWelcome();
951
1198
  setFocusPanel(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tpc-explorer",
3
- "version": "1.1.1",
3
+ "version": "2.0.0",
4
4
  "description": "Terminal file explorer with embedded AI assistants (Claude Code / OpenAI Codex)",
5
5
  "main": "index.js",
6
6
  "bin": {