specmem-hardwicksoftware 3.7.35 → 3.7.36

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 (55) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +11 -15
  3. package/bin/specmem-console.cjs +839 -51
  4. package/claude-hooks/agent-chooser-hook.js +6 -6
  5. package/claude-hooks/agent-loading-hook.cjs +16 -16
  6. package/claude-hooks/agent-loading-hook.js +18 -18
  7. package/claude-hooks/agent-type-matcher.js +1 -1
  8. package/claude-hooks/background-completion-silencer.js +1 -1
  9. package/claude-hooks/file-claim-enforcer.cjs +37 -36
  10. package/claude-hooks/output-cleaner.cjs +1 -1
  11. package/claude-hooks/settings.json +27 -3
  12. package/claude-hooks/specmem-search-enforcer.cjs +2 -11
  13. package/claude-hooks/specmem-team-member-inject.js +1 -1
  14. package/claude-hooks/specmem-unified-hook.py +1 -1
  15. package/claude-hooks/subagent-loading-hook.cjs +1 -1
  16. package/claude-hooks/task-progress-hook.cjs +7 -7
  17. package/claude-hooks/task-progress-hook.js +3 -3
  18. package/claude-hooks/team-comms-enforcer.cjs +49 -47
  19. package/dist/claude-sessions/sessionParser.js +5 -0
  20. package/dist/codebase/codebaseIndexer.js +48 -17
  21. package/dist/codebase/exclusions.js +3 -4
  22. package/dist/codebase/index.js +4 -0
  23. package/dist/codebase/pdfExtractor.js +298 -0
  24. package/dist/dashboard/api/taskTeamMembers.js +2 -2
  25. package/dist/db/bigBrainMigrations.js +29 -0
  26. package/dist/hooks/hookManager.js +4 -4
  27. package/dist/hooks/teamFramingCli.js +1 -1
  28. package/dist/hooks/teamMemberPrepromptHook.js +5 -5
  29. package/dist/init/claudeConfigInjector.js +2 -2
  30. package/dist/mcp/compactionProxy.js +834 -186
  31. package/dist/mcp/compactionProxyDaemon.js +112 -37
  32. package/dist/mcp/contextVault.js +439 -0
  33. package/dist/mcp/embeddingServerManager.js +61 -1
  34. package/dist/mcp/mcpProtocolHandler.js +6 -1
  35. package/dist/mcp/miniCOTServerManager.js +82 -8
  36. package/dist/mcp/specMemServer.js +45 -10
  37. package/dist/mcp/toolRegistry.js +6 -0
  38. package/dist/startup/startupIndexing.js +14 -0
  39. package/dist/team-members/taskOrchestrator.js +3 -3
  40. package/dist/team-members/taskTeamMemberLogger.js +2 -2
  41. package/dist/tools/goofy/deployTeamMember.js +3 -3
  42. package/dist/tools/goofy/digInTheVault.js +81 -0
  43. package/dist/tools/goofy/stashTheGoods.js +56 -0
  44. package/dist/tools/teamMemberDeployer.js +2 -2
  45. package/dist/watcher/changeHandler.js +65 -8
  46. package/dist/watcher/changeQueue.js +20 -1
  47. package/embedding-sandbox/mini-cot-service.py +11 -13
  48. package/embedding-sandbox/pdf-text-extract.py +208 -0
  49. package/package.json +1 -1
  50. package/scripts/deploy-hooks.cjs +2 -2
  51. package/scripts/global-postinstall.cjs +2 -2
  52. package/scripts/specmem-init.cjs +130 -36
  53. package/specmem/model-config.json +6 -6
  54. package/specmem/supervisord.conf +1 -1
  55. package/svg-sections/readme-token-compaction.svg +246 -0
@@ -954,12 +954,28 @@ process.on('unhandledRejection', (reason) => {
954
954
  console.error(`\n${c.red}Unhandled rejection:${c.reset}`, reason);
955
955
  });
956
956
 
957
+ function _killProxyDaemon() {
958
+ try {
959
+ const portFile = require('path').join(require('os').homedir(), '.claude', '.compaction-proxy-port');
960
+ if (require('fs').existsSync(portFile)) {
961
+ const port = parseInt(require('fs').readFileSync(portFile, 'utf8').trim());
962
+ if (port > 0) {
963
+ const req = require('http').request({ hostname: '127.0.0.1', port, path: '/shutdown', method: 'POST' });
964
+ req.on('error', () => {});
965
+ req.end();
966
+ }
967
+ }
968
+ } catch {}
969
+ }
970
+
957
971
  process.on('SIGINT', () => {
972
+ _killProxyDaemon();
958
973
  restoreTerminalState();
959
974
  process.exit(0);
960
975
  });
961
976
 
962
977
  process.on('SIGTERM', () => {
978
+ _killProxyDaemon();
963
979
  restoreTerminalState();
964
980
  process.exit(0);
965
981
  });
@@ -3924,45 +3940,95 @@ class SpecMemConsole {
3924
3940
  let pid = null, isRunning = false;
3925
3941
  try {
3926
3942
  if (fs.existsSync(pidPath)) {
3927
- pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim());
3928
- try { process.kill(pid, 0); isRunning = true; } catch (e) {}
3943
+ const content = fs.readFileSync(pidPath, 'utf8').trim();
3944
+ // PID file format is PID:TIMESTAMP
3945
+ pid = parseInt(content.split(':')[0], 10);
3946
+ if (!isNaN(pid)) {
3947
+ try { process.kill(pid, 0); isRunning = true; } catch (e) {
3948
+ // Process dead — stale PID file
3949
+ isRunning = false;
3950
+ }
3951
+ } else {
3952
+ pid = null;
3953
+ }
3929
3954
  }
3930
3955
  } catch (e) {}
3931
3956
  const isStopped = fs.existsSync(stoppedPath);
3932
3957
  const socketExists = fs.existsSync(sockPath);
3933
- console.log(` Status: ${isRunning ? `${c.green}Running${c.reset}` : `${c.red}Stopped${c.reset}`}`);
3934
- console.log(` PID: ${pid || '(none)'}`);
3935
- console.log(` Socket: ${socketExists ? `${c.green}Exists${c.reset}` : 'Not found'}`);
3958
+ const statusLabel = isRunning ? `${c.green}Running${c.reset}` :
3959
+ (pid && !isRunning) ? `${c.red}Dead (stale PID ${pid})${c.reset}` : `${c.red}Stopped${c.reset}`;
3960
+ console.log(` Status: ${statusLabel}`);
3961
+ console.log(` PID: ${isRunning ? pid : '(none)'}`);
3962
+ console.log(` Socket: ${socketExists ? `${c.green}Exists${c.reset}` : 'Not found'} (${sockPath})`);
3936
3963
  console.log(` Auto-start: ${isStopped ? `${c.yellow}Disabled${c.reset}` : `${c.green}Enabled${c.reset}`}`);
3964
+ if (pid && !isRunning) {
3965
+ console.log(` ${c.yellow}Hint: stale PID file detected. Run 'minicot stop' to clean up.${c.reset}`);
3966
+ }
3937
3967
  break;
3938
3968
  }
