specmem-hardwicksoftware 3.7.35 → 3.7.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.md +11 -15
- package/bin/specmem-autoclaude.cjs +12 -1
- package/bin/specmem-cli.cjs +1077 -11
- package/bin/specmem-console.cjs +890 -63
- package/bootstrap.cjs +10 -2
- package/claude-hooks/agent-loading-hook.cjs +16 -16
- package/claude-hooks/agent-loading-hook.js +28 -21
- package/claude-hooks/agent-type-matcher.js +1 -1
- package/claude-hooks/background-completion-silencer.js +1 -1
- package/claude-hooks/file-claim-enforcer.cjs +37 -36
- package/claude-hooks/output-cleaner.cjs +1 -1
- package/claude-hooks/refusal-detector-hook.cjs +53 -0
- package/claude-hooks/settings.json +64 -4
- package/claude-hooks/smart-search-interceptor.js +1 -1
- package/claude-hooks/specmem-search-enforcer.cjs +2 -11
- package/claude-hooks/specmem-team-member-inject.js +1 -1
- package/claude-hooks/specmem-unified-hook.py +1 -1
- package/claude-hooks/subagent-loading-hook.cjs +1 -1
- package/claude-hooks/task-progress-hook.cjs +7 -7
- package/claude-hooks/task-progress-hook.js +3 -3
- package/claude-hooks/team-comms-enforcer.cjs +113 -47
- package/claude-hooks/use-code-pointers.cjs +1 -1
- package/dist/claude-sessions/sessionParser.js +5 -0
- package/dist/cli/deploy-to-claude.js +9 -2
- package/dist/codebase/codebaseIndexer.js +48 -17
- package/dist/codebase/exclusions.js +3 -4
- package/dist/codebase/index.js +4 -0
- package/dist/codebase/pdfExtractor.js +298 -0
- package/dist/dashboard/api/taskTeamMembers.js +2 -2
- package/dist/db/bigBrainMigrations.js +29 -0
- package/dist/hooks/hookManager.js +4 -4
- package/dist/hooks/teamFramingCli.js +1 -1
- package/dist/hooks/teamMemberPrepromptHook.js +5 -5
- package/dist/index.js +49 -12
- package/dist/init/claudeConfigInjector.js +27 -8
- package/dist/installer/autoInstall.js +7 -1
- package/dist/mcp/compactionProxy.js +1052 -192
- package/dist/mcp/compactionProxyDaemon.js +112 -37
- package/dist/mcp/contextVault.js +439 -0
- package/dist/mcp/embeddingServerManager.js +151 -17
- package/dist/mcp/mcpProtocolHandler.js +6 -1
- package/dist/mcp/miniCOTServerManager.js +82 -8
- package/dist/mcp/specMemServer.js +45 -10
- package/dist/mcp/toolRegistry.js +6 -0
- package/dist/startup/startupIndexing.js +14 -0
- package/dist/team-members/taskOrchestrator.js +3 -3
- package/dist/team-members/taskTeamMemberLogger.js +2 -2
- package/dist/tools/goofy/deployTeamMember.js +3 -3
- package/dist/tools/goofy/digInTheVault.js +81 -0
- package/dist/tools/goofy/findCodePointers.js +17 -0
- package/dist/tools/goofy/findWhatISaid.js +19 -0
- package/dist/tools/goofy/stashTheGoods.js +56 -0
- package/dist/tools/teamMemberDeployer.js +2 -2
- package/dist/watcher/changeHandler.js +65 -8
- package/dist/watcher/changeQueue.js +20 -1
- package/embedding-sandbox/frankenstein-embeddings.py +4 -3
- package/embedding-sandbox/mini-cot-service.py +11 -13
- package/embedding-sandbox/pdf-text-extract.py +208 -0
- package/package.json +1 -1
- package/scripts/deploy-hooks.cjs +12 -4
- package/scripts/fast-batch-embedder.cjs +2 -2
- package/scripts/force-retry.cjs +34 -0
- package/scripts/global-postinstall.cjs +97 -4
- package/scripts/poetic-abliteration.cjs +379 -0
- package/scripts/refusal-enforcer.cjs +88 -0
- package/scripts/specmem-init.cjs +222 -41
- package/specmem/model-config.json +6 -6
- package/specmem/supervisord.conf +1 -1
- package/svg-sections/readme-token-compaction.svg +246 -0
- package/claude-hooks/agent-chooser-hook.js +0 -179
package/bin/specmem-console.cjs
CHANGED
|
@@ -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
|
});
|
|
@@ -1574,6 +1590,19 @@ class Controller {
|
|
|
1574
1590
|
];
|
|
1575
1591
|
}
|
|
1576
1592
|
|
|
1593
|
+
/**
|
|
1594
|
+
* Get compaction proxy port if running
|
|
1595
|
+
*/
|
|
1596
|
+
_getProxyPort() {
|
|
1597
|
+
try {
|
|
1598
|
+
const portFile = path.join(os.homedir(), '.claude', '.compaction-proxy-port');
|
|
1599
|
+
if (fs.existsSync(portFile)) {
|
|
1600
|
+
return parseInt(fs.readFileSync(portFile, 'utf8').trim(), 10) || null;
|
|
1601
|
+
}
|
|
1602
|
+
} catch (e) {}
|
|
1603
|
+
return null;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1577
1606
|
/**
|
|
1578
1607
|
* Check if is running
|
|
1579
1608
|
*/
|
|
@@ -1597,10 +1626,13 @@ class Controller {
|
|
|
1597
1626
|
// Uses screen hardcopy to tmpfs on-demand instead of continuous logging
|
|
1598
1627
|
// -h 5000 sets scrollback buffer to 5000 lines for hardcopy capture
|
|
1599
1628
|
// SPECMEM_DASHBOARD=1 tells claudefix to disable its footer (dashboard handles rendering)
|
|
1629
|
+
// COMPACTION: Route through proxy if running (overrides settings.json ANTHROPIC_BASE_URL)
|
|
1630
|
+
const proxyPort = this._getProxyPort();
|
|
1631
|
+
const proxyEnv = proxyPort ? `ANTHROPIC_BASE_URL="http://127.0.0.1:${proxyPort}" ` : '';
|
|
1600
1632
|
const claudeBin = getClaudeBinary();
|
|
1601
1633
|
const cmd = prompt
|
|
1602
|
-
? `screen -h 5000 -dmS ${this.claudeSession} bash -c "cd '${this.projectPath}' && SPECMEM_DASHBOARD=1 '${claudeBin}' '${prompt.replace(/'/g, "\\'")}' 2>&1; exec bash"`
|
|
1603
|
-
: `screen -h 5000 -dmS ${this.claudeSession} bash -c "cd '${this.projectPath}' && SPECMEM_DASHBOARD=1 '${claudeBin}' 2>&1; exec bash"`;
|
|
1634
|
+
? `screen -h 5000 -dmS ${this.claudeSession} bash -c "cd '${this.projectPath}' && ${proxyEnv}SPECMEM_DASHBOARD=1 '${claudeBin}' '${prompt.replace(/'/g, "\\'")}' 2>&1; exec bash"`
|
|
1635
|
+
: `screen -h 5000 -dmS ${this.claudeSession} bash -c "cd '${this.projectPath}' && ${proxyEnv}SPECMEM_DASHBOARD=1 '${claudeBin}' 2>&1; exec bash"`;
|
|
1604
1636
|
|
|
1605
1637
|
execSync(cmd, { stdio: 'ignore' });
|
|
1606
1638
|
|
|
@@ -1883,7 +1915,12 @@ ${output}
|
|
|
1883
1915
|
class SpecMemDirect {
|
|
1884
1916
|
constructor(projectPath) {
|
|
1885
1917
|
this.projectPath = projectPath;
|
|
1886
|
-
|
|
1918
|
+
// Check env var first (set by MCP server config), then try both socket locations
|
|
1919
|
+
this.socketPath = process.env.SPECMEM_EMBEDDING_SOCKET
|
|
1920
|
+
|| [path.join(projectPath, 'specmem', 'run', 'embed.sock'),
|
|
1921
|
+
path.join(projectPath, 'specmem', 'sockets', 'embeddings.sock')]
|
|
1922
|
+
.find(p => fs.existsSync(p))
|
|
1923
|
+
|| path.join(projectPath, 'specmem', 'run', 'embed.sock');
|
|
1887
1924
|
this.configPath = path.join(projectPath, 'specmem', 'model-config.json');
|
|
1888
1925
|
this.pool = null; // FIX HIGH-29: Database pool for direct queries
|
|
1889
1926
|
this.schema = null; // Cached schema name
|
|
@@ -3914,9 +3951,15 @@ class SpecMemConsole {
|
|
|
3914
3951
|
*/
|
|
3915
3952
|
async handleMiniCOTCommand(args) {
|
|
3916
3953
|
const subCmd = args[0]?.toLowerCase() || 'status';
|
|
3917
|
-
|
|
3918
|
-
const
|
|
3919
|
-
const
|
|
3954
|
+
// Check run/ first (Docker brain container), fall back to sockets/ (legacy host mode)
|
|
3955
|
+
const mcRunDir = path.join(this.projectPath, 'specmem', 'run');
|
|
3956
|
+
const mcSockDir = path.join(this.projectPath, 'specmem', 'sockets');
|
|
3957
|
+
const mcBaseDir = fs.existsSync(path.join(mcRunDir, 'minicot.sock')) ? mcRunDir
|
|
3958
|
+
: fs.existsSync(path.join(mcSockDir, 'minicot.sock')) ? mcSockDir
|
|
3959
|
+
: mcRunDir;
|
|
3960
|
+
const sockPath = path.join(mcBaseDir, 'minicot.sock');
|
|
3961
|
+
const pidPath = path.join(mcBaseDir, 'minicot.pid');
|
|
3962
|
+
const stoppedPath = path.join(mcBaseDir, 'minicot.stopped');
|
|
3920
3963
|
|
|
3921
3964
|
switch (subCmd) {
|
|
3922
3965
|
case 'status': {
|
|
@@ -3924,45 +3967,95 @@ class SpecMemConsole {
|
|
|
3924
3967
|
let pid = null, isRunning = false;
|
|
3925
3968
|
try {
|
|
3926
3969
|
if (fs.existsSync(pidPath)) {
|
|
3927
|
-
|
|
3928
|
-
|
|
3970
|
+
const content = fs.readFileSync(pidPath, 'utf8').trim();
|
|
3971
|
+
// PID file format is PID:TIMESTAMP
|
|
3972
|
+
pid = parseInt(content.split(':')[0], 10);
|
|
3973
|
+
if (!isNaN(pid)) {
|
|
3974
|
+
try { process.kill(pid, 0); isRunning = true; } catch (e) {
|
|
3975
|
+
// Process dead — stale PID file
|
|
3976
|
+
isRunning = false;
|
|
3977
|
+
}
|
|
3978
|
+
} else {
|
|
3979
|
+
pid = null;
|
|
3980
|
+
}
|
|
3929
3981
|
}
|
|
3930
3982
|
} catch (e) {}
|
|
3931
3983
|
const isStopped = fs.existsSync(stoppedPath);
|
|
3932
3984
|
const socketExists = fs.existsSync(sockPath);
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
console.log(`
|
|
3985
|
+
const statusLabel = isRunning ? `${c.green}Running${c.reset}` :
|
|
3986
|
+
(pid && !isRunning) ? `${c.red}Dead (stale PID ${pid})${c.reset}` : `${c.red}Stopped${c.reset}`;
|
|
3987
|
+
console.log(` Status: ${statusLabel}`);
|
|
3988
|
+
console.log(` PID: ${isRunning ? pid : '(none)'}`);
|
|
3989
|
+
console.log(` Socket: ${socketExists ? `${c.green}Exists${c.reset}` : 'Not found'} (${sockPath})`);
|
|
3936
3990
|
console.log(` Auto-start: ${isStopped ? `${c.yellow}Disabled${c.reset}` : `${c.green}Enabled${c.reset}`}`);
|
|
3991
|
+
if (pid && !isRunning) {
|
|
3992
|
+
console.log(` ${c.yellow}Hint: stale PID file detected. Run 'minicot stop' to clean up.${c.reset}`);
|
|
3993
|
+
}
|
|
3937
3994
|
break;
|
|
3938
3995
|
}
|
|
3939
3996
|
case 'start': {
|
|
3940
3997
|
if (fs.existsSync(stoppedPath)) fs.unlinkSync(stoppedPath);
|
|
3941
|
-
|
|
3998
|
+
// Clean stale PID/socket before starting
|
|
3999
|
+
if (fs.existsSync(pidPath)) {
|
|
4000
|
+
try {
|
|
4001
|
+
const content = fs.readFileSync(pidPath, 'utf8').trim();
|
|
4002
|
+
const oldPid = parseInt(content.split(':')[0], 10);
|
|
4003
|
+
if (!isNaN(oldPid)) {
|
|
4004
|
+
try { process.kill(oldPid, 0); console.log(`${c.yellow}Already running (PID ${oldPid})${c.reset}`); break; } catch (_e) {}
|
|
4005
|
+
}
|
|
4006
|
+
fs.unlinkSync(pidPath);
|
|
4007
|
+
} catch (_e) {}
|
|
4008
|
+
}
|
|
4009
|
+
if (fs.existsSync(sockPath)) {
|
|
4010
|
+
try { fs.unlinkSync(sockPath); } catch (_e) {}
|
|
4011
|
+
}
|
|
4012
|
+
const scriptPath = path.join(__dirname, '..', 'embedding-sandbox', 'mini-cot-service.py');
|
|
3942
4013
|
if (fs.existsSync(scriptPath)) {
|
|
3943
4014
|
// Task #22 fix: Use getPythonPath() instead of hardcoded 'python3'
|
|
3944
4015
|
const pythonPath = getPythonPath();
|
|
3945
4016
|
const proc = require('child_process').spawn(pythonPath, [scriptPath], {
|
|
3946
4017
|
cwd: this.projectPath, detached: true, stdio: 'ignore',
|
|
3947
|
-
env: { ...process.env, SPECMEM_PROJECT_PATH: this.projectPath }
|
|
4018
|
+
env: { ...process.env, SPECMEM_PROJECT_PATH: this.projectPath, SPECMEM_MINICOT_SOCKET: sockPath }
|
|
3948
4019
|
});
|
|
3949
4020
|
proc.unref();
|
|
4021
|
+
// Write PID file atomically (PID:TIMESTAMP format)
|
|
4022
|
+
const pidDir = path.dirname(pidPath);
|
|
4023
|
+
fs.mkdirSync(pidDir, { recursive: true });
|
|
4024
|
+
const tmpPid = pidPath + '.tmp';
|
|
4025
|
+
fs.writeFileSync(tmpPid, `${proc.pid}:${Date.now()}`, 'utf8');
|
|
4026
|
+
fs.renameSync(tmpPid, pidPath);
|
|
3950
4027
|
console.log(`${c.green}Mini COT starting (PID: ${proc.pid})${c.reset}`);
|
|
3951
4028
|
} else {
|
|
3952
|
-
console.log(`${c.yellow}mini-cot-service.py not found${c.reset}`);
|
|
4029
|
+
console.log(`${c.yellow}mini-cot-service.py not found at ${scriptPath}${c.reset}`);
|
|
3953
4030
|
}
|
|
3954
4031
|
break;
|
|
3955
4032
|
}
|
|
3956
4033
|
case 'stop': {
|
|
3957
4034
|
fs.mkdirSync(path.dirname(stoppedPath), { recursive: true });
|
|
3958
4035
|
fs.writeFileSync(stoppedPath, new Date().toISOString());
|
|
4036
|
+
let killed = false;
|
|
3959
4037
|
if (fs.existsSync(pidPath)) {
|
|
3960
4038
|
try {
|
|
3961
|
-
const
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
4039
|
+
const content = fs.readFileSync(pidPath, 'utf8').trim();
|
|
4040
|
+
const pid = parseInt(content.split(':')[0], 10);
|
|
4041
|
+
if (!isNaN(pid)) {
|
|
4042
|
+
process.kill(pid, 'SIGTERM');
|
|
4043
|
+
console.log(`${c.green}Stopped PID ${pid}${c.reset}`);
|
|
4044
|
+
killed = true;
|
|
4045
|
+
}
|
|
4046
|
+
} catch (e) { console.log(`${c.yellow}Process already dead${c.reset}`); }
|
|
4047
|
+
// Always clean up PID file
|
|
4048
|
+
try { fs.unlinkSync(pidPath); } catch (_e) {}
|
|
4049
|
+
// Also clean up .tmp from atomic writes
|
|
4050
|
+
try { fs.unlinkSync(pidPath + '.tmp'); } catch (_e) {}
|
|
4051
|
+
}
|
|
4052
|
+
// Clean up stale socket
|
|
4053
|
+
if (fs.existsSync(sockPath)) {
|
|
4054
|
+
try { fs.unlinkSync(sockPath); } catch (_e) {}
|
|
4055
|
+
console.log(` Cleaned up socket: ${sockPath}`);
|
|
4056
|
+
}
|
|
4057
|
+
if (!killed && !fs.existsSync(pidPath)) {
|
|
4058
|
+
console.log(`${c.yellow}Already stopped${c.reset}`);
|
|
3966
4059
|
}
|
|
3967
4060
|
break;
|
|
3968
4061
|
}
|
|
@@ -4061,10 +4154,16 @@ class SpecMemConsole {
|
|
|
4061
4154
|
*/
|
|
4062
4155
|
async handleEmbeddingCommand(args) {
|
|
4063
4156
|
const subCmd = args[0]?.toLowerCase() || 'status';
|
|
4064
|
-
|
|
4065
|
-
const
|
|
4066
|
-
const
|
|
4067
|
-
const
|
|
4157
|
+
// Check run/ first (Docker brain container), fall back to sockets/ (legacy host mode)
|
|
4158
|
+
const runDir = path.join(this.projectPath, 'specmem', 'run');
|
|
4159
|
+
const socketsDir = path.join(this.projectPath, 'specmem', 'sockets');
|
|
4160
|
+
const baseDir = fs.existsSync(path.join(runDir, 'embed.sock')) ? runDir
|
|
4161
|
+
: fs.existsSync(path.join(socketsDir, 'embedding.sock')) ? socketsDir
|
|
4162
|
+
: runDir;
|
|
4163
|
+
const sockPath = baseDir === runDir ? path.join(runDir, 'embed.sock') : path.join(socketsDir, 'embedding.sock');
|
|
4164
|
+
const pidPath = path.join(baseDir, 'embedding.pid');
|
|
4165
|
+
const stoppedPath = path.join(baseDir, 'embedding.stopped');
|
|
4166
|
+
const logPath = path.join(baseDir, 'embedding.log');
|
|
4068
4167
|
|
|
4069
4168
|
switch (subCmd) {
|
|
4070
4169
|
case 'status': {
|
|
@@ -4906,7 +5005,33 @@ class SpecMemConsole {
|
|
|
4906
5005
|
} catch (e) { /* no container runtime */ }
|
|
4907
5006
|
|
|
4908
5007
|
if (isContainerMode && brainContainerName) {
|
|
4909
|
-
// Container mode:
|
|
5008
|
+
// Container mode: update container cgroup limits via docker update
|
|
5009
|
+
try {
|
|
5010
|
+
const configPath = path.join(this.projectPath, 'specmem', 'model-config.json');
|
|
5011
|
+
if (fs.existsSync(configPath)) {
|
|
5012
|
+
const modelCfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
5013
|
+
const res = modelCfg.resources || {};
|
|
5014
|
+
const updateArgs = [];
|
|
5015
|
+
// RAM: convert MB to docker memory format
|
|
5016
|
+
if (res.ramMaxMb && res.ramMaxMb >= 256) {
|
|
5017
|
+
updateArgs.push(`--memory=${res.ramMaxMb}m`);
|
|
5018
|
+
// Memory swap = 2x memory to allow some swap headroom
|
|
5019
|
+
updateArgs.push(`--memory-swap=${res.ramMaxMb * 2}m`);
|
|
5020
|
+
}
|
|
5021
|
+
// CPU: convert cpuCoreMax to docker --cpus (float)
|
|
5022
|
+
if (res.cpuCoreMax && res.cpuCoreMax > 0) {
|
|
5023
|
+
updateArgs.push(`--cpus=${res.cpuCoreMax}`);
|
|
5024
|
+
}
|
|
5025
|
+
if (updateArgs.length > 0) {
|
|
5026
|
+
execSync(`${rtCmd} update ${updateArgs.join(' ')} ${brainContainerName}`, { timeout: 10000 });
|
|
5027
|
+
console.log(` ${c.green}${icons.success}${c.reset} Container limits updated: ${updateArgs.join(' ')}`);
|
|
5028
|
+
}
|
|
5029
|
+
}
|
|
5030
|
+
} catch (e) {
|
|
5031
|
+
console.log(` ${c.dim}docker update failed: ${e.message} — container restart may be needed${c.reset}`);
|
|
5032
|
+
}
|
|
5033
|
+
|
|
5034
|
+
// Signal embedding server PID inside container for config reload
|
|
4910
5035
|
try {
|
|
4911
5036
|
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
5037
|
if (pid && !isNaN(parseInt(pid))) {
|
|
@@ -5291,10 +5416,14 @@ class SpecMemConsole {
|
|
|
5291
5416
|
try { process.kill(parseInt(pid, 10), 'SIGTERM'); killed++; } catch (e) { /* gone */ }
|
|
5292
5417
|
}
|
|
5293
5418
|
} catch (e) { /* ok */ }
|
|
5294
|
-
// Clean socket files
|
|
5419
|
+
// Clean socket files from both legacy (sockets/) and new (run/) directories
|
|
5420
|
+
const runDir = path.join(this.projectPath, 'specmem', 'run');
|
|
5295
5421
|
for (const f of ['embeddings.sock', 'embedding.pid', 'embedding.lock', 'translate.sock', 'translate.pid', 'minicot.sock', 'minicot.pid']) {
|
|
5296
5422
|
try { fs.unlinkSync(path.join(socketsDir, f)); } catch (e) { /* ok */ }
|
|
5297
5423
|
}
|
|
5424
|
+
for (const f of ['embed.sock', 'embedding.pid', 'embedding.lock', 'translate.sock', 'translate.pid', 'minicot.sock', 'minicot.pid']) {
|
|
5425
|
+
try { fs.unlinkSync(path.join(runDir, f)); } catch (e) { /* ok */ }
|
|
5426
|
+
}
|
|
5298
5427
|
if (killed > 0) console.log(` ${c.green}${icons.success}${c.reset} Host services killed`);
|
|
5299
5428
|
else console.log(` ${c.dim}(not running)${c.reset}`);
|
|
5300
5429
|
}
|
|
@@ -6865,7 +6994,9 @@ ${last500}
|
|
|
6865
6994
|
const updatePythiaDisplay = (maxWidth) => {
|
|
6866
6995
|
try {
|
|
6867
6996
|
// Check if MiniCOT is running by looking for its socket or process
|
|
6868
|
-
const miniCotSocket = path.join(self.projectPath, 'specmem/
|
|
6997
|
+
const miniCotSocket = fs.existsSync(path.join(self.projectPath, 'specmem/run/minicot.sock'))
|
|
6998
|
+
? path.join(self.projectPath, 'specmem/run/minicot.sock')
|
|
6999
|
+
: path.join(self.projectPath, 'specmem/sockets/minicot.sock');
|
|
6869
7000
|
const isRunning = fs.existsSync(miniCotSocket);
|
|
6870
7001
|
|
|
6871
7002
|
if (isRunning) {
|
|
@@ -8315,7 +8446,9 @@ const TUI_TABS = [
|
|
|
8315
8446
|
},
|
|
8316
8447
|
{
|
|
8317
8448
|
name: 'Proxy', key: '6',
|
|
8318
|
-
commands: [
|
|
8449
|
+
commands: [
|
|
8450
|
+
{ label: 'Restart Proxy', cmd: 'proxy-restart' },
|
|
8451
|
+
],
|
|
8319
8452
|
type: 'proxy',
|
|
8320
8453
|
},
|
|
8321
8454
|
{
|
|
@@ -8363,7 +8496,15 @@ class ConsoleTUI {
|
|
|
8363
8496
|
];
|
|
8364
8497
|
this._proxyLogLines = []; // scrollable debug log buffer
|
|
8365
8498
|
this._proxyLogSince = ''; // ISO timestamp for incremental log fetch
|
|
8499
|
+
this._proxyPreviewHistory = []; // accumulated preview entries for In/Out panels
|
|
8500
|
+
this._proxyPreviewSince = ''; // ISO timestamp for incremental preview fetch
|
|
8366
8501
|
this._proxySystemPrompt = null;
|
|
8502
|
+
this._proxyCustomSysPrompt = null;
|
|
8503
|
+
// Smart scroll state — prevent setScrollPerc(100) from dragging user back down
|
|
8504
|
+
this._proxyLastInHash = null;
|
|
8505
|
+
this._proxyLastOutHash = null;
|
|
8506
|
+
this._proxyUserScrolledIn = false;
|
|
8507
|
+
this._proxyUserScrolledOut = false;
|
|
8367
8508
|
const sysCores = os.cpus().length;
|
|
8368
8509
|
const sysTotalMb = Math.round(os.totalmem() / 1024 / 1024);
|
|
8369
8510
|
const ramStep = sysTotalMb > 16000 ? 500 : sysTotalMb > 4000 ? 250 : 100;
|
|
@@ -8648,6 +8789,7 @@ class ConsoleTUI {
|
|
|
8648
8789
|
tags: true,
|
|
8649
8790
|
scrollable: true,
|
|
8650
8791
|
alwaysScroll: true,
|
|
8792
|
+
scrollbar: { ch: '│', style: { fg: '#3a3a50' } },
|
|
8651
8793
|
keys: true,
|
|
8652
8794
|
mouse: true,
|
|
8653
8795
|
content: '',
|
|
@@ -8663,12 +8805,17 @@ class ConsoleTUI {
|
|
|
8663
8805
|
tags: true,
|
|
8664
8806
|
scrollable: true,
|
|
8665
8807
|
alwaysScroll: true,
|
|
8808
|
+
scrollbar: { ch: '│', style: { fg: '#3a3a50' } },
|
|
8666
8809
|
keys: true,
|
|
8667
8810
|
mouse: true,
|
|
8668
8811
|
content: '',
|
|
8669
8812
|
label: ' OUT (compacted) ',
|
|
8670
8813
|
});
|
|
8671
8814
|
|
|
8815
|
+
// Scroll event listeners — detect when user manually scrolls up to prevent auto-scroll drag-down
|
|
8816
|
+
w.proxyIn.on('scroll', () => { this._proxyUserScrolledIn = true; });
|
|
8817
|
+
w.proxyOut.on('scroll', () => { this._proxyUserScrolledOut = true; });
|
|
8818
|
+
|
|
8672
8819
|
// Logs panel (hidden, shown on Logs tab — full width)
|
|
8673
8820
|
w.logsBox = blessed.log({
|
|
8674
8821
|
parent: this._screen,
|
|
@@ -8715,8 +8862,15 @@ class ConsoleTUI {
|
|
|
8715
8862
|
});
|
|
8716
8863
|
|
|
8717
8864
|
// ── Key bindings ──
|
|
8718
|
-
this._screen.key(['q'
|
|
8865
|
+
this._screen.key(['q'], () => {
|
|
8866
|
+
if (this._commandMode || this._filterMode) return;
|
|
8867
|
+
this.stop();
|
|
8868
|
+
});
|
|
8869
|
+
// Ctrl+C: copy selection if in editor, otherwise quit
|
|
8870
|
+
this._screen.key(['C-c'], () => {
|
|
8719
8871
|
if (this._commandMode || this._filterMode) return;
|
|
8872
|
+
// If editor overlay active, don't kill - let editor handle it
|
|
8873
|
+
if (this._editorActive) return;
|
|
8720
8874
|
this.stop();
|
|
8721
8875
|
});
|
|
8722
8876
|
|
|
@@ -8877,6 +9031,13 @@ class ConsoleTUI {
|
|
|
8877
9031
|
}
|
|
8878
9032
|
});
|
|
8879
9033
|
|
|
9034
|
+
// Customize system prompt modal (P key on proxy tab)
|
|
9035
|
+
this._screen.key(['p'], () => {
|
|
9036
|
+
if (TUI_TABS[this._activeTabIdx].type === 'proxy') {
|
|
9037
|
+
this._showCustomSysPromptModal();
|
|
9038
|
+
}
|
|
9039
|
+
});
|
|
9040
|
+
|
|
8880
9041
|
// ── Screen resize handler ──
|
|
8881
9042
|
// Panels use percentage widths and bottom-based height, so blessed
|
|
8882
9043
|
// auto-recalculates geometry. We just need a full repaint.
|
|
@@ -9236,6 +9397,18 @@ class ConsoleTUI {
|
|
|
9236
9397
|
return;
|
|
9237
9398
|
}
|
|
9238
9399
|
|
|
9400
|
+
// Handle proxy-restart
|
|
9401
|
+
if (cmdDef.cmd === 'proxy-restart') {
|
|
9402
|
+
(async () => {
|
|
9403
|
+
this._debug('Restarting proxy daemon...');
|
|
9404
|
+
await this._killProxyDaemon();
|
|
9405
|
+
await new Promise(r => setTimeout(r, 600));
|
|
9406
|
+
await this._spawnProxyDaemon();
|
|
9407
|
+
this._renderProxyTab();
|
|
9408
|
+
})();
|
|
9409
|
+
return;
|
|
9410
|
+
}
|
|
9411
|
+
|
|
9239
9412
|
// Handle confirm type
|
|
9240
9413
|
if (cmdDef.type === 'confirm') {
|
|
9241
9414
|
this._showConfirmDialog(cmdDef.confirmText || 'Are you sure?', () => {
|
|
@@ -9466,26 +9639,58 @@ class ConsoleTUI {
|
|
|
9466
9639
|
|
|
9467
9640
|
_startProxyPolling() {
|
|
9468
9641
|
if (this._proxyPollTimer) return;
|
|
9642
|
+
this._proxyResetDone = false;
|
|
9643
|
+
this._proxySpawnAttempted = false;
|
|
9469
9644
|
const poll = async () => {
|
|
9470
9645
|
try {
|
|
9471
9646
|
if (TUI_TABS[this._activeTabIdx]?.type !== 'proxy') return;
|
|
9472
|
-
|
|
9647
|
+
// Auto-spawn proxy daemon if not running and port file missing
|
|
9648
|
+
if (!this._proxySpawnAttempted) {
|
|
9649
|
+
const port = this._getProxyPort();
|
|
9650
|
+
if (!port) {
|
|
9651
|
+
this._proxySpawnAttempted = true;
|
|
9652
|
+
this._debug('Proxy not running — spawning daemon...');
|
|
9653
|
+
await this._spawnProxyDaemon();
|
|
9654
|
+
}
|
|
9655
|
+
}
|
|
9656
|
+
// First poll: reset proxy state to clear stale data from previous session
|
|
9657
|
+
if (!this._proxyResetDone) {
|
|
9658
|
+
this._proxyResetDone = true;
|
|
9659
|
+
this._proxyPreviewHistory = [];
|
|
9660
|
+
this._proxyLogLines = [];
|
|
9661
|
+
this._proxyLogSince = '';
|
|
9662
|
+
this._proxyPreviewSince = '';
|
|
9663
|
+
try { await this._proxyHttpPost('/reset'); } catch {}
|
|
9664
|
+
}
|
|
9665
|
+
const [stats, preview, config, logData, sysPrompt, customSysPrompt] = await Promise.all([
|
|
9473
9666
|
this._proxyHttpGet('/stats'),
|
|
9474
|
-
this._proxyHttpGet(
|
|
9667
|
+
this._proxyHttpGet(`/preview?since=${this._proxyPreviewSince || ''}`),
|
|
9475
9668
|
this._proxyHttpGet('/config'),
|
|
9476
9669
|
this._proxyHttpGet(`/log?limit=50&since=${this._proxyLogSince || ''}`),
|
|
9477
9670
|
this._proxyHttpGet('/system-prompt'),
|
|
9671
|
+
this._proxyHttpGet('/custom-system-prompt'),
|
|
9478
9672
|
]);
|
|
9479
9673
|
this._proxyStats = stats;
|
|
9674
|
+
// Accumulate preview history instead of replacing each cycle
|
|
9675
|
+
if (preview?.history?.length) {
|
|
9676
|
+
for (const entry of preview.history) {
|
|
9677
|
+
if (!this._proxyPreviewSince || entry.timestamp > this._proxyPreviewSince) {
|
|
9678
|
+
this._proxyPreviewHistory.push(entry);
|
|
9679
|
+
}
|
|
9680
|
+
}
|
|
9681
|
+
if (this._proxyPreviewHistory.length > 20) {
|
|
9682
|
+
this._proxyPreviewHistory = this._proxyPreviewHistory.slice(-20);
|
|
9683
|
+
}
|
|
9684
|
+
this._proxyPreviewSince = preview.history[preview.history.length - 1].timestamp;
|
|
9685
|
+
}
|
|
9480
9686
|
this._proxyPreview = preview?.preview || null;
|
|
9481
9687
|
this._proxyConfig = config;
|
|
9482
9688
|
this._proxySystemPrompt = sysPrompt;
|
|
9483
|
-
|
|
9689
|
+
this._proxyCustomSysPrompt = customSysPrompt;
|
|
9484
9690
|
if (logData?.entries?.length) {
|
|
9485
9691
|
for (const entry of logData.entries) {
|
|
9486
9692
|
this._proxyLogLines.push(entry);
|
|
9487
9693
|
}
|
|
9488
|
-
// Keep last 200 lines
|
|
9489
9694
|
if (this._proxyLogLines.length > 200) {
|
|
9490
9695
|
this._proxyLogLines = this._proxyLogLines.slice(-200);
|
|
9491
9696
|
}
|
|
@@ -9493,13 +9698,68 @@ class ConsoleTUI {
|
|
|
9493
9698
|
}
|
|
9494
9699
|
this._renderProxyTab();
|
|
9495
9700
|
} catch (e) {
|
|
9496
|
-
this._debug(`
|
|
9701
|
+
this._debug(`Proxy poll error: ${e.message}`);
|
|
9497
9702
|
}
|
|
9498
9703
|
};
|
|
9499
9704
|
poll();
|
|
9500
9705
|
this._proxyPollTimer = setInterval(poll, 2000);
|
|
9501
9706
|
}
|
|
9502
9707
|
|
|
9708
|
+
async _spawnProxyDaemon() {
|
|
9709
|
+
try {
|
|
9710
|
+
const { spawn } = require('child_process');
|
|
9711
|
+
const fs = require('fs');
|
|
9712
|
+
const path = require('path');
|
|
9713
|
+
// Resolve daemon path relative to this file (bin/ → ../dist/mcp/)
|
|
9714
|
+
// Works whether running from /specmem/bin/ (dev) or /usr/lib/node_modules/.../bin/ (installed)
|
|
9715
|
+
let daemonPath = path.resolve(__dirname, '..', 'dist', 'mcp', 'compactionProxyDaemon.js');
|
|
9716
|
+
if (!fs.existsSync(daemonPath)) {
|
|
9717
|
+
// Fallback: try resolving via the real path of this script
|
|
9718
|
+
try {
|
|
9719
|
+
const realBin = fs.realpathSync(__filename);
|
|
9720
|
+
daemonPath = path.resolve(path.dirname(realBin), '..', 'dist', 'mcp', 'compactionProxyDaemon.js');
|
|
9721
|
+
} catch {}
|
|
9722
|
+
}
|
|
9723
|
+
if (!fs.existsSync(daemonPath)) {
|
|
9724
|
+
throw new Error(`Daemon not found at ${daemonPath}`);
|
|
9725
|
+
}
|
|
9726
|
+
this._debug(`Spawning proxy daemon: ${daemonPath}`);
|
|
9727
|
+
const child = spawn(process.execPath, [daemonPath], {
|
|
9728
|
+
detached: true,
|
|
9729
|
+
stdio: 'ignore',
|
|
9730
|
+
env: { ...process.env, SPECMEM_DAEMON: '1' }
|
|
9731
|
+
});
|
|
9732
|
+
child.unref();
|
|
9733
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
9734
|
+
this._proxyPort = null; // force re-read port file
|
|
9735
|
+
this._debug('Proxy daemon spawned, waiting for port file...');
|
|
9736
|
+
} catch (e) {
|
|
9737
|
+
this._debug(`Proxy spawn failed: ${e.message}`);
|
|
9738
|
+
}
|
|
9739
|
+
}
|
|
9740
|
+
|
|
9741
|
+
async _killProxyDaemon() {
|
|
9742
|
+
try {
|
|
9743
|
+
const pidFile = require('path').join(require('os').homedir(), '.claude', '.compaction-proxy.pid');
|
|
9744
|
+
const portFile = require('path').join(require('os').homedir(), '.claude', '.compaction-proxy-port');
|
|
9745
|
+
try {
|
|
9746
|
+
const pid = parseInt(require('fs').readFileSync(pidFile, 'utf8').trim(), 10);
|
|
9747
|
+
if (pid > 0) {
|
|
9748
|
+
try { process.kill(pid, 'SIGTERM'); } catch {}
|
|
9749
|
+
await new Promise(r => setTimeout(r, 500));
|
|
9750
|
+
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
9751
|
+
}
|
|
9752
|
+
} catch {}
|
|
9753
|
+
try { require('fs').unlinkSync(pidFile); } catch {}
|
|
9754
|
+
try { require('fs').unlinkSync(portFile); } catch {}
|
|
9755
|
+
this._proxyPort = null;
|
|
9756
|
+
this._proxySpawnAttempted = false;
|
|
9757
|
+
this._debug('Proxy daemon killed.');
|
|
9758
|
+
} catch (e) {
|
|
9759
|
+
this._debug(`Proxy kill failed: ${e.message}`);
|
|
9760
|
+
}
|
|
9761
|
+
}
|
|
9762
|
+
|
|
9503
9763
|
_renderProxyTab() {
|
|
9504
9764
|
const w = this._widgets;
|
|
9505
9765
|
if (!w.proxyBox || w.proxyBox.hidden) return;
|
|
@@ -9600,6 +9860,12 @@ class ConsoleTUI {
|
|
|
9600
9860
|
ctrl += _stat('Size', `~${_fmtNum(sp.estTokens)} tok`) + '\n';
|
|
9601
9861
|
ctrl += _stat('Model', sp.model || '?') + '\n';
|
|
9602
9862
|
ctrl += _stat('Messages', `${sp.messageCount || 0}`) + '\n';
|
|
9863
|
+
// Custom prompt indicator + edit hint
|
|
9864
|
+
if (this._proxyCustomSysPrompt?.hasCustom) {
|
|
9865
|
+
ctrl += ` {#e056a0-fg}★ Custom Active{/#e056a0-fg} {#6c6c80-fg}(P=edit){/#6c6c80-fg}\n`;
|
|
9866
|
+
} else {
|
|
9867
|
+
ctrl += ` {#6c6c80-fg}(P) Customize Sys Prompt{/#6c6c80-fg}\n`;
|
|
9868
|
+
}
|
|
9603
9869
|
}
|
|
9604
9870
|
|
|
9605
9871
|
if (w.proxyControls) w.proxyControls.setContent(ctrl);
|
|
@@ -9638,58 +9904,619 @@ class ConsoleTUI {
|
|
|
9638
9904
|
if (w.proxyStats) w.proxyStats.setContent(st);
|
|
9639
9905
|
|
|
9640
9906
|
// ═══════════════════════════════════════════════════════════════
|
|
9641
|
-
// TOP-RIGHT: Raw IN text (what Claude sends)
|
|
9907
|
+
// TOP-RIGHT: Raw IN text — accumulated history (what Claude sends)
|
|
9642
9908
|
// ═══════════════════════════════════════════════════════════════
|
|
9643
9909
|
let inText = '';
|
|
9644
|
-
|
|
9645
|
-
|
|
9646
|
-
|
|
9647
|
-
|
|
9648
|
-
|
|
9910
|
+
const previewHistory = this._proxyPreviewHistory;
|
|
9911
|
+
if (previewHistory.length > 0) {
|
|
9912
|
+
for (const entry of previewHistory) {
|
|
9913
|
+
if (!entry?.original) continue;
|
|
9914
|
+
const ts = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '';
|
|
9915
|
+
const saved = entry.savings ? ` {#42d77d-fg}-${_fmtNum(entry.savings)} chars{/#42d77d-fg}` : '';
|
|
9916
|
+
inText += `{#5a5a80-fg}── ${ts}${saved} ──{/#5a5a80-fg}\n`;
|
|
9917
|
+
// Truncate each entry to keep panel readable (first 2000 chars)
|
|
9918
|
+
const rawContent = (entry.original || '').replace(/\{[^}]*\}/g, '');
|
|
9919
|
+
inText += rawContent.slice(0, 2000) + (rawContent.length > 2000 ? '\n{#4a4a60-fg}... truncated{/#4a4a60-fg}' : '') + '\n\n';
|
|
9920
|
+
}
|
|
9649
9921
|
} else {
|
|
9650
9922
|
inText += '{#4a4a60-fg}waiting for traffic...{/#4a4a60-fg}';
|
|
9651
9923
|
}
|
|
9652
9924
|
|
|
9925
|
+
// Smart scroll: only update content when changed, only auto-scroll if user hasn't scrolled up
|
|
9653
9926
|
if (w.proxyIn) {
|
|
9654
|
-
|
|
9655
|
-
|
|
9927
|
+
const inHash = crypto.createHash('md5').update(inText).digest('hex').slice(0, 12);
|
|
9928
|
+
if (inHash !== this._proxyLastInHash) {
|
|
9929
|
+
this._proxyLastInHash = inHash;
|
|
9930
|
+
w.proxyIn.setContent(inText);
|
|
9931
|
+
if (!this._proxyUserScrolledIn) {
|
|
9932
|
+
w.proxyIn.setScrollPerc(100);
|
|
9933
|
+
}
|
|
9934
|
+
this._proxyUserScrolledIn = false; // reset for next new-content cycle
|
|
9935
|
+
}
|
|
9656
9936
|
}
|
|
9657
9937
|
|
|
9658
9938
|
// ═══════════════════════════════════════════════════════════════
|
|
9659
|
-
// BOTTOM-RIGHT:
|
|
9939
|
+
// BOTTOM-RIGHT: Compacted OUT — accumulated history
|
|
9660
9940
|
// ═══════════════════════════════════════════════════════════════
|
|
9661
9941
|
let outText = '';
|
|
9662
|
-
if (
|
|
9663
|
-
|
|
9664
|
-
|
|
9665
|
-
|
|
9666
|
-
|
|
9667
|
-
|
|
9668
|
-
|
|
9669
|
-
|
|
9670
|
-
|
|
9671
|
-
|
|
9672
|
-
|
|
9673
|
-
|
|
9674
|
-
|
|
9675
|
-
|
|
9676
|
-
|
|
9677
|
-
|
|
9678
|
-
|
|
9679
|
-
|
|
9680
|
-
|
|
9942
|
+
if (previewHistory.length > 0) {
|
|
9943
|
+
for (const entry of previewHistory) {
|
|
9944
|
+
const ts = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '';
|
|
9945
|
+
const saved = entry.savings ? ` {#42d77d-fg}-${_fmtNum(entry.savings)} chars{/#42d77d-fg}` : '';
|
|
9946
|
+
// Show translation samples if available
|
|
9947
|
+
if (entry.samples && entry.samples.length > 0) {
|
|
9948
|
+
outText += `{#5a5a80-fg}── ${ts}${saved} {#a55eea-fg}${entry.samples.length} blocks{/#a55eea-fg} ──{/#5a5a80-fg}\n`;
|
|
9949
|
+
for (const s of entry.samples.slice(0, 4)) {
|
|
9950
|
+
const before = (s.before || '').slice(0, 60).replace(/\{[^}]*\}/g, '');
|
|
9951
|
+
const after = (s.after || '').slice(0, 60).replace(/\{[^}]*\}/g, '');
|
|
9952
|
+
outText += `{#ff4757-fg}EN:{/#ff4757-fg} ${before}${s.before?.length > 60 ? '…' : ''}\n`;
|
|
9953
|
+
outText += `{#42d77d-fg}→ {/#42d77d-fg} ${after}${s.after?.length > 60 ? '…' : ''}\n`;
|
|
9954
|
+
}
|
|
9955
|
+
if (entry.samples.length > 4) outText += `{#4a4a60-fg} +${entry.samples.length - 4} more{/#4a4a60-fg}\n`;
|
|
9956
|
+
} else if (entry.optimized) {
|
|
9957
|
+
outText += `{#5a5a80-fg}── ${ts}${saved} ──{/#5a5a80-fg}\n`;
|
|
9958
|
+
const optContent = (entry.optimized || '').replace(/\{[^}]*\}/g, '');
|
|
9959
|
+
outText += optContent.slice(0, 2000) + (optContent.length > 2000 ? '\n{#4a4a60-fg}... truncated{/#4a4a60-fg}' : '') + '\n';
|
|
9960
|
+
}
|
|
9961
|
+
outText += '\n';
|
|
9962
|
+
}
|
|
9681
9963
|
} else {
|
|
9682
9964
|
outText += '{#4a4a60-fg}waiting for traffic...{/#4a4a60-fg}';
|
|
9683
9965
|
}
|
|
9684
9966
|
|
|
9967
|
+
// Smart scroll: only update content when changed, only auto-scroll if user hasn't scrolled up
|
|
9685
9968
|
if (w.proxyOut) {
|
|
9686
|
-
|
|
9687
|
-
|
|
9969
|
+
const outHash = crypto.createHash('md5').update(outText).digest('hex').slice(0, 12);
|
|
9970
|
+
if (outHash !== this._proxyLastOutHash) {
|
|
9971
|
+
this._proxyLastOutHash = outHash;
|
|
9972
|
+
w.proxyOut.setContent(outText);
|
|
9973
|
+
if (!this._proxyUserScrolledOut) {
|
|
9974
|
+
w.proxyOut.setScrollPerc(100);
|
|
9975
|
+
}
|
|
9976
|
+
this._proxyUserScrolledOut = false;
|
|
9977
|
+
}
|
|
9688
9978
|
}
|
|
9689
9979
|
|
|
9690
9980
|
this._safeRender();
|
|
9691
9981
|
}
|
|
9692
9982
|
|
|
9983
|
+
async _showCustomSysPromptModal() {
|
|
9984
|
+
this._editorActive = true;
|
|
9985
|
+
const blessed = this._blessed;
|
|
9986
|
+
const screen = this._screen;
|
|
9987
|
+
if (!blessed || !screen) return;
|
|
9988
|
+
|
|
9989
|
+
// Fetch current state from proxy
|
|
9990
|
+
const data = await this._proxyHttpGet('/custom-system-prompt');
|
|
9991
|
+
if (!data) {
|
|
9992
|
+
this._updateStatusBar('{red-fg}✗ Cannot reach proxy{/red-fg}');
|
|
9993
|
+
return;
|
|
9994
|
+
}
|
|
9995
|
+
|
|
9996
|
+
const initialText = data.customPrompt || data.ogPrompt || '';
|
|
9997
|
+
if (!initialText) {
|
|
9998
|
+
this._updateStatusBar('{yellow-fg}No system prompt captured yet — send a request first{/yellow-fg}');
|
|
9999
|
+
return;
|
|
10000
|
+
}
|
|
10001
|
+
|
|
10002
|
+
const ogHash = data.ogHash;
|
|
10003
|
+
const self = this;
|
|
10004
|
+
|
|
10005
|
+
// ── Editor State ──
|
|
10006
|
+
const E = {
|
|
10007
|
+
lines: initialText.split('\n'),
|
|
10008
|
+
row: 0, col: 0, // cursor position
|
|
10009
|
+
scrollY: 0,
|
|
10010
|
+
selAnchor: null, // { row, col } — where selection started
|
|
10011
|
+
selEnd: null, // { row, col } — where selection ends
|
|
10012
|
+
clipboard: '',
|
|
10013
|
+
lastClickTime: 0,
|
|
10014
|
+
clickCount: 0,
|
|
10015
|
+
dirty: false,
|
|
10016
|
+
dragging: false, // mouse drag in progress
|
|
10017
|
+
};
|
|
10018
|
+
|
|
10019
|
+
// ── System clipboard helpers ──
|
|
10020
|
+
const { execSync: _execSync } = require('child_process');
|
|
10021
|
+
const _clipEnv = { ...process.env, DISPLAY: process.env.DISPLAY || ':1' };
|
|
10022
|
+
function _copyToSystemClipboard(text) {
|
|
10023
|
+
// OSC 52 — works in some terminals (iTerm2, kitty, alacritty)
|
|
10024
|
+
const b64 = Buffer.from(text).toString('base64');
|
|
10025
|
+
process.stdout.write(`\x1b]52;c;${b64}\x07`);
|
|
10026
|
+
// xclip/xsel with DISPLAY for VNC environments
|
|
10027
|
+
try {
|
|
10028
|
+
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();
|
|
10029
|
+
if (tool === 'xclip') _execSync('xclip -selection clipboard', { input: text, timeout: 2000, env: _clipEnv });
|
|
10030
|
+
else if (tool === 'xsel') _execSync('xsel --clipboard --input', { input: text, timeout: 2000, env: _clipEnv });
|
|
10031
|
+
} catch {}
|
|
10032
|
+
}
|
|
10033
|
+
function _readSystemClipboard() {
|
|
10034
|
+
try {
|
|
10035
|
+
return _execSync('xclip -selection clipboard -o 2>/dev/null || xsel --clipboard --output 2>/dev/null', { encoding: 'utf8', timeout: 2000, env: _clipEnv });
|
|
10036
|
+
} catch { return null; }
|
|
10037
|
+
}
|
|
10038
|
+
|
|
10039
|
+
// ── Helpers ──
|
|
10040
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
10041
|
+
// Ensure E.col never exceeds current line length
|
|
10042
|
+
function clampCursor() {
|
|
10043
|
+
E.row = clamp(E.row, 0, E.lines.length - 1);
|
|
10044
|
+
E.col = clamp(E.col, 0, E.lines[E.row].length);
|
|
10045
|
+
}
|
|
10046
|
+
|
|
10047
|
+
function getText() {
|
|
10048
|
+
return E.lines.join('\n');
|
|
10049
|
+
}
|
|
10050
|
+
|
|
10051
|
+
function hasSelection() {
|
|
10052
|
+
return E.selAnchor !== null && E.selEnd !== null &&
|
|
10053
|
+
(E.selAnchor.row !== E.selEnd.row || E.selAnchor.col !== E.selEnd.col);
|
|
10054
|
+
}
|
|
10055
|
+
|
|
10056
|
+
// Normalized selection: start <= end
|
|
10057
|
+
function getSelRange() {
|
|
10058
|
+
if (!hasSelection()) return null;
|
|
10059
|
+
const a = E.selAnchor, b = E.selEnd;
|
|
10060
|
+
const before = (a.row < b.row) || (a.row === b.row && a.col <= b.col);
|
|
10061
|
+
return before ? { start: a, end: b } : { start: b, end: a };
|
|
10062
|
+
}
|
|
10063
|
+
|
|
10064
|
+
function clearSel() { E.selAnchor = null; E.selEnd = null; }
|
|
10065
|
+
|
|
10066
|
+
function getSelectedText() {
|
|
10067
|
+
const r = getSelRange();
|
|
10068
|
+
if (!r) return '';
|
|
10069
|
+
if (r.start.row === r.end.row) {
|
|
10070
|
+
return E.lines[r.start.row].slice(r.start.col, r.end.col);
|
|
10071
|
+
}
|
|
10072
|
+
let t = E.lines[r.start.row].slice(r.start.col) + '\n';
|
|
10073
|
+
for (let i = r.start.row + 1; i < r.end.row; i++) t += E.lines[i] + '\n';
|
|
10074
|
+
t += E.lines[r.end.row].slice(0, r.end.col);
|
|
10075
|
+
return t;
|
|
10076
|
+
}
|
|
10077
|
+
|
|
10078
|
+
function deleteSelection() {
|
|
10079
|
+
const r = getSelRange();
|
|
10080
|
+
if (!r) return;
|
|
10081
|
+
const before = E.lines[r.start.row].slice(0, r.start.col);
|
|
10082
|
+
const after = E.lines[r.end.row].slice(r.end.col);
|
|
10083
|
+
E.lines.splice(r.start.row, r.end.row - r.start.row + 1, before + after);
|
|
10084
|
+
E.row = r.start.row; E.col = r.start.col;
|
|
10085
|
+
clearSel();
|
|
10086
|
+
E.dirty = true;
|
|
10087
|
+
}
|
|
10088
|
+
|
|
10089
|
+
function insertTextAt(text) {
|
|
10090
|
+
if (hasSelection()) deleteSelection();
|
|
10091
|
+
const before = E.lines[E.row].slice(0, E.col);
|
|
10092
|
+
const after = E.lines[E.row].slice(E.col);
|
|
10093
|
+
const insertLines = text.split('\n');
|
|
10094
|
+
if (insertLines.length === 1) {
|
|
10095
|
+
E.lines[E.row] = before + insertLines[0] + after;
|
|
10096
|
+
E.col += insertLines[0].length;
|
|
10097
|
+
} else {
|
|
10098
|
+
E.lines[E.row] = before + insertLines[0];
|
|
10099
|
+
for (let i = 1; i < insertLines.length - 1; i++) {
|
|
10100
|
+
E.lines.splice(E.row + i, 0, insertLines[i]);
|
|
10101
|
+
}
|
|
10102
|
+
const lastLine = insertLines[insertLines.length - 1];
|
|
10103
|
+
E.lines.splice(E.row + insertLines.length - 1, 0, lastLine + after);
|
|
10104
|
+
E.row += insertLines.length - 1;
|
|
10105
|
+
E.col = lastLine.length;
|
|
10106
|
+
}
|
|
10107
|
+
E.dirty = true;
|
|
10108
|
+
}
|
|
10109
|
+
|
|
10110
|
+
function isInSel(row, col) {
|
|
10111
|
+
const r = getSelRange();
|
|
10112
|
+
if (!r) return false;
|
|
10113
|
+
if (row < r.start.row || row > r.end.row) return false;
|
|
10114
|
+
if (row === r.start.row && col < r.start.col) return false;
|
|
10115
|
+
if (row === r.end.row && col >= r.end.col) return false;
|
|
10116
|
+
if (row === r.start.row && row === r.end.row) return col >= r.start.col && col < r.end.col;
|
|
10117
|
+
if (row === r.start.row) return col >= r.start.col;
|
|
10118
|
+
if (row === r.end.row) return col < r.end.col;
|
|
10119
|
+
return true;
|
|
10120
|
+
}
|
|
10121
|
+
|
|
10122
|
+
function selectWord(row, col) {
|
|
10123
|
+
const line = E.lines[row] || '';
|
|
10124
|
+
if (col >= line.length) { E.selAnchor = { row, col: 0 }; E.selEnd = { row, col: line.length }; return; }
|
|
10125
|
+
let start = col, end = col;
|
|
10126
|
+
const isWordChar = c => /\w/.test(c);
|
|
10127
|
+
const charType = isWordChar(line[col]);
|
|
10128
|
+
while (start > 0 && isWordChar(line[start - 1]) === charType) start--;
|
|
10129
|
+
while (end < line.length && isWordChar(line[end]) === charType) end++;
|
|
10130
|
+
E.selAnchor = { row, col: start };
|
|
10131
|
+
E.selEnd = { row, col: end };
|
|
10132
|
+
E.col = end;
|
|
10133
|
+
}
|
|
10134
|
+
|
|
10135
|
+
function selectLine(row) {
|
|
10136
|
+
E.selAnchor = { row, col: 0 };
|
|
10137
|
+
E.selEnd = { row, col: E.lines[row].length };
|
|
10138
|
+
E.col = E.lines[row].length;
|
|
10139
|
+
}
|
|
10140
|
+
|
|
10141
|
+
// ── Escape blessed tags in content ──
|
|
10142
|
+
function esc(ch) {
|
|
10143
|
+
if (ch === '{') return '{open}';
|
|
10144
|
+
if (ch === '}') return '{close}';
|
|
10145
|
+
return ch;
|
|
10146
|
+
}
|
|
10147
|
+
|
|
10148
|
+
function escStr(s) {
|
|
10149
|
+
return s.replace(/[{}]/g, c => c === '{' ? '{open}' : '{close}');
|
|
10150
|
+
}
|
|
10151
|
+
|
|
10152
|
+
// ── Render ──
|
|
10153
|
+
// Build visual rows from logical lines with soft word wrap.
|
|
10154
|
+
// Returns array of { logRow, colStart, text } — one entry per visual row.
|
|
10155
|
+
function buildVisualRows(visW) {
|
|
10156
|
+
const vRows = [];
|
|
10157
|
+
for (let i = 0; i < E.lines.length; i++) {
|
|
10158
|
+
const line = E.lines[i];
|
|
10159
|
+
if (line.length === 0) {
|
|
10160
|
+
vRows.push({ logRow: i, colStart: 0, text: '' });
|
|
10161
|
+
} else {
|
|
10162
|
+
for (let off = 0; off < line.length; off += visW) {
|
|
10163
|
+
vRows.push({ logRow: i, colStart: off, text: line.slice(off, off + visW) });
|
|
10164
|
+
}
|
|
10165
|
+
}
|
|
10166
|
+
}
|
|
10167
|
+
return vRows;
|
|
10168
|
+
}
|
|
10169
|
+
|
|
10170
|
+
// Find visual row index where cursor lives
|
|
10171
|
+
function cursorVisRow(vRows) {
|
|
10172
|
+
for (let v = 0; v < vRows.length; v++) {
|
|
10173
|
+
const vr = vRows[v];
|
|
10174
|
+
if (vr.logRow === E.row && E.col >= vr.colStart && E.col < vr.colStart + Math.max(vr.text.length, 1)) return v;
|
|
10175
|
+
// Cursor at end-of-line on last wrap segment
|
|
10176
|
+
if (vr.logRow === E.row && E.col === vr.colStart + vr.text.length) {
|
|
10177
|
+
const next = vRows[v + 1];
|
|
10178
|
+
if (!next || next.logRow !== E.row) return v; // last segment of this line
|
|
10179
|
+
}
|
|
10180
|
+
}
|
|
10181
|
+
return vRows.length - 1;
|
|
10182
|
+
}
|
|
10183
|
+
|
|
10184
|
+
function render() {
|
|
10185
|
+
clampCursor();
|
|
10186
|
+
const visH = editorBox.height - 2;
|
|
10187
|
+
const visW = editorBox.width - 2;
|
|
10188
|
+
if (visW < 2) return;
|
|
10189
|
+
|
|
10190
|
+
const vRows = buildVisualRows(visW);
|
|
10191
|
+
const cVRow = cursorVisRow(vRows);
|
|
10192
|
+
|
|
10193
|
+
// Scroll to keep cursor visual row visible
|
|
10194
|
+
if (cVRow < E.scrollY) E.scrollY = cVRow;
|
|
10195
|
+
if (cVRow >= E.scrollY + visH) E.scrollY = cVRow - visH + 1;
|
|
10196
|
+
if (E.scrollY < 0) E.scrollY = 0;
|
|
10197
|
+
|
|
10198
|
+
let out = '';
|
|
10199
|
+
for (let v = E.scrollY; v < Math.min(vRows.length, E.scrollY + visH); v++) {
|
|
10200
|
+
const vr = vRows[v];
|
|
10201
|
+
let rendered = '';
|
|
10202
|
+
for (let j = 0; j < vr.text.length; j++) {
|
|
10203
|
+
const logCol = vr.colStart + j;
|
|
10204
|
+
const ch = esc(vr.text[j]);
|
|
10205
|
+
if (vr.logRow === E.row && logCol === E.col) {
|
|
10206
|
+
rendered += `{inverse}${ch}{/inverse}`;
|
|
10207
|
+
} else if (isInSel(vr.logRow, logCol)) {
|
|
10208
|
+
rendered += `{blue-bg}{white-fg}${ch}{/white-fg}{/blue-bg}`;
|
|
10209
|
+
} else {
|
|
10210
|
+
rendered += ch;
|
|
10211
|
+
}
|
|
10212
|
+
}
|
|
10213
|
+
// Cursor at end of this visual segment
|
|
10214
|
+
if (vr.logRow === E.row && E.col === vr.colStart + vr.text.length) {
|
|
10215
|
+
const next = vRows[v + 1];
|
|
10216
|
+
if (!next || next.logRow !== E.row) {
|
|
10217
|
+
rendered += '{inverse} {/inverse}';
|
|
10218
|
+
}
|
|
10219
|
+
}
|
|
10220
|
+
out += rendered + '\n';
|
|
10221
|
+
}
|
|
10222
|
+
|
|
10223
|
+
// Status line at bottom
|
|
10224
|
+
const charCount = getText().length;
|
|
10225
|
+
const ogLen = data.ogPrompt?.length || 0;
|
|
10226
|
+
const diff = ogLen - charCount;
|
|
10227
|
+
const diffStr = diff > 0 ? `{#42d77d-fg}-${diff.toLocaleString()}{/#42d77d-fg}` : `{#ff4757-fg}+${Math.abs(diff).toLocaleString()}{/#ff4757-fg}`;
|
|
10228
|
+
|
|
10229
|
+
header.setContent(
|
|
10230
|
+
` {#6c6c80-fg}OG:{/#6c6c80-fg} ${ogLen.toLocaleString()} `
|
|
10231
|
+
+ `{#6c6c80-fg}Now:{/#6c6c80-fg} ${charCount.toLocaleString()} (${diffStr}) `
|
|
10232
|
+
+ `{#6c6c80-fg}Ln:{/#6c6c80-fg} ${E.row + 1}/${E.lines.length} `
|
|
10233
|
+
+ `{#6c6c80-fg}Col:{/#6c6c80-fg} ${E.col + 1}`
|
|
10234
|
+
+ (E.dirty ? ' {#e056a0-fg}●modified{/#e056a0-fg}' : '') + '\n'
|
|
10235
|
+
+ ` {#42d77d-fg}Ctrl+S{/#42d77d-fg}=Save {#ff4757-fg}Ctrl+R{/#ff4757-fg}=Reset {yellow-fg}Esc{/yellow-fg}=Cancel `
|
|
10236
|
+
+ `{#6c6c80-fg}Ctrl+A{/#6c6c80-fg}=SelAll {#6c6c80-fg}Ctrl+C/X/V{/#6c6c80-fg}=Clipboard`
|
|
10237
|
+
);
|
|
10238
|
+
|
|
10239
|
+
editorBox.setContent(out);
|
|
10240
|
+
// Prevent blessed from resetting scroll position on setContent
|
|
10241
|
+
editorBox.childBase = 0;
|
|
10242
|
+
editorBox.childOffset = 0;
|
|
10243
|
+
self._safeRender();
|
|
10244
|
+
}
|
|
10245
|
+
|
|
10246
|
+
// ── Create UI ──
|
|
10247
|
+
const overlay = blessed.box({
|
|
10248
|
+
parent: screen,
|
|
10249
|
+
top: 'center', left: 'center',
|
|
10250
|
+
width: '90%', height: '90%',
|
|
10251
|
+
grabKeys: true, // prevent key leaking to elements behind overlay
|
|
10252
|
+
border: { type: 'line' },
|
|
10253
|
+
style: { fg: '#c0c0d0', bg: '#0a0a15', border: { fg: '#e056a0' }, label: { fg: '#e056a0' } },
|
|
10254
|
+
tags: true,
|
|
10255
|
+
label: data.hasCustom ? ' ★ Edit Custom System Prompt ' : ' Customize System Prompt ',
|
|
10256
|
+
});
|
|
10257
|
+
|
|
10258
|
+
const header = blessed.box({
|
|
10259
|
+
parent: overlay,
|
|
10260
|
+
top: 0, left: 1, right: 1, height: 3,
|
|
10261
|
+
style: { fg: '#a0a0b0', bg: '#0a0a15' },
|
|
10262
|
+
tags: true,
|
|
10263
|
+
});
|
|
10264
|
+
|
|
10265
|
+
const editorBox = blessed.box({
|
|
10266
|
+
parent: overlay,
|
|
10267
|
+
top: 3, left: 1, right: 1, bottom: 0,
|
|
10268
|
+
border: { type: 'line' },
|
|
10269
|
+
style: { fg: '#c0c0d0', bg: '#0f0f1a', border: { fg: '#3a3a50' } },
|
|
10270
|
+
scrollable: false, // we handle scrolling manually
|
|
10271
|
+
mouse: true,
|
|
10272
|
+
keys: true,
|
|
10273
|
+
tags: true,
|
|
10274
|
+
keyable: true,
|
|
10275
|
+
});
|
|
10276
|
+
|
|
10277
|
+
let _destroyed = false;
|
|
10278
|
+
// Block named key events (screen.key() bindings like 'key q', 'key tab') from reaching
|
|
10279
|
+
// dashboard while editor is open. We keep 'keypress' flowing so editorBox still gets input.
|
|
10280
|
+
const _origEmit = screen.emit.bind(screen);
|
|
10281
|
+
screen.emit = function(event, ...args) {
|
|
10282
|
+
if (typeof event === 'string' && event.startsWith('key ') && event !== 'keypress') return false;
|
|
10283
|
+
return _origEmit(event, ...args);
|
|
10284
|
+
};
|
|
10285
|
+
|
|
10286
|
+
const cleanup = () => {
|
|
10287
|
+
if (_destroyed) return;
|
|
10288
|
+
_destroyed = true;
|
|
10289
|
+
screen.emit = _origEmit; // restore original emit so dashboard keys work again
|
|
10290
|
+
editorBox.removeAllListeners();
|
|
10291
|
+
overlay.removeAllListeners();
|
|
10292
|
+
overlay.detach();
|
|
10293
|
+
self._editorActive = false;
|
|
10294
|
+
self._safeRender();
|
|
10295
|
+
process.nextTick(() => overlay.destroy());
|
|
10296
|
+
};
|
|
10297
|
+
|
|
10298
|
+
// ── Mouse Handling (click, drag-select) ──
|
|
10299
|
+
function mouseToPos(mouse) {
|
|
10300
|
+
const localY = mouse.y - (editorBox.atop || 0) - 1;
|
|
10301
|
+
const localX = mouse.x - (editorBox.aleft || 0) - 1;
|
|
10302
|
+
const visW = editorBox.width - 2;
|
|
10303
|
+
const vRows = buildVisualRows(visW);
|
|
10304
|
+
const vIdx = clamp(localY + E.scrollY, 0, vRows.length - 1);
|
|
10305
|
+
const vr = vRows[vIdx];
|
|
10306
|
+
const col = clamp(vr.colStart + localX, 0, E.lines[vr.logRow].length);
|
|
10307
|
+
return { row: vr.logRow, col };
|
|
10308
|
+
}
|
|
10309
|
+
|
|
10310
|
+
editorBox.on('mousedown', (mouse) => {
|
|
10311
|
+
const { row, col } = mouseToPos(mouse);
|
|
10312
|
+
const now = Date.now();
|
|
10313
|
+
if (now - E.lastClickTime < 350 && E.clickCount < 3) {
|
|
10314
|
+
E.clickCount++;
|
|
10315
|
+
} else {
|
|
10316
|
+
E.clickCount = 1;
|
|
10317
|
+
}
|
|
10318
|
+
E.lastClickTime = now;
|
|
10319
|
+
|
|
10320
|
+
if (E.clickCount === 3) {
|
|
10321
|
+
selectLine(row);
|
|
10322
|
+
E.dragging = false;
|
|
10323
|
+
} else if (E.clickCount === 2) {
|
|
10324
|
+
selectWord(row, col);
|
|
10325
|
+
E.dragging = false;
|
|
10326
|
+
} else {
|
|
10327
|
+
E.row = row; E.col = col;
|
|
10328
|
+
clearSel();
|
|
10329
|
+
E.dragging = true;
|
|
10330
|
+
}
|
|
10331
|
+
render();
|
|
10332
|
+
});
|
|
10333
|
+
|
|
10334
|
+
editorBox.on('mousemove', (mouse) => {
|
|
10335
|
+
if (!E.dragging) return;
|
|
10336
|
+
const { row, col } = mouseToPos(mouse);
|
|
10337
|
+
// Start selection from cursor if not already started
|
|
10338
|
+
if (!hasSelection()) {
|
|
10339
|
+
E.selAnchor = { row: E.row, col: E.col };
|
|
10340
|
+
}
|
|
10341
|
+
E.selEnd = { row, col };
|
|
10342
|
+
E.row = row; E.col = col;
|
|
10343
|
+
render();
|
|
10344
|
+
});
|
|
10345
|
+
|
|
10346
|
+
editorBox.on('mouseup', () => {
|
|
10347
|
+
E.dragging = false;
|
|
10348
|
+
});
|
|
10349
|
+
|
|
10350
|
+
// Scroll wheel
|
|
10351
|
+
editorBox.on('wheeldown', () => { E.scrollY = Math.min(E.scrollY + 3, Math.max(0, E.lines.length - 5)); render(); });
|
|
10352
|
+
editorBox.on('wheelup', () => { E.scrollY = Math.max(0, E.scrollY - 3); render(); });
|
|
10353
|
+
|
|
10354
|
+
// ── Keyboard Handling ──
|
|
10355
|
+
editorBox.on('keypress', (ch, key) => {
|
|
10356
|
+
if (!key) return;
|
|
10357
|
+
const ctrl = key.ctrl;
|
|
10358
|
+
const shift = key.shift;
|
|
10359
|
+
const name = key.name;
|
|
10360
|
+
|
|
10361
|
+
// ── Ctrl combos ──
|
|
10362
|
+
if (ctrl && name === 's') {
|
|
10363
|
+
const text = getText();
|
|
10364
|
+
if (!text.trim()) { self._updateStatusBar('{red-fg}Cannot save empty prompt{/red-fg}'); return; }
|
|
10365
|
+
self._proxyHttpPost('/custom-system-prompt', { prompt: text, ogHash }).then((res) => {
|
|
10366
|
+
if (res?.ok) self._updateStatusBar(`{#42d77d-fg}✓ Custom prompt saved (${text.length} chars){/#42d77d-fg}`);
|
|
10367
|
+
else self._updateStatusBar(`{red-fg}✗ Save failed: ${res?.error || 'unknown'}{/red-fg}`);
|
|
10368
|
+
cleanup();
|
|
10369
|
+
});
|
|
10370
|
+
return;
|
|
10371
|
+
}
|
|
10372
|
+
if (ctrl && name === 'r') {
|
|
10373
|
+
self._proxyHttpPost('/custom-system-prompt', { reset: true }).then((res) => {
|
|
10374
|
+
if (res?.ok) self._updateStatusBar('{#42d77d-fg}✓ System prompt reset to original{/#42d77d-fg}');
|
|
10375
|
+
cleanup();
|
|
10376
|
+
});
|
|
10377
|
+
return;
|
|
10378
|
+
}
|
|
10379
|
+
if (ctrl && name === 'a') { // Select All
|
|
10380
|
+
E.selAnchor = { row: 0, col: 0 };
|
|
10381
|
+
E.selEnd = { row: E.lines.length - 1, col: E.lines[E.lines.length - 1].length };
|
|
10382
|
+
E.row = E.selEnd.row; E.col = E.selEnd.col;
|
|
10383
|
+
render(); return;
|
|
10384
|
+
}
|
|
10385
|
+
if (ctrl && name === 'c') { // Copy
|
|
10386
|
+
if (hasSelection()) {
|
|
10387
|
+
E.clipboard = getSelectedText();
|
|
10388
|
+
_copyToSystemClipboard(E.clipboard);
|
|
10389
|
+
}
|
|
10390
|
+
render(); return;
|
|
10391
|
+
}
|
|
10392
|
+
if (ctrl && name === 'x') { // Cut
|
|
10393
|
+
if (hasSelection()) {
|
|
10394
|
+
E.clipboard = getSelectedText();
|
|
10395
|
+
_copyToSystemClipboard(E.clipboard);
|
|
10396
|
+
deleteSelection();
|
|
10397
|
+
}
|
|
10398
|
+
render(); return;
|
|
10399
|
+
}
|
|
10400
|
+
if (ctrl && name === 'v') { // Paste
|
|
10401
|
+
// Try system clipboard first, fallback to internal
|
|
10402
|
+
const sysCb = _readSystemClipboard();
|
|
10403
|
+
if (sysCb) { E.clipboard = sysCb; insertTextAt(sysCb); }
|
|
10404
|
+
else if (E.clipboard) insertTextAt(E.clipboard);
|
|
10405
|
+
render(); return;
|
|
10406
|
+
}
|
|
10407
|
+
|
|
10408
|
+
// ── Escape ──
|
|
10409
|
+
if (name === 'escape') {
|
|
10410
|
+
// Prevent event propagation before cleanup
|
|
10411
|
+
editorBox.removeAllListeners('keypress');
|
|
10412
|
+
cleanup();
|
|
10413
|
+
return;
|
|
10414
|
+
}
|
|
10415
|
+
|
|
10416
|
+
// ── Arrow keys (with shift-select) ──
|
|
10417
|
+
if (name === 'left' || name === 'right' || name === 'up' || name === 'down'
|
|
10418
|
+
|| name === 'home' || name === 'end' || name === 'pageup' || name === 'pagedown') {
|
|
10419
|
+
const prevRow = E.row, prevCol = E.col;
|
|
10420
|
+
|
|
10421
|
+
if (name === 'left') {
|
|
10422
|
+
if (E.col > 0) E.col--;
|
|
10423
|
+
else if (E.row > 0) { E.row--; E.col = E.lines[E.row].length; }
|
|
10424
|
+
} else if (name === 'right') {
|
|
10425
|
+
if (E.col < E.lines[E.row].length) E.col++;
|
|
10426
|
+
else if (E.row < E.lines.length - 1) { E.row++; E.col = 0; }
|
|
10427
|
+
} else if (name === 'up') {
|
|
10428
|
+
if (E.row > 0) { E.row--; E.col = Math.min(E.col, E.lines[E.row].length); }
|
|
10429
|
+
} else if (name === 'down') {
|
|
10430
|
+
if (E.row < E.lines.length - 1) { E.row++; E.col = Math.min(E.col, E.lines[E.row].length); }
|
|
10431
|
+
} else if (name === 'home') {
|
|
10432
|
+
E.col = 0;
|
|
10433
|
+
} else if (name === 'end') {
|
|
10434
|
+
E.col = E.lines[E.row].length;
|
|
10435
|
+
} else if (name === 'pageup') {
|
|
10436
|
+
const visH = editorBox.height - 2;
|
|
10437
|
+
E.row = Math.max(0, E.row - visH);
|
|
10438
|
+
E.col = Math.min(E.col, E.lines[E.row].length);
|
|
10439
|
+
} else if (name === 'pagedown') {
|
|
10440
|
+
const visH = editorBox.height - 2;
|
|
10441
|
+
E.row = Math.min(E.lines.length - 1, E.row + visH);
|
|
10442
|
+
E.col = Math.min(E.col, E.lines[E.row].length);
|
|
10443
|
+
}
|
|
10444
|
+
|
|
10445
|
+
if (shift) {
|
|
10446
|
+
if (!E.selAnchor) E.selAnchor = { row: prevRow, col: prevCol };
|
|
10447
|
+
E.selEnd = { row: E.row, col: E.col };
|
|
10448
|
+
} else {
|
|
10449
|
+
clearSel();
|
|
10450
|
+
}
|
|
10451
|
+
render(); return;
|
|
10452
|
+
}
|
|
10453
|
+
|
|
10454
|
+
// ── Backspace ──
|
|
10455
|
+
if (name === 'backspace') {
|
|
10456
|
+
if (hasSelection()) { deleteSelection(); }
|
|
10457
|
+
else if (E.col > 0) {
|
|
10458
|
+
E.lines[E.row] = E.lines[E.row].slice(0, E.col - 1) + E.lines[E.row].slice(E.col);
|
|
10459
|
+
E.col--;
|
|
10460
|
+
E.dirty = true;
|
|
10461
|
+
} else if (E.row > 0) {
|
|
10462
|
+
E.col = E.lines[E.row - 1].length;
|
|
10463
|
+
E.lines[E.row - 1] += E.lines[E.row];
|
|
10464
|
+
E.lines.splice(E.row, 1);
|
|
10465
|
+
E.row--;
|
|
10466
|
+
E.dirty = true;
|
|
10467
|
+
}
|
|
10468
|
+
render(); return;
|
|
10469
|
+
}
|
|
10470
|
+
|
|
10471
|
+
// ── Delete ──
|
|
10472
|
+
if (name === 'delete') {
|
|
10473
|
+
if (hasSelection()) { deleteSelection(); }
|
|
10474
|
+
else if (E.col < E.lines[E.row].length) {
|
|
10475
|
+
E.lines[E.row] = E.lines[E.row].slice(0, E.col) + E.lines[E.row].slice(E.col + 1);
|
|
10476
|
+
E.dirty = true;
|
|
10477
|
+
} else if (E.row < E.lines.length - 1) {
|
|
10478
|
+
E.lines[E.row] += E.lines[E.row + 1];
|
|
10479
|
+
E.lines.splice(E.row + 1, 1);
|
|
10480
|
+
E.dirty = true;
|
|
10481
|
+
}
|
|
10482
|
+
render(); return;
|
|
10483
|
+
}
|
|
10484
|
+
|
|
10485
|
+
// ── Enter ──
|
|
10486
|
+
if (name === 'enter' || name === 'return') {
|
|
10487
|
+
if (hasSelection()) deleteSelection();
|
|
10488
|
+
const after = E.lines[E.row].slice(E.col);
|
|
10489
|
+
E.lines[E.row] = E.lines[E.row].slice(0, E.col);
|
|
10490
|
+
E.lines.splice(E.row + 1, 0, after);
|
|
10491
|
+
E.row++; E.col = 0;
|
|
10492
|
+
E.dirty = true;
|
|
10493
|
+
render(); return;
|
|
10494
|
+
}
|
|
10495
|
+
|
|
10496
|
+
// ── Tab ──
|
|
10497
|
+
if (name === 'tab') {
|
|
10498
|
+
if (hasSelection()) deleteSelection();
|
|
10499
|
+
insertTextAt(' ');
|
|
10500
|
+
render(); return;
|
|
10501
|
+
}
|
|
10502
|
+
|
|
10503
|
+
// ── Printable character ──
|
|
10504
|
+
if (ch && ch.length === 1 && !ctrl && !key.meta) {
|
|
10505
|
+
if (hasSelection()) deleteSelection();
|
|
10506
|
+
E.lines[E.row] = E.lines[E.row].slice(0, E.col) + ch + E.lines[E.row].slice(E.col);
|
|
10507
|
+
E.col++;
|
|
10508
|
+
E.dirty = true;
|
|
10509
|
+
render(); return;
|
|
10510
|
+
}
|
|
10511
|
+
});
|
|
10512
|
+
|
|
10513
|
+
// Prevent blessed's built-in key/scroll handling from moving content
|
|
10514
|
+
editorBox.scrollable = false;
|
|
10515
|
+
editorBox.focus();
|
|
10516
|
+
screen.on('keypress', _blockKeys);
|
|
10517
|
+
render();
|
|
10518
|
+
}
|
|
10519
|
+
|
|
9693
10520
|
_adjustProxySlider(direction) {
|
|
9694
10521
|
const def = this._proxySliderDefs[this._proxySliderIdx];
|
|
9695
10522
|
if (!def || !this._proxyConfig) return;
|