3939
3969
  case 'start': {
3940
3970
  if (fs.existsSync(stoppedPath)) fs.unlinkSync(stoppedPath);
3941
- const scriptPath = path.join(__dirname, '..', 'mini-cot-service.py');
3971
+ // Clean stale PID/socket before starting
3972
+ if (fs.existsSync(pidPath)) {
3973
+ try {
3974
+ const content = fs.readFileSync(pidPath, 'utf8').trim();
3975
+ const oldPid = parseInt(content.split(':')[0], 10);
3976
+ if (!isNaN(oldPid)) {
3977
+ try { process.kill(oldPid, 0); console.log(`${c.yellow}Already running (PID ${oldPid})${c.reset}`); break; } catch (_e) {}
3978
+ }
3979
+ fs.unlinkSync(pidPath);
3980
+ } catch (_e) {}
3981
+ }
3982
+ if (fs.existsSync(sockPath)) {
3983
+ try { fs.unlinkSync(sockPath); } catch (_e) {}
3984
+ }
3985
+ const scriptPath = path.join(__dirname, '..', 'embedding-sandbox', 'mini-cot-service.py');
3942
3986
  if (fs.existsSync(scriptPath)) {
3943
3987
  // Task #22 fix: Use getPythonPath() instead of hardcoded 'python3'
3944
3988
  const pythonPath = getPythonPath();
3945
3989
  const proc = require('child_process').spawn(pythonPath, [scriptPath], {
3946
3990
  cwd: this.projectPath, detached: true, stdio: 'ignore',
3947
- env: { ...process.env, SPECMEM_PROJECT_PATH: this.projectPath }
3991
+ env: { ...process.env, SPECMEM_PROJECT_PATH: this.projectPath, SPECMEM_MINICOT_SOCKET: sockPath }
3948
3992
  });
3949
3993
  proc.unref();
3994
+ // Write PID file atomically (PID:TIMESTAMP format)
3995
+ const pidDir = path.dirname(pidPath);
3996
+ fs.mkdirSync(pidDir, { recursive: true });
3997
+ const tmpPid = pidPath + '.tmp';
3998
+ fs.writeFileSync(tmpPid, `${proc.pid}:${Date.now()}`, 'utf8');
3999
+ fs.renameSync(tmpPid, pidPath);
3950
4000
  console.log(`${c.green}Mini COT starting (PID: ${proc.pid})${c.reset}`);
3951
4001
  } else {
3952
- console.log(`${c.yellow}mini-cot-service.py not found${c.reset}`);
4002
+ console.log(`${c.yellow}mini-cot-service.py not found at ${scriptPath}${c.reset}`);
3953
4003
  }
3954
4004
  break;
3955
4005
  }
3956
4006
  case 'stop': {
3957
4007
  fs.mkdirSync(path.dirname(stoppedPath), { recursive: true });
3958
4008
  fs.writeFileSync(stoppedPath, new Date().toISOString());
4009
+ let killed = false;
3959
4010
  if (fs.existsSync(pidPath)) {
3960
4011
  try {
3961
- const pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim());
3962
- process.kill(pid, 'SIGTERM');
3963
- fs.unlinkSync(pidPath);
3964
- console.log(`${c.green}Stopped PID ${pid}${c.reset}`);
3965
- } catch (e) { console.log(`${c.yellow}Already stopped${c.reset}`); }
4012
+ const content = fs.readFileSync(pidPath, 'utf8').trim();
4013
+ const pid = parseInt(content.split(':')[0], 10);
4014
+ if (!isNaN(pid)) {
4015
+ process.kill(pid, 'SIGTERM');
4016
+ console.log(`${c.green}Stopped PID ${pid}${c.reset}`);
4017
+ killed = true;
4018
+ }
4019
+ } catch (e) { console.log(`${c.yellow}Process already dead${c.reset}`); }
4020
+ // Always clean up PID file
4021
+ try { fs.unlinkSync(pidPath); } catch (_e) {}
4022
+ // Also clean up .tmp from atomic writes
4023
+ try { fs.unlinkSync(pidPath + '.tmp'); } catch (_e) {}
4024
+ }
4025
+ // Clean up stale socket
4026
+ if (fs.existsSync(sockPath)) {
4027
+ try { fs.unlinkSync(sockPath); } catch (_e) {}
4028
+ console.log(` Cleaned up socket: ${sockPath}`);
4029
+ }
4030
+ if (!killed && !fs.existsSync(pidPath)) {
4031
+ console.log(`${c.yellow}Already stopped${c.reset}`);
3966
4032
  }
3967
4033
  break;
3968
4034
  }
@@ -4906,7 +4972,33 @@ class SpecMemConsole {
4906
4972
  } catch (e) { /* no container runtime */ }
4907
4973
 
4908
4974
  if (isContainerMode && brainContainerName) {
4909
- // Container mode: find embedding PID inside container and send SIGUSR1
4975
+ // Container mode: update container cgroup limits via docker update
4976
+ try {
4977
+ const configPath = path.join(this.projectPath, 'specmem', 'model-config.json');
4978
+ if (fs.existsSync(configPath)) {
4979
+ const modelCfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
4980
+ const res = modelCfg.resources || {};
4981
+ const updateArgs = [];
4982
+ // RAM: convert MB to docker memory format
4983
+ if (res.ramMaxMb && res.ramMaxMb >= 256) {
4984
+ updateArgs.push(`--memory=${res.ramMaxMb}m`);
4985
+ // Memory swap = 2x memory to allow some swap headroom
4986
+ updateArgs.push(`--memory-swap=${res.ramMaxMb * 2}m`);
4987
+ }
4988
+ // CPU: convert cpuCoreMax to docker --cpus (float)
4989
+ if (res.cpuCoreMax && res.cpuCoreMax > 0) {
4990
+ updateArgs.push(`--cpus=${res.cpuCoreMax}`);
4991
+ }
4992
+ if (updateArgs.length > 0) {
4993
+ execSync(`${rtCmd} update ${updateArgs.join(' ')} ${brainContainerName}`, { timeout: 10000 });
4994
+ console.log(` ${c.green}${icons.success}${c.reset} Container limits updated: ${updateArgs.join(' ')}`);
4995
+ }
4996
+ }
4997
+ } catch (e) {
4998
+ console.log(` ${c.dim}docker update failed: ${e.message} — container restart may be needed${c.reset}`);
4999
+ }
5000
+
5001
+ // Signal embedding server PID inside container for config reload
4910
5002
  try {
4911
5003
  const pid = execSync(`${rtCmd} exec ${brainContainerName} cat /data/specmem/run/embedding.pid 2>/dev/null || ${rtCmd} exec ${brainContainerName} cat /data/specmem/sockets/embedding.pid 2>/dev/null`, { encoding: 'utf8', timeout: 5000 }).trim().split(':')[0];
4912
5004
  if (pid && !isNaN(parseInt(pid))) {
@@ -8315,7 +8407,9 @@ const TUI_TABS = [
8315
8407
  },
8316
8408
  {
8317
8409
  name: 'Proxy', key: '6',
8318
- commands: [],
8410
+ commands: [
8411
+ { label: 'Restart Proxy', cmd: 'proxy-restart' },
8412
+ ],
8319
8413
  type: 'proxy',
8320
8414
  },
8321
8415
  {
@@ -8363,7 +8457,15 @@ class ConsoleTUI {
8363
8457
  ];
8364
8458
  this._proxyLogLines = []; // scrollable debug log buffer
8365
8459
  this._proxyLogSince = ''; // ISO timestamp for incremental log fetch
8460
+ this._proxyPreviewHistory = []; // accumulated preview entries for In/Out panels
8461
+ this._proxyPreviewSince = ''; // ISO timestamp for incremental preview fetch
8366
8462
  this._proxySystemPrompt = null;
8463
+ this._proxyCustomSysPrompt = null;
8464
+ // Smart scroll state — prevent setScrollPerc(100) from dragging user back down
8465
+ this._proxyLastInHash = null;
8466
+ this._proxyLastOutHash = null;
8467
+ this._proxyUserScrolledIn = false;
8468
+ this._proxyUserScrolledOut = false;
8367
8469
  const sysCores = os.cpus().length;
8368
8470
  const sysTotalMb = Math.round(os.totalmem() / 1024 / 1024);
8369
8471
  const ramStep = sysTotalMb > 16000 ? 500 : sysTotalMb > 4000 ? 250 : 100;
@@ -8648,6 +8750,7 @@ class ConsoleTUI {
8648
8750
  tags: true,
8649
8751
  scrollable: true,
8650
8752
  alwaysScroll: true,
8753
+ scrollbar: { ch: '│', style: { fg: '#3a3a50' } },
8651
8754
  keys: true,
8652
8755
  mouse: true,
8653
8756
  content: '',
@@ -8663,12 +8766,17 @@ class ConsoleTUI {
8663
8766
  tags: true,
8664
8767
  scrollable: true,
8665
8768
  alwaysScroll: true,
8769
+ scrollbar: { ch: '│', style: { fg: '#3a3a50' } },
8666
8770
  keys: true,
8667
8771
  mouse: true,
8668
8772
  content: '',
8669
8773
  label: ' OUT (compacted) ',
8670
8774
  });
8671
8775
 
8776
+ // Scroll event listeners — detect when user manually scrolls up to prevent auto-scroll drag-down
8777
+ w.proxyIn.on('scroll', () => { this._proxyUserScrolledIn = true; });
8778
+ w.proxyOut.on('scroll', () => { this._proxyUserScrolledOut = true; });
8779
+
8672
8780
  // Logs panel (hidden, shown on Logs tab — full width)
8673
8781
  w.logsBox = blessed.log({
8674
8782
  parent: this._screen,
@@ -8715,10 +8823,17 @@ class ConsoleTUI {
8715
8823
  });
8716
8824
 
8717
8825
  // ── Key bindings ──
8718
- this._screen.key(['q', 'C-c'], () => {
8826
+ this._screen.key(['q'], () => {
8719
8827
  if (this._commandMode || this._filterMode) return;
8720
8828
  this.stop();
8721
8829
  });
8830
+ // Ctrl+C: copy selection if in editor, otherwise quit
8831
+ this._screen.key(['C-c'], () => {
8832
+ if (this._commandMode || this._filterMode) return;
8833
+ // If editor overlay active, don't kill - let editor handle it
8834
+ if (this._editorActive) return;
8835
+ this.stop();
8836
+ });
8722
8837
 
8723
8838
  this._screen.key(['tab'], () => {
8724
8839
  const next = (this._activeTabIdx + 1) % TUI_TABS.length;
@@ -8877,6 +8992,13 @@ class ConsoleTUI {
8877
8992
  }
8878
8993
  });
8879
8994
 
8995
+ // Customize system prompt modal (P key on proxy tab)
8996
+ this._screen.key(['p'], () => {
8997
+ if (TUI_TABS[this._activeTabIdx].type === 'proxy') {
8998
+ this._showCustomSysPromptModal();
8999
+ }
9000
+ });
9001
+
8880
9002
  // ── Screen resize handler ──
8881
9003
  // Panels use percentage widths and bottom-based height, so blessed
8882
9004
  // auto-recalculates geometry. We just need a full repaint.
@@ -9236,6 +9358,18 @@ class ConsoleTUI {
9236
9358
  return;
9237
9359
  }
9238
9360
 
9361
+ // Handle proxy-restart
9362
+ if (cmdDef.cmd === 'proxy-restart') {
9363
+ (async () => {
9364
+ this._debug('Restarting proxy daemon...');
9365
+ await this._killProxyDaemon();
9366
+ await new Promise(r => setTimeout(r, 600));
9367
+ await this._spawnProxyDaemon();
9368
+ this._renderProxyTab();
9369
+ })();
9370
+ return;
9371
+ }
9372
+
9239
9373
  // Handle confirm type
9240
9374
  if (cmdDef.type === 'confirm') {
9241
9375
  this._showConfirmDialog(cmdDef.confirmText || 'Are you sure?', () => {
@@ -9466,26 +9600,58 @@ class ConsoleTUI {
9466
9600
 
9467
9601
  _startProxyPolling() {
9468
9602
  if (this._proxyPollTimer) return;
9603
+ this._proxyResetDone = false;
9604
+ this._proxySpawnAttempted = false;
9469
9605
  const poll = async () => {
9470
9606
  try {
9471
9607
  if (TUI_TABS[this._activeTabIdx]?.type !== 'proxy') return;
9472
- const [stats, preview, config, logData, sysPrompt] = await Promise.all([
9608
+ // Auto-spawn proxy daemon if not running and port file missing
9609
+ if (!this._proxySpawnAttempted) {
9610
+ const port = this._getProxyPort();
9611
+ if (!port) {
9612
+ this._proxySpawnAttempted = true;
9613
+ this._debug('Proxy not running — spawning daemon...');
9614
+ await this._spawnProxyDaemon();
9615
+ }
9616
+ }
9617
+ // First poll: reset proxy state to clear stale data from previous session
9618
+ if (!this._proxyResetDone) {
9619
+ this._proxyResetDone = true;
9620
+ this._proxyPreviewHistory = [];
9621
+ this._proxyLogLines = [];
9622
+ this._proxyLogSince = '';
9623
+ this._proxyPreviewSince = '';
9624
+ try { await this._proxyHttpPost('/reset'); } catch {}
9625
+ }
9626
+ const [stats, preview, config, logData, sysPrompt, customSysPrompt] = await Promise.all([
9473
9627
  this._proxyHttpGet('/stats'),
9474
- this._proxyHttpGet('/preview'),
9628
+ this._proxyHttpGet(`/preview?since=${this._proxyPreviewSince || ''}`),
9475
9629
  this._proxyHttpGet('/config'),
9476
9630
  this._proxyHttpGet(`/log?limit=50&since=${this._proxyLogSince || ''}`),
9477
9631
  this._proxyHttpGet('/system-prompt'),
9632
+ this._proxyHttpGet('/custom-system-prompt'),
9478
9633
  ]);
9479
9634
  this._proxyStats = stats;
9635
+ // Accumulate preview history instead of replacing each cycle
9636
+ if (preview?.history?.length) {
9637
+ for (const entry of preview.history) {
9638
+ if (!this._proxyPreviewSince || entry.timestamp > this._proxyPreviewSince) {
9639
+ this._proxyPreviewHistory.push(entry);
9640
+ }
9641
+ }
9642
+ if (this._proxyPreviewHistory.length > 20) {
9643
+ this._proxyPreviewHistory = this._proxyPreviewHistory.slice(-20);
9644
+ }
9645
+ this._proxyPreviewSince = preview.history[preview.history.length - 1].timestamp;
9646
+ }
9480
9647
  this._proxyPreview = preview?.preview || null;
9481
9648
  this._proxyConfig = config;
9482
9649
  this._proxySystemPrompt = sysPrompt;
9483
- // Append new log entries
9650
+ this._proxyCustomSysPrompt = customSysPrompt;
9484
9651
  if (logData?.entries?.length) {
9485
9652
  for (const entry of logData.entries) {
9486
9653
  this._proxyLogLines.push(entry);
9487
9654
  }
9488
- // Keep last 200 lines
9489
9655
  if (this._proxyLogLines.length > 200) {
9490
9656
  this._proxyLogLines = this._proxyLogLines.slice(-200);
9491
9657
  }
@@ -9493,13 +9659,68 @@ class ConsoleTUI {
9493
9659
  }
9494
9660
  this._renderProxyTab();
9495
9661
  } catch (e) {
9496
- this._debug(`proxyPoll error: ${e.message}`);
9662
+ this._debug(`Proxy poll error: ${e.message}`);
9497
9663
  }
9498
9664
  };
9499
9665
  poll();
9500
9666
  this._proxyPollTimer = setInterval(poll, 2000);
9501
9667
  }
9502
9668
 
9669
+ async _spawnProxyDaemon() {
9670
+ try {
9671
+ const { spawn } = require('child_process');
9672
+ const fs = require('fs');
9673
+ const path = require('path');
9674
+ // Resolve daemon path relative to this file (bin/ → ../dist/mcp/)
9675
+ // Works whether running from /specmem/bin/ (dev) or /usr/lib/node_modules/.../bin/ (installed)
9676
+ let daemonPath = path.resolve(__dirname, '..', 'dist', 'mcp', 'compactionProxyDaemon.js');
9677
+ if (!fs.existsSync(daemonPath)) {
9678
+ // Fallback: try resolving via the real path of this script
9679
+ try {
9680
+ const realBin = fs.realpathSync(__filename);
9681
+ daemonPath = path.resolve(path.dirname(realBin), '..', 'dist', 'mcp', 'compactionProxyDaemon.js');
9682
+ } catch {}
9683
+ }
9684
+ if (!fs.existsSync(daemonPath)) {
9685
+ throw new Error(`Daemon not found at ${daemonPath}`);
9686
+ }
9687
+ this._debug(`Spawning proxy daemon: ${daemonPath}`);
9688
+ const child = spawn(process.execPath, [daemonPath], {
9689
+ detached: true,
9690
+ stdio: 'ignore',
9691
+ env: { ...process.env, SPECMEM_DAEMON: '1' }
9692
+ });
9693
+ child.unref();
9694
+ await new Promise(r => setTimeout(r, 2000));
9695
+ this._proxyPort = null; // force re-read port file
9696
+ this._debug('Proxy daemon spawned, waiting for port file...');
9697
+ } catch (e) {
9698
+ this._debug(`Proxy spawn failed: ${e.message}`);
9699
+ }
9700
+ }
9701
+
9702
+ async _killProxyDaemon() {
9703
+ try {
9704
+ const pidFile = require('path').join(require('os').homedir(), '.claude', '.compaction-proxy.pid');
9705
+ const portFile = require('path').join(require('os').homedir(), '.claude', '.compaction-proxy-port');
9706
+ try {
9707
+ const pid = parseInt(require('fs').readFileSync(pidFile, 'utf8').trim(), 10);
9708
+ if (pid > 0) {
9709
+ try { process.kill(pid, 'SIGTERM'); } catch {}
9710
+ await new Promise(r => setTimeout(r, 500));
9711
+ try { process.kill(pid, 'SIGKILL'); } catch {}
9712
+ }
9713
+ } catch {}
9714
+ try { require('fs').unlinkSync(pidFile); } catch {}
9715
+ try { require('fs').unlinkSync(portFile); } catch {}
9716
+ this._proxyPort = null;
9717
+ this._proxySpawnAttempted = false;
9718
+ this._debug('Proxy daemon killed.');
9719
+ } catch (e) {
9720
+ this._debug(`Proxy kill failed: ${e.message}`);
9721
+ }
9722
+ }
9723
+
9503
9724
  _renderProxyTab() {
9504
9725
  const w = this._widgets;
9505
9726
  if (!w.proxyBox || w.proxyBox.hidden) return;
@@ -9600,6 +9821,12 @@ class ConsoleTUI {
9600
9821
  ctrl += _stat('Size', `~${_fmtNum(sp.estTokens)} tok`) + '\n';
9601
9822
  ctrl += _stat('Model', sp.model || '?') + '\n';
9602
9823
  ctrl += _stat('Messages', `${sp.messageCount || 0}`) + '\n';
9824
+ // Custom prompt indicator + edit hint
9825
+ if (this._proxyCustomSysPrompt?.hasCustom) {
9826
+ ctrl += ` {#e056a0-fg}★ Custom Active{/#e056a0-fg} {#6c6c80-fg}(P=edit){/#6c6c80-fg}\n`;
9827
+ } else {
9828
+ ctrl += ` {#6c6c80-fg}(P) Customize Sys Prompt{/#6c6c80-fg}\n`;
9829
+ }
9603
9830
  }
9604
9831
 
9605
9832
  if (w.proxyControls) w.proxyControls.setContent(ctrl);
@@ -9638,58 +9865,619 @@ class ConsoleTUI {
9638
9865
  if (w.proxyStats) w.proxyStats.setContent(st);
9639
9866
 
9640
9867
  // ═══════════════════════════════════════════════════════════════
9641
- // TOP-RIGHT: Raw IN text (what Claude sends)
9868
+ // TOP-RIGHT: Raw IN text — accumulated history (what Claude sends)
9642
9869
  // ═══════════════════════════════════════════════════════════════
9643
9870
  let inText = '';
9644
- if (preview?.original) {
9645
- const ts = preview.timestamp ? new Date(preview.timestamp).toLocaleTimeString() : '';
9646
- inText += `{#4a4a60-fg}${ts}{/#4a4a60-fg}\n`;
9647
- // Raw text — strip blessed tags only, show everything else as-is
9648
- inText += preview.original.replace(/\{[^}]*\}/g, '');
9871
+ const previewHistory = this._proxyPreviewHistory;
9872
+ if (previewHistory.length > 0) {
9873
+ for (const entry of previewHistory) {
9874
+ if (!entry?.original) continue;
9875
+ const ts = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '';
9876
+ const saved = entry.savings ? ` {#42d77d-fg}-${_fmtNum(entry.savings)} chars{/#42d77d-fg}` : '';
9877
+ inText += `{#5a5a80-fg}── ${ts}${saved} ──{/#5a5a80-fg}\n`;
9878
+ // Truncate each entry to keep panel readable (first 2000 chars)
9879
+ const rawContent = (entry.original || '').replace(/\{[^}]*\}/g, '');
9880
+ inText += rawContent.slice(0, 2000) + (rawContent.length > 2000 ? '\n{#4a4a60-fg}... truncated{/#4a4a60-fg}' : '') + '\n\n';
9881
+ }
9649
9882
  } else {
9650
9883
  inText += '{#4a4a60-fg}waiting for traffic...{/#4a4a60-fg}';
9651
9884
  }
9652
9885
 
9886
+ // Smart scroll: only update content when changed, only auto-scroll if user hasn't scrolled up
9653
9887
  if (w.proxyIn) {
9654
- w.proxyIn.setContent(inText);
9655
- w.proxyIn.setScrollPerc(100);
9888
+ const inHash = crypto.createHash('md5').update(inText).digest('hex').slice(0, 12);
9889
+ if (inHash !== this._proxyLastInHash) {
9890
+ this._proxyLastInHash = inHash;
9891
+ w.proxyIn.setContent(inText);
9892
+ if (!this._proxyUserScrolledIn) {
9893
+ w.proxyIn.setScrollPerc(100);
9894
+ }
9895
+ this._proxyUserScrolledIn = false; // reset for next new-content cycle
9896
+ }
9656
9897
  }
9657
9898
 
9658
9899
  // ═══════════════════════════════════════════════════════════════
9659
- // BOTTOM-RIGHT: Translation samples + compacted output
9900
+ // BOTTOM-RIGHT: Compacted OUT accumulated history
9660
9901
  // ═══════════════════════════════════════════════════════════════
9661
9902
  let outText = '';
9662
- if (preview?.samples && preview.samples.length > 0) {
9663
- // Show translation before→after samples prominently
9664
- const ts = preview.timestamp ? new Date(preview.timestamp).toLocaleTimeString() : '';
9665
- const saved = preview.savings ? ` {#42d77d-fg}-${_fmtNum(preview.savings)} chars{/#42d77d-fg}` : '';
9666
- outText += `{#4a4a60-fg}${ts}{/#4a4a60-fg}${saved} {#a55eea-fg}${preview.samples.length} blocks translated{/#a55eea-fg}\n`;
9667
- for (const s of preview.samples.slice(0, 8)) {
9668
- const before = (s.before || '').slice(0, 60).replace(/\{[^}]*\}/g, '');
9669
- const after = (s.after || '').slice(0, 60).replace(/\{[^}]*\}/g, '');
9670
- outText += `{#ff4757-fg}EN:{/#ff4757-fg} ${before}${s.before?.length > 60 ? '' : ''}\n`;
9671
- outText += `{#42d77d-fg}→ {/#42d77d-fg} ${after}${s.after?.length > 60 ? '' : ''}\n`;
9672
- }
9673
- if (preview.samples.length > 8) {
9674
- outText += `{#4a4a60-fg}... +${preview.samples.length - 8} more{/#4a4a60-fg}\n`;
9675
- }
9676
- } else if (preview?.optimized) {
9677
- const ts = preview.timestamp ? new Date(preview.timestamp).toLocaleTimeString() : '';
9678
- const saved = preview.savings ? ` {#42d77d-fg}-${_fmtNum(preview.savings)} chars{/#42d77d-fg}` : '';
9679
- outText += `{#4a4a60-fg}${ts}{/#4a4a60-fg}${saved}\n`;
9680
- outText += preview.optimized.replace(/\{[^}]*\}/g, '');
9903
+ if (previewHistory.length > 0) {
9904
+ for (const entry of previewHistory) {
9905
+ const ts = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '';
9906
+ const saved = entry.savings ? ` {#42d77d-fg}-${_fmtNum(entry.savings)} chars{/#42d77d-fg}` : '';
9907
+ // Show translation samples if available
9908
+ if (entry.samples && entry.samples.length > 0) {
9909
+ outText += `{#5a5a80-fg}── ${ts}${saved} {#a55eea-fg}${entry.samples.length} blocks{/#a55eea-fg} ──{/#5a5a80-fg}\n`;
9910
+ for (const s of entry.samples.slice(0, 4)) {
9911
+ const before = (s.before || '').slice(0, 60).replace(/\{[^}]*\}/g, '');
9912
+ const after = (s.after || '').slice(0, 60).replace(/\{[^}]*\}/g, '');
9913
+ outText += `{#ff4757-fg}EN:{/#ff4757-fg} ${before}${s.before?.length > 60 ? '…' : ''}\n`;
9914
+ outText += `{#42d77d-fg}→ {/#42d77d-fg} ${after}${s.after?.length > 60 ? '…' : ''}\n`;
9915
+ }
9916
+ if (entry.samples.length > 4) outText += `{#4a4a60-fg} +${entry.samples.length - 4} more{/#4a4a60-fg}\n`;
9917
+ } else if (entry.optimized) {
9918
+ outText += `{#5a5a80-fg}── ${ts}${saved} ──{/#5a5a80-fg}\n`;
9919
+ const optContent = (entry.optimized || '').replace(/\{[^}]*\}/g, '');
9920
+ outText += optContent.slice(0, 2000) + (optContent.length > 2000 ? '\n{#4a4a60-fg}... truncated{/#4a4a60-fg}' : '') + '\n';
9921
+ }
9922
+ outText += '\n';
9923
+ }
9681
9924
  } else {
9682
9925
  outText += '{#4a4a60-fg}waiting for traffic...{/#4a4a60-fg}';
9683
9926
  }
9684
9927
 
9928
+ // Smart scroll: only update content when changed, only auto-scroll if user hasn't scrolled up
9685
9929
  if (w.proxyOut) {
9686
- w.proxyOut.setContent(outText);
9687
- w.proxyOut.setScrollPerc(100);
9930
+ const outHash = crypto.createHash('md5').update(outText).digest('hex').slice(0, 12);
9931
+ if (outHash !== this._proxyLastOutHash) {
9932
+ this._proxyLastOutHash = outHash;
9933
+ w.proxyOut.setContent(outText);
9934
+ if (!this._proxyUserScrolledOut) {
9935
+ w.proxyOut.setScrollPerc(100);
9936
+ }
9937
+ this._proxyUserScrolledOut = false;
9938
+ }
9688
9939
  }
9689
9940
 
9690
9941
  this._safeRender();
9691
9942
  }
9692
9943
 
9944
+ async _showCustomSysPromptModal() {
9945
+ this._editorActive = true;
9946
+ const blessed = this._blessed;
9947
+ const screen = this._screen;
9948
+ if (!blessed || !screen) return;
9949
+
9950
+ // Fetch current state from proxy
9951
+ const data = await this._proxyHttpGet('/custom-system-prompt');
9952
+ if (!data) {
9953
+ this._updateStatusBar('{red-fg}✗ Cannot reach proxy{/red-fg}');
9954
+ return;
9955
+ }
9956
+
9957
+ const initialText = data.customPrompt || data.ogPrompt || '';
9958
+ if (!initialText) {
9959
+ this._updateStatusBar('{yellow-fg}No system prompt captured yet — send a request first{/yellow-fg}');
9960
+ return;
9961
+ }
9962
+
9963
+ const ogHash = data.ogHash;
9964
+ const self = this;
9965
+
9966
+ // ── Editor State ──
9967
+ const E = {
9968
+ lines: initialText.split('\n'),
9969
+ row: 0, col: 0, // cursor position
9970
+ scrollY: 0,
9971
+ selAnchor: null, // { row, col } — where selection started
9972
+ selEnd: null, // { row, col } — where selection ends
9973
+ clipboard: '',
9974
+ lastClickTime: 0,
9975
+ clickCount: 0,
9976
+ dirty: false,
9977
+ dragging: false, // mouse drag in progress
9978
+ };
9979
+
9980
+ // ── System clipboard helpers ──
9981
+ const { execSync: _execSync } = require('child_process');
9982
+ const _clipEnv = { ...process.env, DISPLAY: process.env.DISPLAY || ':1' };
9983
+ function _copyToSystemClipboard(text) {
9984
+ // OSC 52 — works in some terminals (iTerm2, kitty, alacritty)
9985
+ const b64 = Buffer.from(text).toString('base64');
9986
+ process.stdout.write(`\x1b]52;c;${b64}\x07`);
9987
+ // xclip/xsel with DISPLAY for VNC environments
9988
+ try {
9989
+ const tool = _execSync('command -v xclip >/dev/null 2>&1 && echo xclip || (command -v xsel >/dev/null 2>&1 && echo xsel)', { encoding: 'utf8', env: _clipEnv }).trim();
9990
+ if (tool === 'xclip') _execSync('xclip -selection clipboard', { input: text, timeout: 2000, env: _clipEnv });
9991
+ else if (tool === 'xsel') _execSync('xsel --clipboard --input', { input: text, timeout: 2000, env: _clipEnv });
9992
+ } catch {}
9993
+ }
9994
+ function _readSystemClipboard() {
9995
+ try {
9996
+ return _execSync('xclip -selection clipboard -o 2>/dev/null || xsel --clipboard --output 2>/dev/null', { encoding: 'utf8', timeout: 2000, env: _clipEnv });
9997
+ } catch { return null; }
9998
+ }
9999
+
10000
+ // ── Helpers ──
10001
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
10002
+ // Ensure E.col never exceeds current line length
10003
+ function clampCursor() {
10004
+ E.row = clamp(E.row, 0, E.lines.length - 1);
10005
+ E.col = clamp(E.col, 0, E.lines[E.row].length);
10006
+ }
10007
+
10008
+ function getText() {
10009
+ return E.lines.join('\n');
10010
+ }
10011
+
10012
+ function hasSelection() {
10013
+ return E.selAnchor !== null && E.selEnd !== null &&
10014
+ (E.selAnchor.row !== E.selEnd.row || E.selAnchor.col !== E.selEnd.col);
10015
+ }
10016
+
10017
+ // Normalized selection: start <= end
10018
+ function getSelRange() {
10019
+ if (!hasSelection()) return null;
10020
+ const a = E.selAnchor, b = E.selEnd;
10021
+ const before = (a.row < b.row) || (a.row === b.row && a.col <= b.col);
10022
+ return before ? { start: a, end: b } : { start: b, end: a };
10023
+ }
10024
+
10025
+ function clearSel() { E.selAnchor = null; E.selEnd = null; }
10026
+
10027
+ function getSelectedText() {
10028
+ const r = getSelRange();
10029
+ if (!r) return '';
10030
+ if (r.start.row === r.end.row) {
10031
+ return E.lines[r.start.row].slice(r.start.col, r.end.col);
10032
+ }
10033
+ let t = E.lines[r.start.row].slice(r.start.col) + '\n';
10034
+ for (let i = r.start.row + 1; i < r.end.row; i++) t += E.lines[i] + '\n';
10035
+ t += E.lines[r.end.row].slice(0, r.end.col);
10036
+ return t;
10037
+ }
10038
+
10039
+ function deleteSelection() {
10040
+ const r = getSelRange();
10041
+ if (!r) return;
10042
+ const before = E.lines[r.start.row].slice(0, r.start.col);
10043
+ const after = E.lines[r.end.row].slice(r.end.col);
10044
+ E.lines.splice(r.start.row, r.end.row - r.start.row + 1, before + after);
10045
+ E.row = r.start.row; E.col = r.start.col;
10046
+ clearSel();
10047
+ E.dirty = true;
10048
+ }
10049
+
10050
+ function insertTextAt(text) {
10051
+ if (hasSelection()) deleteSelection();
10052
+ const before = E.lines[E.row].slice(0, E.col);
10053
+ const after = E.lines[E.row].slice(E.col);
10054
+ const insertLines = text.split('\n');
10055
+ if (insertLines.length === 1) {
10056
+ E.lines[E.row] = before + insertLines[0] + after;
10057
+ E.col += insertLines[0].length;
10058
+ } else {
10059
+ E.lines[E.row] = before + insertLines[0];
10060
+ for (let i = 1; i < insertLines.length - 1; i++) {
10061
+ E.lines.splice(E.row + i, 0, insertLines[i]);
10062
+ }
10063
+ const lastLine = insertLines[insertLines.length - 1];
10064
+ E.lines.splice(E.row + insertLines.length - 1, 0, lastLine + after);
10065
+ E.row += insertLines.length - 1;
10066
+ E.col = lastLine.length;
10067
+ }
10068
+ E.dirty = true;
10069
+ }
10070
+
10071
+ function isInSel(row, col) {
10072
+ const r = getSelRange();
10073
+ if (!r) return false;
10074
+ if (row < r.start.row || row > r.end.row) return false;
10075
+ if (row === r.start.row && col < r.start.col) return false;
10076
+ if (row === r.end.row && col >= r.end.col) return false;
10077
+ if (row === r.start.row && row === r.end.row) return col >= r.start.col && col < r.end.col;
10078
+ if (row === r.start.row) return col >= r.start.col;
10079
+ if (row === r.end.row) return col < r.end.col;
10080
+ return true;
10081
+ }
10082
+
10083
+ function selectWord(row, col) {
10084
+ const line = E.lines[row] || '';
10085
+ if (col >= line.length) { E.selAnchor = { row, col: 0 }; E.selEnd = { row, col: line.length }; return; }
10086
+ let start = col, end = col;
10087
+ const isWordChar = c => /\w/.test(c);
10088
+ const charType = isWordChar(line[col]);
10089
+ while (start > 0 && isWordChar(line[start - 1]) === charType) start--;
10090
+ while (end < line.length && isWordChar(line[end]) === charType) end++;
10091
+ E.selAnchor = { row, col: start };
10092
+ E.selEnd = { row, col: end };
10093
+ E.col = end;
10094
+ }
10095
+
10096
+ function selectLine(row) {
10097
+ E.selAnchor = { row, col: 0 };
10098
+ E.selEnd = { row, col: E.lines[row].length };
10099
+ E.col = E.lines[row].length;
10100
+ }
10101
+
10102
+ // ── Escape blessed tags in content ──
10103
+ function esc(ch) {
10104
+ if (ch === '{') return '{open}';
10105
+ if (ch === '}') return '{close}';
10106
+ return ch;
10107
+ }
10108
+
10109
+ function escStr(s) {
10110
+ return s.replace(/[{}]/g, c => c === '{' ? '{open}' : '{close}');
10111
+ }
10112
+
10113
+ // ── Render ──
10114
+ // Build visual rows from logical lines with soft word wrap.
10115
+ // Returns array of { logRow, colStart, text } — one entry per visual row.
10116
+ function buildVisualRows(visW) {
10117
+ const vRows = [];
10118
+ for (let i = 0; i < E.lines.length; i++) {
10119
+ const line = E.lines[i];
10120
+ if (line.length === 0) {
10121
+ vRows.push({ logRow: i, colStart: 0, text: '' });
10122
+ } else {
10123
+ for (let off = 0; off < line.length; off += visW) {
10124
+ vRows.push({ logRow: i, colStart: off, text: line.slice(off, off + visW) });
10125
+ }
10126
+ }
10127
+ }
10128
+ return vRows;
10129
+ }
10130
+
10131
+ // Find visual row index where cursor lives
10132
+ function cursorVisRow(vRows) {
10133
+ for (let v = 0; v < vRows.length; v++) {
10134
+ const vr = vRows[v];
10135
+ if (vr.logRow === E.row && E.col >= vr.colStart && E.col < vr.colStart + Math.max(vr.text.length, 1)) return v;
10136
+ // Cursor at end-of-line on last wrap segment
10137
+ if (vr.logRow === E.row && E.col === vr.colStart + vr.text.length) {
10138
+ const next = vRows[v + 1];
10139
+ if (!next || next.logRow !== E.row) return v; // last segment of this line
10140
+ }
10141
+ }
10142
+ return vRows.length - 1;
10143
+ }
10144
+
10145
+ function render() {
10146
+ clampCursor();
10147
+ const visH = editorBox.height - 2;
10148
+ const visW = editorBox.width - 2;
10149
+ if (visW < 2) return;
10150
+
10151
+ const vRows = buildVisualRows(visW);
10152
+ const cVRow = cursorVisRow(vRows);
10153
+
10154
+ // Scroll to keep cursor visual row visible
10155
+ if (cVRow < E.scrollY) E.scrollY = cVRow;
10156
+ if (cVRow >= E.scrollY + visH) E.scrollY = cVRow - visH + 1;
10157
+ if (E.scrollY < 0) E.scrollY = 0;
10158
+
10159
+ let out = '';
10160
+ for (let v = E.scrollY; v < Math.min(vRows.length, E.scrollY + visH); v++) {
10161
+ const vr = vRows[v];
10162
+ let rendered = '';
10163
+ for (let j = 0; j < vr.text.length; j++) {
10164
+ const logCol = vr.colStart + j;
10165
+ const ch = esc(vr.text[j]);
10166
+ if (vr.logRow === E.row && logCol === E.col) {
10167
+ rendered += `{inverse}${ch}{/inverse}`;
10168
+ } else if (isInSel(vr.logRow, logCol)) {
10169
+ rendered += `{blue-bg}{white-fg}${ch}{/white-fg}{/blue-bg}`;
10170
+ } else {
10171
+ rendered += ch;
10172
+ }
10173
+ }
10174
+ // Cursor at end of this visual segment
10175
+ if (vr.logRow === E.row && E.col === vr.colStart + vr.text.length) {
10176
+ const next = vRows[v + 1];
10177
+ if (!next || next.logRow !== E.row) {
10178
+ rendered += '{inverse} {/inverse}';
10179
+ }
10180
+ }
10181
+ out += rendered + '\n';
10182
+ }
10183
+
10184
+ // Status line at bottom
10185
+ const charCount = getText().length;
10186
+ const ogLen = data.ogPrompt?.length || 0;
10187
+ const diff = ogLen - charCount;
10188
+ const diffStr = diff > 0 ? `{#42d77d-fg}-${diff.toLocaleString()}{/#42d77d-fg}` : `{#ff4757-fg}+${Math.abs(diff).toLocaleString()}{/#ff4757-fg}`;
10189
+
10190
+ header.setContent(
10191
+ ` {#6c6c80-fg}OG:{/#6c6c80-fg} ${ogLen.toLocaleString()} `
10192
+ + `{#6c6c80-fg}Now:{/#6c6c80-fg} ${charCount.toLocaleString()} (${diffStr}) `
10193
+ + `{#6c6c80-fg}Ln:{/#6c6c80-fg} ${E.row + 1}/${E.lines.length} `
10194
+ + `{#6c6c80-fg}Col:{/#6c6c80-fg} ${E.col + 1}`
10195
+ + (E.dirty ? ' {#e056a0-fg}●modified{/#e056a0-fg}' : '') + '\n'
10196
+ + ` {#42d77d-fg}Ctrl+S{/#42d77d-fg}=Save {#ff4757-fg}Ctrl+R{/#ff4757-fg}=Reset {yellow-fg}Esc{/yellow-fg}=Cancel `
10197
+ + `{#6c6c80-fg}Ctrl+A{/#6c6c80-fg}=SelAll {#6c6c80-fg}Ctrl+C/X/V{/#6c6c80-fg}=Clipboard`
10198
+ );
10199
+
10200
+ editorBox.setContent(out);
10201
+ // Prevent blessed from resetting scroll position on setContent
10202
+ editorBox.childBase = 0;
10203
+ editorBox.childOffset = 0;
10204
+ self._safeRender();
10205
+ }
10206
+
10207
+ // ── Create UI ──
10208
+ const overlay = blessed.box({
10209
+ parent: screen,
10210
+ top: 'center', left: 'center',
10211
+ width: '90%', height: '90%',
10212
+ grabKeys: true, // prevent key leaking to elements behind overlay
10213
+ border: { type: 'line' },
10214
+ style: { fg: '#c0c0d0', bg: '#0a0a15', border: { fg: '#e056a0' }, label: { fg: '#e056a0' } },
10215
+ tags: true,
10216
+ label: data.hasCustom ? ' ★ Edit Custom System Prompt ' : ' Customize System Prompt ',
10217
+ });
10218
+
10219
+ const header = blessed.box({
10220
+ parent: overlay,
10221
+ top: 0, left: 1, right: 1, height: 3,
10222
+ style: { fg: '#a0a0b0', bg: '#0a0a15' },
10223
+ tags: true,
10224
+ });
10225
+
10226
+ const editorBox = blessed.box({
10227
+ parent: overlay,
10228
+ top: 3, left: 1, right: 1, bottom: 0,
10229
+ border: { type: 'line' },
10230
+ style: { fg: '#c0c0d0', bg: '#0f0f1a', border: { fg: '#3a3a50' } },
10231
+ scrollable: false, // we handle scrolling manually
10232
+ mouse: true,
10233
+ keys: true,
10234
+ tags: true,
10235
+ keyable: true,
10236
+ });
10237
+
10238
+ let _destroyed = false;
10239
+ // Block named key events (screen.key() bindings like 'key q', 'key tab') from reaching
10240
+ // dashboard while editor is open. We keep 'keypress' flowing so editorBox still gets input.
10241
+ const _origEmit = screen.emit.bind(screen);
10242
+ screen.emit = function(event, ...args) {
10243
+ if (typeof event === 'string' && event.startsWith('key ') && event !== 'keypress') return false;
10244
+ return _origEmit(event, ...args);
10245
+ };
10246
+
10247
+ const cleanup = () => {
10248
+ if (_destroyed) return;
10249
+ _destroyed = true;
10250
+ screen.emit = _origEmit; // restore original emit so dashboard keys work again
10251
+ editorBox.removeAllListeners();
10252
+ overlay.removeAllListeners();
10253
+ overlay.detach();
10254
+ self._editorActive = false;
10255
+ self._safeRender();
10256
+ process.nextTick(() => overlay.destroy());
10257
+ };
10258
+
10259
+ // ── Mouse Handling (click, drag-select) ──
10260
+ function mouseToPos(mouse) {
10261
+ const localY = mouse.y - (editorBox.atop || 0) - 1;
10262
+ const localX = mouse.x - (editorBox.aleft || 0) - 1;
10263
+ const visW = editorBox.width - 2;
10264
+ const vRows = buildVisualRows(visW);
10265
+ const vIdx = clamp(localY + E.scrollY, 0, vRows.length - 1);
10266
+ const vr = vRows[vIdx];
10267
+ const col = clamp(vr.colStart + localX, 0, E.lines[vr.logRow].length);
10268
+ return { row: vr.logRow, col };
10269
+ }
10270
+
10271
+ editorBox.on('mousedown', (mouse) => {
10272
+ const { row, col } = mouseToPos(mouse);
10273
+ const now = Date.now();
10274
+ if (now - E.lastClickTime < 350 && E.clickCount < 3) {
10275
+ E.clickCount++;
10276
+ } else {
10277
+ E.clickCount = 1;
10278
+ }
10279
+ E.lastClickTime = now;
10280
+
10281
+ if (E.clickCount === 3) {
10282
+ selectLine(row);
10283
+ E.dragging = false;
10284
+ } else if (E.clickCount === 2) {
10285
+ selectWord(row, col);
10286
+ E.dragging = false;
10287
+ } else {
10288
+ E.row = row; E.col = col;
10289
+ clearSel();
10290
+ E.dragging = true;
10291
+ }
10292
+ render();
10293
+ });
10294
+
10295
+ editorBox.on('mousemove', (mouse) => {
10296
+ if (!E.dragging) return;
10297
+ const { row, col } = mouseToPos(mouse);
10298
+ // Start selection from cursor if not already started
10299
+ if (!hasSelection()) {
10300
+ E.selAnchor = { row: E.row, col: E.col };
10301
+ }
10302
+ E.selEnd = { row, col };
10303
+ E.row = row; E.col = col;
10304
+ render();
10305
+ });
10306
+
10307
+ editorBox.on('mouseup', () => {
10308
+ E.dragging = false;
10309
+ });
10310
+
10311
+ // Scroll wheel
10312
+ editorBox.on('wheeldown', () => { E.scrollY = Math.min(E.scrollY + 3, Math.max(0, E.lines.length - 5)); render(); });
10313
+ editorBox.on('wheelup', () => { E.scrollY = Math.max(0, E.scrollY - 3); render(); });
10314
+
10315
+ // ── Keyboard Handling ──
10316
+ editorBox.on('keypress', (ch, key) => {
10317
+ if (!key) return;
10318
+ const ctrl = key.ctrl;
10319
+ const shift = key.shift;
10320
+ const name = key.name;
10321
+
10322
+ // ── Ctrl combos ──
10323
+ if (ctrl && name === 's') {
10324
+ const text = getText();
10325
+ if (!text.trim()) { self._updateStatusBar('{red-fg}Cannot save empty prompt{/red-fg}'); return; }
10326
+ self._proxyHttpPost('/custom-system-prompt', { prompt: text, ogHash }).then((res) => {
10327
+ if (res?.ok) self._updateStatusBar(`{#42d77d-fg}✓ Custom prompt saved (${text.length} chars){/#42d77d-fg}`);
10328
+ else self._updateStatusBar(`{red-fg}✗ Save failed: ${res?.error || 'unknown'}{/red-fg}`);
10329
+ cleanup();
10330
+ });
10331
+ return;
10332
+ }
10333
+ if (ctrl && name === 'r') {
10334
+ self._proxyHttpPost('/custom-system-prompt', { reset: true }).then((res) => {
10335
+ if (res?.ok) self._updateStatusBar('{#42d77d-fg}✓ System prompt reset to original{/#42d77d-fg}');
10336
+ cleanup();
10337
+ });
10338
+ return;
10339
+ }
10340
+ if (ctrl && name === 'a') { // Select All
10341
+ E.selAnchor = { row: 0, col: 0 };
10342
+ E.selEnd = { row: E.lines.length - 1, col: E.lines[E.lines.length - 1].length };
10343
+ E.row = E.selEnd.row; E.col = E.selEnd.col;
10344
+ render(); return;
10345
+ }
10346
+ if (ctrl && name === 'c') { // Copy
10347
+ if (hasSelection()) {
10348
+ E.clipboard = getSelectedText();
10349
+ _copyToSystemClipboard(E.clipboard);
10350
+ }
10351
+ render(); return;
10352
+ }
10353
+ if (ctrl && name === 'x') { // Cut
10354
+ if (hasSelection()) {
10355
+ E.clipboard = getSelectedText();
10356
+ _copyToSystemClipboard(E.clipboard);
10357
+ deleteSelection();
10358
+ }
10359
+ render(); return;
10360
+ }
10361
+ if (ctrl && name === 'v') { // Paste
10362
+ // Try system clipboard first, fallback to internal
10363
+ const sysCb = _readSystemClipboard();
10364
+ if (sysCb) { E.clipboard = sysCb; insertTextAt(sysCb); }
10365
+ else if (E.clipboard) insertTextAt(E.clipboard);
10366
+ render(); return;
10367
+ }
10368
+
10369
+ // ── Escape ──
10370
+ if (name === 'escape') {
10371
+ // Prevent event propagation before cleanup
10372
+ editorBox.removeAllListeners('keypress');
10373
+ cleanup();
10374
+ return;
10375
+ }
10376
+
10377
+ // ── Arrow keys (with shift-select) ──
10378
+ if (name === 'left' || name === 'right' || name === 'up' || name === 'down'
10379
+ || name === 'home' || name === 'end' || name === 'pageup' || name === 'pagedown') {
10380
+ const prevRow = E.row, prevCol = E.col;
10381
+
10382
+ if (name === 'left') {
10383
+ if (E.col > 0) E.col--;
10384
+ else if (E.row > 0) { E.row--; E.col = E.lines[E.row].length; }
10385
+ } else if (name === 'right') {
10386
+ if (E.col < E.lines[E.row].length) E.col++;
10387
+ else if (E.row < E.lines.length - 1) { E.row++; E.col = 0; }
10388
+ } else if (name === 'up') {
10389
+ if (E.row > 0) { E.row--; E.col = Math.min(E.col, E.lines[E.row].length); }
10390
+ } else if (name === 'down') {
10391
+ if (E.row < E.lines.length - 1) { E.row++; E.col = Math.min(E.col, E.lines[E.row].length); }
10392
+ } else if (name === 'home') {
10393
+ E.col = 0;
10394
+ } else if (name === 'end') {
10395
+ E.col = E.lines[E.row].length;
10396
+ } else if (name === 'pageup') {
10397
+ const visH = editorBox.height - 2;
10398
+ E.row = Math.max(0, E.row - visH);
10399
+ E.col = Math.min(E.col, E.lines[E.row].length);
10400
+ } else if (name === 'pagedown') {
10401
+ const visH = editorBox.height - 2;
10402
+ E.row = Math.min(E.lines.length - 1, E.row + visH);
10403
+ E.col = Math.min(E.col, E.lines[E.row].length);
10404
+ }
10405
+
10406
+ if (shift) {
10407
+ if (!E.selAnchor) E.selAnchor = { row: prevRow, col: prevCol };
10408
+ E.selEnd = { row: E.row, col: E.col };
10409
+ } else {
10410
+ clearSel();
10411
+ }
10412
+ render(); return;
10413
+ }
10414
+
10415
+ // ── Backspace ──
10416
+ if (name === 'backspace') {
10417
+ if (hasSelection()) { deleteSelection(); }
10418
+ else if (E.col > 0) {
10419
+ E.lines[E.row] = E.lines[E.row].slice(0, E.col - 1) + E.lines[E.row].slice(E.col);
10420
+ E.col--;
10421
+ E.dirty = true;
10422
+ } else if (E.row > 0) {
10423
+ E.col = E.lines[E.row - 1].length;
10424
+ E.lines[E.row - 1] += E.lines[E.row];
10425
+ E.lines.splice(E.row, 1);
10426
+ E.row--;
10427
+ E.dirty = true;
10428
+ }
10429
+ render(); return;
10430
+ }
10431
+
10432
+ // ── Delete ──
10433
+ if (name === 'delete') {
10434
+ if (hasSelection()) { deleteSelection(); }
10435
+ else if (E.col < E.lines[E.row].length) {
10436
+ E.lines[E.row] = E.lines[E.row].slice(0, E.col) + E.lines[E.row].slice(E.col + 1);
10437
+ E.dirty = true;
10438
+ } else if (E.row < E.lines.length - 1) {
10439
+ E.lines[E.row] += E.lines[E.row + 1];
10440
+ E.lines.splice(E.row + 1, 1);
10441
+ E.dirty = true;
10442
+ }
10443
+ render(); return;
10444
+ }
10445
+
10446
+ // ── Enter ──
10447
+ if (name === 'enter' || name === 'return') {
10448
+ if (hasSelection()) deleteSelection();
10449
+ const after = E.lines[E.row].slice(E.col);
10450
+ E.lines[E.row] = E.lines[E.row].slice(0, E.col);
10451
+ E.lines.splice(E.row + 1, 0, after);
10452
+ E.row++; E.col = 0;
10453
+ E.dirty = true;
10454
+ render(); return;
10455
+ }
10456
+
10457
+ // ── Tab ──
10458
+ if (name === 'tab') {
10459
+ if (hasSelection()) deleteSelection();
10460
+ insertTextAt(' ');
10461
+ render(); return;
10462
+ }
10463
+
10464
+ // ── Printable character ──
10465
+ if (ch && ch.length === 1 && !ctrl && !key.meta) {
10466
+ if (hasSelection()) deleteSelection();
10467
+ E.lines[E.row] = E.lines[E.row].slice(0, E.col) + ch + E.lines[E.row].slice(E.col);
10468
+ E.col++;
10469
+ E.dirty = true;
10470
+ render(); return;
10471
+ }
10472
+ });
10473
+
10474
+ // Prevent blessed's built-in key/scroll handling from moving content
10475
+ editorBox.scrollable = false;
10476
+ editorBox.focus();
10477
+ screen.on('keypress', _blockKeys);
10478
+ render();
10479
+ }
10480
+
9693
10481
  _adjustProxySlider(direction) {
9694
10482
  const def = this._proxySliderDefs[this._proxySliderIdx];
9695
10483
  if (!def || !this._proxyConfig) return;