@blockrun/franklin 3.6.1 → 3.6.3
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/dist/agent/bash-guard.d.ts +17 -0
- package/dist/agent/bash-guard.js +158 -0
- package/dist/agent/compact.js +3 -3
- package/dist/agent/loop.js +9 -1
- package/dist/agent/permissions.js +41 -2
- package/dist/agent/tokens.js +1 -1
- package/dist/agent/types.d.ts +5 -0
- package/dist/mcp/client.js +36 -0
- package/dist/pricing.js +1 -1
- package/dist/tools/bash.js +56 -1
- package/dist/tools/subagent.js +16 -1
- package/dist/tools/websearch.js +25 -0
- package/dist/ui/app.js +82 -20
- package/dist/ui/model-picker.js +2 -2
- package/dist/ui/mouse.d.ts +43 -0
- package/dist/ui/mouse.js +267 -0
- package/package.json +1 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash Risk Classifier — lightweight Guardian for Franklin.
|
|
3
|
+
*
|
|
4
|
+
* Classifies bash commands into three risk levels:
|
|
5
|
+
* safe — read-only or standard dev commands → auto-approve
|
|
6
|
+
* normal — typical mutations (file writes, installs) → default ask behavior
|
|
7
|
+
* dangerous — destructive/irreversible operations → always ask, with warning
|
|
8
|
+
*
|
|
9
|
+
* Inspired by OpenAI Codex's Guardian system, but deterministic pattern matching
|
|
10
|
+
* instead of an LLM call. Fast, predictable, zero-cost.
|
|
11
|
+
*/
|
|
12
|
+
export type BashRiskLevel = 'safe' | 'normal' | 'dangerous';
|
|
13
|
+
export interface BashRiskResult {
|
|
14
|
+
level: BashRiskLevel;
|
|
15
|
+
reason?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function classifyBashRisk(command: string): BashRiskResult;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash Risk Classifier — lightweight Guardian for Franklin.
|
|
3
|
+
*
|
|
4
|
+
* Classifies bash commands into three risk levels:
|
|
5
|
+
* safe — read-only or standard dev commands → auto-approve
|
|
6
|
+
* normal — typical mutations (file writes, installs) → default ask behavior
|
|
7
|
+
* dangerous — destructive/irreversible operations → always ask, with warning
|
|
8
|
+
*
|
|
9
|
+
* Inspired by OpenAI Codex's Guardian system, but deterministic pattern matching
|
|
10
|
+
* instead of an LLM call. Fast, predictable, zero-cost.
|
|
11
|
+
*/
|
|
12
|
+
// ─── Dangerous Patterns ──────────────────────────────────────────────────
|
|
13
|
+
// Checked first. If ANY pattern matches, the command is dangerous.
|
|
14
|
+
const DANGEROUS_PATTERNS = [
|
|
15
|
+
// Destructive file operations
|
|
16
|
+
[/\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*\s+[/~]/, 'recursive delete on root/home'],
|
|
17
|
+
[/\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*f/, 'forced recursive delete'],
|
|
18
|
+
[/\brm\s+-[a-zA-Z]*f[a-zA-Z]*[rR]/, 'forced recursive delete'],
|
|
19
|
+
[/\bmkfs\b/, 'format filesystem'],
|
|
20
|
+
[/\bdd\s+.*of=/, 'raw disk write'],
|
|
21
|
+
// Git irreversible operations
|
|
22
|
+
[/\bgit\s+push\s+.*--force\b/, 'force push'],
|
|
23
|
+
[/\bgit\s+push\s+-f\b/, 'force push'],
|
|
24
|
+
[/\bgit\s+reset\s+--hard\b/, 'hard reset — discards uncommitted changes'],
|
|
25
|
+
[/\bgit\s+clean\s+-[a-zA-Z]*f/, 'git clean — deletes untracked files'],
|
|
26
|
+
[/\bgit\s+checkout\s+--\s+\./, 'discard all working changes'],
|
|
27
|
+
[/\bgit\s+branch\s+-D\b/, 'force delete branch'],
|
|
28
|
+
// Database destructive
|
|
29
|
+
[/\bDROP\s+(TABLE|DATABASE|SCHEMA)\b/i, 'drop database objects'],
|
|
30
|
+
[/\bTRUNCATE\s+TABLE\b/i, 'truncate table'],
|
|
31
|
+
// System-level danger
|
|
32
|
+
[/\bchmod\s+(-R\s+)?777\b/, 'world-writable permissions'],
|
|
33
|
+
[/\bcurl\s+.*\|\s*(sudo\s+)?(ba)?sh\b/, 'pipe URL to shell'],
|
|
34
|
+
[/\bwget\s+.*\|\s*(sudo\s+)?(ba)?sh\b/, 'pipe URL to shell'],
|
|
35
|
+
[/\bsudo\s+rm\b/, 'sudo delete'],
|
|
36
|
+
// Kill/shutdown
|
|
37
|
+
[/\bkill\s+-9\s+-1\b/, 'kill all processes'],
|
|
38
|
+
[/\bshutdown\b/, 'system shutdown'],
|
|
39
|
+
[/\breboot\b/, 'system reboot'],
|
|
40
|
+
];
|
|
41
|
+
// ─── Safe Commands ────────────────────────────────────────────────────────
|
|
42
|
+
// If ALL segments use these commands, auto-approve.
|
|
43
|
+
const SAFE_COMMANDS = new Set([
|
|
44
|
+
// Filesystem read-only
|
|
45
|
+
'ls', 'cat', 'head', 'tail', 'wc', 'du', 'df', 'file', 'stat', 'tree',
|
|
46
|
+
'find', 'grep', 'rg', 'ag', 'ack', 'which', 'whereis', 'type',
|
|
47
|
+
'echo', 'printf', 'date', 'whoami', 'hostname', 'uname', 'printenv',
|
|
48
|
+
'pwd', 'realpath', 'dirname', 'basename',
|
|
49
|
+
// Text processing (read-only when not redirecting)
|
|
50
|
+
'jq', 'yq', 'sort', 'uniq', 'cut', 'tr', 'diff', 'comm', 'less', 'more',
|
|
51
|
+
'wc', 'tee', 'xargs',
|
|
52
|
+
]);
|
|
53
|
+
const SAFE_GIT_SUBCOMMANDS = new Set([
|
|
54
|
+
'status', 'log', 'diff', 'show', 'branch', 'tag', 'remote',
|
|
55
|
+
'blame', 'shortlog', 'describe', 'rev-parse', 'rev-list',
|
|
56
|
+
'ls-files', 'ls-tree', 'ls-remote', 'config', 'reflog',
|
|
57
|
+
]);
|
|
58
|
+
const SAFE_PKG_SUBCOMMANDS = new Set([
|
|
59
|
+
'test', 'run', 'list', 'ls', 'info', 'view', 'show',
|
|
60
|
+
'outdated', 'audit', 'start', 'dev', 'serve', 'lint', 'check',
|
|
61
|
+
'why', 'explain', 'doctor',
|
|
62
|
+
]);
|
|
63
|
+
const SAFE_CARGO_SUBCOMMANDS = new Set([
|
|
64
|
+
'test', 'check', 'clippy', 'build', 'run', 'bench', 'doc',
|
|
65
|
+
'fmt', 'tree', 'metadata', 'verify-project',
|
|
66
|
+
]);
|
|
67
|
+
// ─── Classifier ──────────────────────────────────────────────────────────
|
|
68
|
+
export function classifyBashRisk(command) {
|
|
69
|
+
// 1. Check dangerous patterns first (highest priority)
|
|
70
|
+
for (const [pattern, reason] of DANGEROUS_PATTERNS) {
|
|
71
|
+
if (pattern.test(command)) {
|
|
72
|
+
return { level: 'dangerous', reason };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// 2. Check if every segment is a known-safe command
|
|
76
|
+
const segments = command.split(/\s*(?:&&|\|\||[;|])\s*/);
|
|
77
|
+
let allSafe = true;
|
|
78
|
+
for (const segment of segments) {
|
|
79
|
+
const trimmed = segment.trim();
|
|
80
|
+
if (!trimmed)
|
|
81
|
+
continue;
|
|
82
|
+
if (!isSegmentSafe(trimmed)) {
|
|
83
|
+
allSafe = false;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (allSafe && segments.some(s => s.trim().length > 0)) {
|
|
88
|
+
return { level: 'safe' };
|
|
89
|
+
}
|
|
90
|
+
return { level: 'normal' };
|
|
91
|
+
}
|
|
92
|
+
function isSegmentSafe(segment) {
|
|
93
|
+
// Parse: strip env vars, extract command and args
|
|
94
|
+
const words = segment.split(/\s+/).filter(w => !w.includes('='));
|
|
95
|
+
let idx = 0;
|
|
96
|
+
let cmd = words[idx] || '';
|
|
97
|
+
// Strip harmless prefixes
|
|
98
|
+
while (['time', 'nice'].includes(cmd) && idx < words.length - 1) {
|
|
99
|
+
cmd = words[++idx] || '';
|
|
100
|
+
}
|
|
101
|
+
// sudo → not safe (even if the underlying command is safe)
|
|
102
|
+
if (cmd === 'sudo')
|
|
103
|
+
return false;
|
|
104
|
+
const baseName = cmd.split('/').pop() || cmd;
|
|
105
|
+
const argIdx = idx + 1;
|
|
106
|
+
const subCmd = words[argIdx] || '';
|
|
107
|
+
// git
|
|
108
|
+
if (baseName === 'git') {
|
|
109
|
+
return SAFE_GIT_SUBCOMMANDS.has(subCmd);
|
|
110
|
+
}
|
|
111
|
+
// npm / yarn / pnpm / bun / npx
|
|
112
|
+
if (['npm', 'npx', 'yarn', 'pnpm', 'bun'].includes(baseName)) {
|
|
113
|
+
// "npm run <script>" — safe (dev servers, linters, etc.)
|
|
114
|
+
if (subCmd === 'run')
|
|
115
|
+
return true;
|
|
116
|
+
return SAFE_PKG_SUBCOMMANDS.has(subCmd);
|
|
117
|
+
}
|
|
118
|
+
// cargo
|
|
119
|
+
if (baseName === 'cargo') {
|
|
120
|
+
return SAFE_CARGO_SUBCOMMANDS.has(subCmd);
|
|
121
|
+
}
|
|
122
|
+
// rtk (RTK wrapper — safe, it's a proxy)
|
|
123
|
+
if (baseName === 'rtk')
|
|
124
|
+
return true;
|
|
125
|
+
// Known safe base command
|
|
126
|
+
if (SAFE_COMMANDS.has(baseName)) {
|
|
127
|
+
// sed -i is not read-only
|
|
128
|
+
if (baseName === 'sed' && segment.includes(' -i'))
|
|
129
|
+
return false;
|
|
130
|
+
// Output redirection means writing — not safe
|
|
131
|
+
if (/>\s*[^&|]/.test(segment))
|
|
132
|
+
return false;
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
// Version/help checks are always safe
|
|
136
|
+
if (/\s+(-v|--version|-V)\s*$/.test(segment))
|
|
137
|
+
return true;
|
|
138
|
+
if (/\s+(-h|--help)\s*$/.test(segment))
|
|
139
|
+
return true;
|
|
140
|
+
// gh (GitHub CLI) read-only commands
|
|
141
|
+
if (baseName === 'gh') {
|
|
142
|
+
const ghAction = words.slice(argIdx, argIdx + 2).join(' ');
|
|
143
|
+
if (/^(pr|issue|repo|release|run)\s+(view|list|status|diff|checks|comments)/.test(ghAction))
|
|
144
|
+
return true;
|
|
145
|
+
if (subCmd === 'api')
|
|
146
|
+
return true; // gh api is read-only (GET)
|
|
147
|
+
if (subCmd === 'auth' && words[argIdx + 1] === 'status')
|
|
148
|
+
return true;
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
// docker/podman read-only
|
|
152
|
+
if (baseName === 'docker' || baseName === 'podman') {
|
|
153
|
+
if (['ps', 'images', 'inspect', 'logs', 'stats', 'top', 'port', 'version', 'info'].includes(subCmd))
|
|
154
|
+
return true;
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
package/dist/agent/compact.js
CHANGED
|
@@ -22,9 +22,9 @@ Critical rules:
|
|
|
22
22
|
- Preserve EXACT file paths, function names, line numbers, variable names
|
|
23
23
|
- Preserve EXACT error messages and stack traces (verbatim)
|
|
24
24
|
- Preserve user preferences and corrections (especially "don't do X" instructions)
|
|
25
|
-
- Preserve decisions
|
|
25
|
+
- Preserve decisions WITH their rationale — "changed X to Y because Z was broken" (1-2 sentences per decision)
|
|
26
26
|
- Include full code snippets and function signatures when they are load-bearing
|
|
27
|
-
- DO NOT include reasoning
|
|
27
|
+
- DO NOT include verbose reasoning chains — summarize the WHY in 1-2 sentences, not paragraphs
|
|
28
28
|
- DO NOT include pleasantries, meta-commentary, or apologies
|
|
29
29
|
- Use bullet points inside each section
|
|
30
30
|
- Be specific: "edited src/foo.ts:42 to add error handling" not "made some changes"
|
|
@@ -46,7 +46,7 @@ Then produce the summary inside <summary> tags using these exact section headers
|
|
|
46
46
|
[Any errors encountered, their root causes, and how they were resolved — this prevents re-investigating the same issues]
|
|
47
47
|
|
|
48
48
|
## Decisions
|
|
49
|
-
[
|
|
49
|
+
[Each decision: what was chosen, why, and what constraint/goal drove it. Format: "Chose X over Y because Z." — losing the WHY causes rework later]
|
|
50
50
|
|
|
51
51
|
## Files Modified
|
|
52
52
|
[Each file touched, with a one-line description of what changed and why]
|
package/dist/agent/loop.js
CHANGED
|
@@ -405,7 +405,15 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
405
405
|
// Create streaming executor for concurrent tool execution
|
|
406
406
|
const streamExec = new StreamingExecutor({
|
|
407
407
|
handlers: capabilityMap,
|
|
408
|
-
scope: {
|
|
408
|
+
scope: {
|
|
409
|
+
workingDir: workDir,
|
|
410
|
+
abortSignal: abort.signal,
|
|
411
|
+
onAskUser: config.onAskUser,
|
|
412
|
+
parentContext: {
|
|
413
|
+
goal: lastUserInput?.slice(0, 200),
|
|
414
|
+
recentFiles: [...readFileCache].slice(-10),
|
|
415
|
+
},
|
|
416
|
+
},
|
|
409
417
|
permissions,
|
|
410
418
|
guard: toolGuard,
|
|
411
419
|
onStart: (id, name, preview) => onEvent({ kind: 'capability_start', id, name, preview }),
|
|
@@ -7,6 +7,31 @@ import path from 'node:path';
|
|
|
7
7
|
import readline from 'node:readline';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import { BLOCKRUN_DIR } from '../config.js';
|
|
10
|
+
import { classifyBashRisk } from './bash-guard.js';
|
|
11
|
+
// ─── Common dev command patterns (auto-allow without prompting) ──────────
|
|
12
|
+
// These are "normal" risk commands that are too common to interrupt the user.
|
|
13
|
+
// Only applied when --trust flag is set (user explicitly opted into auto-mode).
|
|
14
|
+
const COMMON_DEV_PATTERNS = [
|
|
15
|
+
/^npm\s+(install|i|ci|run|exec|test|start|build|lint|format|outdated|ls|list|info|view|pack)\b/,
|
|
16
|
+
/^(pnpm|yarn|bun)\s+(install|add|run|test|build|lint|exec)\b/,
|
|
17
|
+
/^pip3?\s+install\b/,
|
|
18
|
+
/^python3?\s+/,
|
|
19
|
+
/^node\s+/,
|
|
20
|
+
/^(pytest|jest|vitest|mocha)\b/,
|
|
21
|
+
/^(tsc|eslint|prettier|biome)\b/,
|
|
22
|
+
/^git\s+(add|commit|push|pull|fetch|status|diff|log|branch|checkout|switch|merge|rebase|stash|tag|remote|show)\b/,
|
|
23
|
+
/^(cat|head|tail|wc|sort|uniq|diff|file|which|whoami|hostname|uname|date|echo)\b/,
|
|
24
|
+
/^(ls|pwd|cd|mkdir|touch)\b/,
|
|
25
|
+
/^(docker|docker-compose)\s+(ps|logs|images|inspect|stats|exec|build|run|pull)\b/,
|
|
26
|
+
/^(curl|wget)\s+/,
|
|
27
|
+
/^make\b/,
|
|
28
|
+
/^cargo\s+(build|test|check|clippy|run|bench|doc|fmt)\b/,
|
|
29
|
+
/^go\s+(build|test|run|vet|fmt|mod)\b/,
|
|
30
|
+
];
|
|
31
|
+
function isCommonDevCommand(cmd) {
|
|
32
|
+
const trimmed = cmd.trim();
|
|
33
|
+
return COMMON_DEV_PATTERNS.some(p => p.test(trimmed));
|
|
34
|
+
}
|
|
10
35
|
// ─── Default Rules ─────────────────────────────────────────────────────────
|
|
11
36
|
const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX']);
|
|
12
37
|
const DESTRUCTIVE_TOOLS = new Set(['Write', 'Edit', 'Bash']);
|
|
@@ -61,8 +86,17 @@ export class PermissionManager {
|
|
|
61
86
|
if (this.matchesRule(toolName, input, this.rules.allow)) {
|
|
62
87
|
return { behavior: 'allow', reason: 'allowed by rule' };
|
|
63
88
|
}
|
|
64
|
-
// Check explicit ask rules
|
|
89
|
+
// Check explicit ask rules — with Bash risk classification
|
|
65
90
|
if (this.matchesRule(toolName, input, this.rules.ask)) {
|
|
91
|
+
// Bash Guardian: classify risk before blindly asking
|
|
92
|
+
if (toolName === 'Bash') {
|
|
93
|
+
const cmd = input.command || '';
|
|
94
|
+
const risk = classifyBashRisk(cmd);
|
|
95
|
+
if (risk.level === 'safe') {
|
|
96
|
+
return { behavior: 'allow', reason: 'safe command' };
|
|
97
|
+
}
|
|
98
|
+
// dangerous and normal both ask, but dangerous gets a warning in describeAction
|
|
99
|
+
}
|
|
66
100
|
return { behavior: 'ask' };
|
|
67
101
|
}
|
|
68
102
|
// Default: read-only tools are auto-allowed, others ask
|
|
@@ -179,7 +213,12 @@ export class PermissionManager {
|
|
|
179
213
|
switch (toolName) {
|
|
180
214
|
case 'Bash': {
|
|
181
215
|
const cmd = input.command || '';
|
|
182
|
-
|
|
216
|
+
const preview = cmd.length > 100 ? cmd.slice(0, 100) + '...' : cmd;
|
|
217
|
+
const risk = classifyBashRisk(cmd);
|
|
218
|
+
if (risk.level === 'dangerous') {
|
|
219
|
+
return `\x1b[31m⚠ DANGEROUS: ${risk.reason}\x1b[0m\n │ Execute: ${preview}`;
|
|
220
|
+
}
|
|
221
|
+
return `Execute: ${preview}`;
|
|
183
222
|
}
|
|
184
223
|
case 'Write': {
|
|
185
224
|
const fp = input.file_path || '';
|
package/dist/agent/tokens.js
CHANGED
|
@@ -180,7 +180,7 @@ const MODEL_CONTEXT_WINDOWS = {
|
|
|
180
180
|
'xai/grok-4-0709': 131_072,
|
|
181
181
|
'xai/grok-4-1-fast-reasoning': 131_072,
|
|
182
182
|
// Others
|
|
183
|
-
'zai/glm-5.1':
|
|
183
|
+
'zai/glm-5.1': 200_000,
|
|
184
184
|
'moonshot/kimi-k2.5': 128_000,
|
|
185
185
|
'minimax/minimax-m2.7': 128_000,
|
|
186
186
|
};
|
package/dist/agent/types.d.ts
CHANGED
|
@@ -67,6 +67,11 @@ export interface ExecutionScope {
|
|
|
67
67
|
onProgress?: (text: string) => void;
|
|
68
68
|
/** Routes AskUser questions through ink UI input to avoid raw-mode stdin conflict */
|
|
69
69
|
onAskUser?: (question: string, options?: string[]) => Promise<string>;
|
|
70
|
+
/** Context from parent agent — helps sub-agents avoid duplicate work */
|
|
71
|
+
parentContext?: {
|
|
72
|
+
goal?: string;
|
|
73
|
+
recentFiles?: string[];
|
|
74
|
+
};
|
|
70
75
|
}
|
|
71
76
|
export interface StreamTextDelta {
|
|
72
77
|
kind: 'text_delta';
|
package/dist/mcp/client.js
CHANGED
|
@@ -79,6 +79,42 @@ async function connectStdio(name, config) {
|
|
|
79
79
|
concurrent: true, // MCP tools are safe to run concurrently
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
|
+
// Discover resources (optional — not all servers expose resources)
|
|
83
|
+
try {
|
|
84
|
+
const { resources: mcpResources } = await client.listResources();
|
|
85
|
+
for (const resource of mcpResources) {
|
|
86
|
+
const resourceToolName = `mcp__${name}__read_${resource.name.replace(/[^a-zA-Z0-9_]/g, '_')}`;
|
|
87
|
+
const resourceDesc = resource.description
|
|
88
|
+
? `Read resource: ${resource.description}`.slice(0, 2048)
|
|
89
|
+
: `Read MCP resource "${resource.name}" from ${name}`;
|
|
90
|
+
capabilities.push({
|
|
91
|
+
spec: {
|
|
92
|
+
name: resourceToolName,
|
|
93
|
+
description: resourceDesc,
|
|
94
|
+
input_schema: { type: 'object', properties: {}, required: [] },
|
|
95
|
+
},
|
|
96
|
+
execute: async () => {
|
|
97
|
+
try {
|
|
98
|
+
const result = await client.readResource({ uri: resource.uri });
|
|
99
|
+
const output = result.contents
|
|
100
|
+
?.map(c => c.text ?? `[resource: ${c.uri}]`)
|
|
101
|
+
?.join('\n') || JSON.stringify(result.contents);
|
|
102
|
+
return { output, isError: false };
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return {
|
|
106
|
+
output: `MCP resource error (${name}/${resource.name}): ${err.message}`,
|
|
107
|
+
isError: true,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
concurrent: true,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Server doesn't support resources — that's fine, tools-only mode
|
|
117
|
+
}
|
|
82
118
|
const connected = { name, client, transport, tools: capabilities };
|
|
83
119
|
connections.set(name, connected);
|
|
84
120
|
return connected;
|
package/dist/pricing.js
CHANGED
|
@@ -73,7 +73,7 @@ export const MODEL_PRICING = {
|
|
|
73
73
|
'zai/glm-5': { input: 0, output: 0, perCall: 0.001 },
|
|
74
74
|
'zai/glm-5.1': { input: 0, output: 0, perCall: 0.001 },
|
|
75
75
|
'zai/glm-5-turbo': { input: 0, output: 0, perCall: 0.001 },
|
|
76
|
-
'zai/glm-5.1-turbo': { input: 0, output: 0, perCall: 0.001 },
|
|
76
|
+
'zai/glm-5.1-turbo': { input: 0, output: 0, perCall: 0.001 }, // client alias for zai/glm-5-turbo
|
|
77
77
|
};
|
|
78
78
|
/** Opus pricing for savings calculations */
|
|
79
79
|
export const OPUS_PRICING = MODEL_PRICING['anthropic/claude-opus-4.6'];
|
package/dist/tools/bash.js
CHANGED
|
@@ -51,7 +51,26 @@ function compressOutput(command, output) {
|
|
|
51
51
|
else if (sub === 'install')
|
|
52
52
|
out = compressInstall(out);
|
|
53
53
|
}
|
|
54
|
-
// 7.
|
|
54
|
+
// 7. Python — pip install, pytest, python scripts
|
|
55
|
+
else if (/^(pip|pip3)\s+install\b/.test(fullCmd)) {
|
|
56
|
+
out = compressInstall(out);
|
|
57
|
+
}
|
|
58
|
+
else if (/^(pytest|python.*-m\s+pytest)\b/.test(fullCmd)) {
|
|
59
|
+
out = compressTests(out);
|
|
60
|
+
}
|
|
61
|
+
// 8. Docker — strip layer hashes, progress bars, keep errors + summary
|
|
62
|
+
else if (/^docker\s+(build|run|pull|push|compose)\b/.test(fullCmd)) {
|
|
63
|
+
out = compressDocker(out);
|
|
64
|
+
}
|
|
65
|
+
// 9. curl/wget — strip progress bars, keep response
|
|
66
|
+
else if (/^(curl|wget)\b/.test(fullCmd)) {
|
|
67
|
+
out = compressDownload(out);
|
|
68
|
+
}
|
|
69
|
+
// 10. Make — keep errors/warnings, drop recipe lines
|
|
70
|
+
else if (cmd === 'make') {
|
|
71
|
+
out = compressBuild(out);
|
|
72
|
+
}
|
|
73
|
+
// 11. Always collapse excessive blank lines
|
|
55
74
|
out = collapseBlankLines(out);
|
|
56
75
|
return out;
|
|
57
76
|
}
|
|
@@ -161,6 +180,42 @@ function compressBuild(out) {
|
|
|
161
180
|
});
|
|
162
181
|
return collapseBlankLines(kept.join('\n')).trim() || out.trim();
|
|
163
182
|
}
|
|
183
|
+
function compressDocker(out) {
|
|
184
|
+
const lines = out.split('\n');
|
|
185
|
+
const kept = lines.filter(l => {
|
|
186
|
+
const t = l.trim();
|
|
187
|
+
// Drop layer progress: "sha256:abc123: Pulling fs layer" / "Downloading [==> ]"
|
|
188
|
+
if (/^[a-f0-9]{12}:\s*(Pull|Wait|Download|Extract|Verif|Already)/.test(t))
|
|
189
|
+
return false;
|
|
190
|
+
// Drop download/upload progress bars
|
|
191
|
+
if (/^\[[\s=>#]+\]/.test(t) || /\d+(\.\d+)?%/.test(t) && t.length < 80)
|
|
192
|
+
return false;
|
|
193
|
+
// Drop "Sending build context" progress
|
|
194
|
+
if (/^Sending build context/.test(t))
|
|
195
|
+
return false;
|
|
196
|
+
return true;
|
|
197
|
+
});
|
|
198
|
+
return collapseBlankLines(kept.join('\n')).trim() || out.trim();
|
|
199
|
+
}
|
|
200
|
+
function compressDownload(out) {
|
|
201
|
+
const lines = out.split('\n');
|
|
202
|
+
const kept = lines.filter(l => {
|
|
203
|
+
const t = l.trim();
|
|
204
|
+
// Drop curl progress bars: " % Total % Received..."
|
|
205
|
+
if (/^\s*%\s+Total/.test(t))
|
|
206
|
+
return false;
|
|
207
|
+
if (/^\s*\d+\s+\d+[kMG]?\s+\d+\s+\d+[kMG]?/.test(t) && t.length < 100)
|
|
208
|
+
return false;
|
|
209
|
+
// Drop wget progress: "2024-01-01 12:00:00 (1.23 MB/s) - saved"
|
|
210
|
+
if (/^\d{4}-\d{2}-\d{2}.*saved/.test(t))
|
|
211
|
+
return false;
|
|
212
|
+
// Drop download percentage lines
|
|
213
|
+
if (/^\s*\d+%\s/.test(t))
|
|
214
|
+
return false;
|
|
215
|
+
return true;
|
|
216
|
+
});
|
|
217
|
+
return collapseBlankLines(kept.join('\n')).trim() || out.trim();
|
|
218
|
+
}
|
|
164
219
|
const backgroundTasks = new Map();
|
|
165
220
|
let bgTaskCounter = 0;
|
|
166
221
|
/** Get a background task's result (called by the agent to check status). */
|
package/dist/tools/subagent.js
CHANGED
|
@@ -24,7 +24,22 @@ async function execute(input, ctx) {
|
|
|
24
24
|
}
|
|
25
25
|
const toolDefs = subTools.map(c => c.spec);
|
|
26
26
|
const systemInstructions = assembleInstructions(ctx.workingDir);
|
|
27
|
-
|
|
27
|
+
// Inject parent context so sub-agent avoids duplicate work
|
|
28
|
+
let parentContextSection = '';
|
|
29
|
+
if (ctx.parentContext) {
|
|
30
|
+
const parts = [];
|
|
31
|
+
if (ctx.parentContext.goal) {
|
|
32
|
+
parts.push(`Parent task: ${ctx.parentContext.goal}`);
|
|
33
|
+
}
|
|
34
|
+
if (ctx.parentContext.recentFiles && ctx.parentContext.recentFiles.length > 0) {
|
|
35
|
+
parts.push(`Files already read by parent: ${ctx.parentContext.recentFiles.join(', ')}`);
|
|
36
|
+
parts.push('Do not re-read these files unless you need to verify a change.');
|
|
37
|
+
}
|
|
38
|
+
if (parts.length > 0) {
|
|
39
|
+
parentContextSection = '\n\n# Parent Agent Context\n' + parts.join('\n');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const systemPrompt = systemInstructions.join('\n\n') + parentContextSection;
|
|
28
43
|
const history = [
|
|
29
44
|
{ role: 'user', content: prompt },
|
|
30
45
|
];
|
package/dist/tools/websearch.js
CHANGED
|
@@ -91,6 +91,31 @@ function parseDuckDuckGoResults(html, maxResults) {
|
|
|
91
91
|
snippet: stripTags(snippet?.[1] || '').trim(),
|
|
92
92
|
});
|
|
93
93
|
}
|
|
94
|
+
// Last resort: if both parsers failed, extract ANY external links from the page
|
|
95
|
+
// Partial results are better than "No results found" when the page loaded OK
|
|
96
|
+
if (results.length === 0) {
|
|
97
|
+
const allLinks = /<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
98
|
+
let match;
|
|
99
|
+
while ((match = allLinks.exec(html)) !== null && results.length < maxResults) {
|
|
100
|
+
let url = match[1] || '';
|
|
101
|
+
const text = stripTags(match[2]).trim();
|
|
102
|
+
// Must be a real external URL with meaningful text
|
|
103
|
+
if (!text || text.length < 4)
|
|
104
|
+
continue;
|
|
105
|
+
if (url.startsWith('/') || url.includes('duckduckgo.com'))
|
|
106
|
+
continue;
|
|
107
|
+
// Extract from DDG redirect wrapper
|
|
108
|
+
const uddg = url.match(/uddg=([^&]+)/);
|
|
109
|
+
if (uddg)
|
|
110
|
+
url = decodeURIComponent(uddg[1]);
|
|
111
|
+
if (!url.startsWith('http'))
|
|
112
|
+
continue;
|
|
113
|
+
if (seenUrls.has(url))
|
|
114
|
+
continue;
|
|
115
|
+
seenUrls.add(url);
|
|
116
|
+
results.push({ title: text, url, snippet: '' });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
94
119
|
return results;
|
|
95
120
|
}
|
|
96
121
|
function stripTags(html) {
|
package/dist/ui/app.js
CHANGED
|
@@ -13,6 +13,7 @@ import { renderMarkdown } from './markdown.js';
|
|
|
13
13
|
import { resolveModel, PICKER_CATEGORIES, PICKER_MODELS_FLAT, } from './model-picker.js';
|
|
14
14
|
import { estimateCost } from '../pricing.js';
|
|
15
15
|
import { formatTokens, shortModelName } from '../stats/format.js';
|
|
16
|
+
import { mouse } from './mouse.js';
|
|
16
17
|
// ─── Full-width input box ──────────────────────────────────────────────────
|
|
17
18
|
function InputBox({ input, setInput, onSubmit, model, balance, sessionCost, queued, queuedCount, focused, busy, contextPct, vimMode, onVimModeChange }) {
|
|
18
19
|
const { stdout } = useStdout();
|
|
@@ -80,6 +81,37 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
80
81
|
// Messages queued while agent is busy — auto-submitted FIFO when turns complete.
|
|
81
82
|
const [queuedInputs, setQueuedInputs] = useState([]);
|
|
82
83
|
const turnDoneCallbackRef = useRef(null);
|
|
84
|
+
// ── Render throttling: batch rapid text_delta/thinking_delta into 50ms frames ──
|
|
85
|
+
// Without this, each delta (20-100/sec) triggers a full React re-render.
|
|
86
|
+
// With this, we accumulate in refs and flush at ~20fps — smooth and efficient.
|
|
87
|
+
const pendingTextRef = useRef('');
|
|
88
|
+
const pendingThinkingRef = useRef('');
|
|
89
|
+
const flushTimerRef = useRef(null);
|
|
90
|
+
const flushPendingText = useCallback(() => {
|
|
91
|
+
flushTimerRef.current = null;
|
|
92
|
+
const text = pendingTextRef.current;
|
|
93
|
+
const thinking = pendingThinkingRef.current;
|
|
94
|
+
if (text) {
|
|
95
|
+
pendingTextRef.current = '';
|
|
96
|
+
setWaiting(false);
|
|
97
|
+
setThinking(false);
|
|
98
|
+
setStreamText(prev => prev + text);
|
|
99
|
+
}
|
|
100
|
+
if (thinking) {
|
|
101
|
+
pendingThinkingRef.current = '';
|
|
102
|
+
setWaiting(false);
|
|
103
|
+
setThinking(true);
|
|
104
|
+
setThinkingText(prev => {
|
|
105
|
+
const updated = prev + thinking;
|
|
106
|
+
return updated.length > 500 ? updated.slice(-500) : updated;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}, []);
|
|
110
|
+
const scheduleFlush = useCallback(() => {
|
|
111
|
+
if (!flushTimerRef.current) {
|
|
112
|
+
flushTimerRef.current = setTimeout(flushPendingText, 50);
|
|
113
|
+
}
|
|
114
|
+
}, [flushPendingText]);
|
|
83
115
|
// Refs to read current state values inside memoized event handlers (avoids stale closures)
|
|
84
116
|
const streamTextRef = useRef('');
|
|
85
117
|
const turnTokensRef = useRef({ input: 0, output: 0, calls: 0 });
|
|
@@ -110,15 +142,19 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
110
142
|
const commitResponse = useCallback((text, tokens = turnTokensRef.current, cost = turnCostRef.current) => {
|
|
111
143
|
if (!text.trim())
|
|
112
144
|
return;
|
|
113
|
-
setCommittedResponses((rs) =>
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
145
|
+
setCommittedResponses((rs) => {
|
|
146
|
+
const next = [...rs, {
|
|
147
|
+
key: String(Date.now() + Math.random()),
|
|
148
|
+
text,
|
|
149
|
+
tokens,
|
|
150
|
+
cost,
|
|
151
|
+
model: turnModelRef.current,
|
|
152
|
+
tier: turnTierRef.current,
|
|
153
|
+
savings: turnSavingsRef.current,
|
|
154
|
+
}];
|
|
155
|
+
// Cap at 300 items — older items are already in terminal scrollback
|
|
156
|
+
return next.length > 300 ? next.slice(-300) : next;
|
|
157
|
+
});
|
|
122
158
|
const allLines = text.split('\n');
|
|
123
159
|
if (allLines.length > 20) {
|
|
124
160
|
setResponsePreview(' ↑ scroll to see full reply\n' + allLines.slice(-20).join('\n'));
|
|
@@ -359,6 +395,25 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
359
395
|
turnSavingsRef.current = undefined;
|
|
360
396
|
onSubmit(trimmed);
|
|
361
397
|
}, [ready, currentModel, totalCost, onSubmit, onModelChange, onAbort, onExit, exit, lastPrompt, inputHistory, showStatus]);
|
|
398
|
+
// Mouse support — clicks toggle tool results, drag selects text
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
const cleanup = mouse.enable();
|
|
401
|
+
const handleClick = (_event) => {
|
|
402
|
+
// Click: toggle expandable tool
|
|
403
|
+
setExpandableTool(prev => prev ? { ...prev, expanded: !prev.expanded } : null);
|
|
404
|
+
};
|
|
405
|
+
const handleCopied = (info) => {
|
|
406
|
+
// Show status when text is copied via drag-select
|
|
407
|
+
showStatus(`Copied ${info.length} chars to clipboard`, 'success', 2000);
|
|
408
|
+
};
|
|
409
|
+
mouse.on('click', handleClick);
|
|
410
|
+
mouse.on('copied', handleCopied);
|
|
411
|
+
return () => {
|
|
412
|
+
mouse.removeListener('click', handleClick);
|
|
413
|
+
mouse.removeListener('copied', handleCopied);
|
|
414
|
+
cleanup();
|
|
415
|
+
};
|
|
416
|
+
}, []);
|
|
362
417
|
// Expose event handler, balance updater, and permission bridge
|
|
363
418
|
useEffect(() => {
|
|
364
419
|
globalThis.__runcode_ui = {
|
|
@@ -390,18 +445,14 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
390
445
|
handleEvent: (event) => {
|
|
391
446
|
switch (event.kind) {
|
|
392
447
|
case 'text_delta':
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
448
|
+
// Throttled: accumulate in ref, flush every 50ms (~20fps)
|
|
449
|
+
pendingTextRef.current += event.text;
|
|
450
|
+
scheduleFlush();
|
|
396
451
|
break;
|
|
397
452
|
case 'thinking_delta':
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
// Keep last 500 chars of thinking for display
|
|
402
|
-
const updated = prev + event.text;
|
|
403
|
-
return updated.length > 500 ? updated.slice(-500) : updated;
|
|
404
|
-
});
|
|
453
|
+
// Throttled: accumulate in ref, flush every 50ms
|
|
454
|
+
pendingThinkingRef.current += event.text;
|
|
455
|
+
scheduleFlush();
|
|
405
456
|
break;
|
|
406
457
|
case 'capability_start':
|
|
407
458
|
setWaiting(false);
|
|
@@ -492,6 +543,17 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
492
543
|
break;
|
|
493
544
|
}
|
|
494
545
|
case 'turn_done': {
|
|
546
|
+
// Flush any pending throttled text immediately
|
|
547
|
+
if (flushTimerRef.current) {
|
|
548
|
+
clearTimeout(flushTimerRef.current);
|
|
549
|
+
flushTimerRef.current = null;
|
|
550
|
+
}
|
|
551
|
+
// Merge pending text into the ref so commitResponse sees the full text
|
|
552
|
+
if (pendingTextRef.current) {
|
|
553
|
+
streamTextRef.current += pendingTextRef.current;
|
|
554
|
+
pendingTextRef.current = '';
|
|
555
|
+
}
|
|
556
|
+
pendingThinkingRef.current = '';
|
|
495
557
|
// Flush expandable tool to Static before committing response
|
|
496
558
|
setExpandableTool(prev => {
|
|
497
559
|
if (prev)
|
|
@@ -649,7 +711,7 @@ export function launchInkUI(opts) {
|
|
|
649
711
|
return new Promise((resolve) => { resolveInput = resolve; });
|
|
650
712
|
},
|
|
651
713
|
onAbort: (cb) => { abortCallback = cb; },
|
|
652
|
-
cleanup: () => { instance.unmount(); },
|
|
714
|
+
cleanup: () => { mouse.disable(); instance.unmount(); },
|
|
653
715
|
requestPermission: (toolName, description) => {
|
|
654
716
|
const ui = globalThis.__runcode_ui;
|
|
655
717
|
return ui?.requestPermission(toolName, description) ?? Promise.resolve('no');
|
package/dist/ui/model-picker.js
CHANGED
|
@@ -54,7 +54,7 @@ export const MODEL_SHORTCUTS = {
|
|
|
54
54
|
// Others
|
|
55
55
|
minimax: 'minimax/minimax-m2.7',
|
|
56
56
|
glm: 'zai/glm-5.1',
|
|
57
|
-
'glm-turbo': 'zai/glm-5
|
|
57
|
+
'glm-turbo': 'zai/glm-5-turbo',
|
|
58
58
|
'glm5': 'zai/glm-5.1',
|
|
59
59
|
kimi: 'moonshot/kimi-k2.5',
|
|
60
60
|
};
|
|
@@ -79,7 +79,7 @@ export const PICKER_CATEGORIES = [
|
|
|
79
79
|
category: '🔥 Promo (flat $0.001/call)',
|
|
80
80
|
models: [
|
|
81
81
|
{ id: 'zai/glm-5.1', shortcut: 'glm', label: 'GLM-5.1', price: '$0.001/call', highlight: true },
|
|
82
|
-
{ id: 'zai/glm-5
|
|
82
|
+
{ id: 'zai/glm-5-turbo', shortcut: 'glm-turbo', label: 'GLM-5 Turbo', price: '$0.001/call', highlight: true },
|
|
83
83
|
],
|
|
84
84
|
},
|
|
85
85
|
{
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mouse event support for Ink terminal UI.
|
|
3
|
+
* - SGR extended mouse tracking (DECSET 1000+1002+1006)
|
|
4
|
+
* - Click detection (left click → 'click' event)
|
|
5
|
+
* - Drag detection with text selection (press → motion → release)
|
|
6
|
+
* - Stdout interception for screen text buffer
|
|
7
|
+
* - Clipboard copy on drag-select
|
|
8
|
+
*/
|
|
9
|
+
import { EventEmitter } from 'node:events';
|
|
10
|
+
export interface MouseEvent {
|
|
11
|
+
button: 'left' | 'middle' | 'right' | 'wheel-up' | 'wheel-down';
|
|
12
|
+
action: 'press' | 'release' | 'drag';
|
|
13
|
+
col: number;
|
|
14
|
+
row: number;
|
|
15
|
+
}
|
|
16
|
+
export interface Selection {
|
|
17
|
+
startRow: number;
|
|
18
|
+
startCol: number;
|
|
19
|
+
endRow: number;
|
|
20
|
+
endCol: number;
|
|
21
|
+
text: string;
|
|
22
|
+
}
|
|
23
|
+
declare class MouseManager extends EventEmitter {
|
|
24
|
+
private enabled;
|
|
25
|
+
private stdinListener;
|
|
26
|
+
private screen;
|
|
27
|
+
private dragState;
|
|
28
|
+
private pressPos;
|
|
29
|
+
private dragPos;
|
|
30
|
+
/**
|
|
31
|
+
* Enable mouse tracking + screen buffer. Returns cleanup function.
|
|
32
|
+
*/
|
|
33
|
+
enable(): () => void;
|
|
34
|
+
private handleLeftButton;
|
|
35
|
+
/**
|
|
36
|
+
* Disable mouse tracking and clean up.
|
|
37
|
+
*/
|
|
38
|
+
disable(): void;
|
|
39
|
+
isEnabled(): boolean;
|
|
40
|
+
}
|
|
41
|
+
/** Singleton mouse manager. */
|
|
42
|
+
export declare const mouse: MouseManager;
|
|
43
|
+
export {};
|
package/dist/ui/mouse.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mouse event support for Ink terminal UI.
|
|
3
|
+
* - SGR extended mouse tracking (DECSET 1000+1002+1006)
|
|
4
|
+
* - Click detection (left click → 'click' event)
|
|
5
|
+
* - Drag detection with text selection (press → motion → release)
|
|
6
|
+
* - Stdout interception for screen text buffer
|
|
7
|
+
* - Clipboard copy on drag-select
|
|
8
|
+
*/
|
|
9
|
+
import { EventEmitter } from 'node:events';
|
|
10
|
+
import { execSync } from 'node:child_process';
|
|
11
|
+
// ─── Terminal escape sequences ────────────────────────────────────────────
|
|
12
|
+
const ENABLE_MOUSE = '\x1b[?1000h' + // Normal mouse tracking (clicks + wheel)
|
|
13
|
+
'\x1b[?1002h' + // Button-motion tracking (drag events)
|
|
14
|
+
'\x1b[?1006h'; // SGR extended format (readable coordinates)
|
|
15
|
+
const DISABLE_MOUSE = '\x1b[?1006l' +
|
|
16
|
+
'\x1b[?1002l' +
|
|
17
|
+
'\x1b[?1000l';
|
|
18
|
+
// SGR mouse event format: ESC [ < button ; col ; row M (press) or m (release)
|
|
19
|
+
const SGR_MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
20
|
+
// Strip ANSI escape sequences to get plain text
|
|
21
|
+
const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07|\x1b[()][012AB]|\x1b\[[\?=]?\d*[hlJKHfABCDEFGSTm]/g;
|
|
22
|
+
function stripAnsi(text) {
|
|
23
|
+
return text.replace(ANSI_RE, '');
|
|
24
|
+
}
|
|
25
|
+
// ─── Screen Buffer ───────────────────────────────────────────────────────
|
|
26
|
+
// Lightweight stdout interceptor that captures rendered text lines.
|
|
27
|
+
// Doesn't parse ANSI cursor movement — just stores line content as written.
|
|
28
|
+
class ScreenBuffer {
|
|
29
|
+
lines = [];
|
|
30
|
+
maxLines = 500; // ring buffer
|
|
31
|
+
originalWrite = null;
|
|
32
|
+
capturing = false;
|
|
33
|
+
start() {
|
|
34
|
+
if (this.capturing)
|
|
35
|
+
return;
|
|
36
|
+
this.capturing = true;
|
|
37
|
+
this.lines = [];
|
|
38
|
+
// Intercept stdout.write to capture rendered text
|
|
39
|
+
this.originalWrite = process.stdout.write.bind(process.stdout);
|
|
40
|
+
const self = this;
|
|
41
|
+
process.stdout.write = function (chunk, ...args) {
|
|
42
|
+
// Capture the text
|
|
43
|
+
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
|
|
44
|
+
self.addText(text);
|
|
45
|
+
// Pass through to original
|
|
46
|
+
return self.originalWrite(chunk, ...args);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
stop() {
|
|
50
|
+
if (!this.capturing)
|
|
51
|
+
return;
|
|
52
|
+
this.capturing = false;
|
|
53
|
+
if (this.originalWrite) {
|
|
54
|
+
process.stdout.write = this.originalWrite;
|
|
55
|
+
this.originalWrite = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
addText(text) {
|
|
59
|
+
// Split into lines and store plain text (ANSI stripped)
|
|
60
|
+
const plain = stripAnsi(text);
|
|
61
|
+
const newLines = plain.split('\n');
|
|
62
|
+
for (const line of newLines) {
|
|
63
|
+
if (line.length > 0) {
|
|
64
|
+
this.lines.push(line);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Cap ring buffer
|
|
68
|
+
if (this.lines.length > this.maxLines) {
|
|
69
|
+
this.lines = this.lines.slice(-this.maxLines);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Get text between two screen coordinates.
|
|
74
|
+
* Uses the stored text buffer — approximate but good enough for selection.
|
|
75
|
+
*/
|
|
76
|
+
getTextInRange(startRow, startCol, endRow, endCol) {
|
|
77
|
+
// Normalize direction
|
|
78
|
+
let r1 = startRow, c1 = startCol, r2 = endRow, c2 = endCol;
|
|
79
|
+
if (r1 > r2 || (r1 === r2 && c1 > c2)) {
|
|
80
|
+
[r1, c1, r2, c2] = [r2, c2, r1, c1];
|
|
81
|
+
}
|
|
82
|
+
// Map screen rows to buffer lines
|
|
83
|
+
// Screen rows are relative to current viewport. Our buffer stores
|
|
84
|
+
// recent lines. We use terminal rows to estimate offset.
|
|
85
|
+
const termRows = process.stdout.rows || 24;
|
|
86
|
+
const bufLen = this.lines.length;
|
|
87
|
+
// Last N lines correspond to the visible screen
|
|
88
|
+
const startIdx = Math.max(0, bufLen - termRows + r1);
|
|
89
|
+
const endIdx = Math.max(0, bufLen - termRows + r2);
|
|
90
|
+
if (startIdx >= bufLen)
|
|
91
|
+
return '';
|
|
92
|
+
const selected = [];
|
|
93
|
+
for (let i = startIdx; i <= Math.min(endIdx, bufLen - 1); i++) {
|
|
94
|
+
const line = this.lines[i] || '';
|
|
95
|
+
if (i === startIdx && i === endIdx) {
|
|
96
|
+
// Single line selection
|
|
97
|
+
selected.push(line.slice(c1, c2 + 1));
|
|
98
|
+
}
|
|
99
|
+
else if (i === startIdx) {
|
|
100
|
+
selected.push(line.slice(c1));
|
|
101
|
+
}
|
|
102
|
+
else if (i === endIdx) {
|
|
103
|
+
selected.push(line.slice(0, c2 + 1));
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
selected.push(line);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return selected.join('\n').trim();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// ─── Clipboard ───────────────────────────────────────────────────────────
|
|
113
|
+
function copyToClipboard(text) {
|
|
114
|
+
if (!text)
|
|
115
|
+
return false;
|
|
116
|
+
try {
|
|
117
|
+
if (process.platform === 'darwin') {
|
|
118
|
+
execSync('pbcopy', { input: text, timeout: 2000 });
|
|
119
|
+
}
|
|
120
|
+
else if (process.platform === 'linux') {
|
|
121
|
+
// Try xclip first, then xsel
|
|
122
|
+
try {
|
|
123
|
+
execSync('xclip -selection clipboard', { input: text, timeout: 2000 });
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
execSync('xsel --clipboard --input', { input: text, timeout: 2000 });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else if (process.platform === 'win32') {
|
|
130
|
+
execSync('clip', { input: text, timeout: 2000 });
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
class MouseManager extends EventEmitter {
|
|
142
|
+
enabled = false;
|
|
143
|
+
stdinListener = null;
|
|
144
|
+
screen = new ScreenBuffer();
|
|
145
|
+
// Drag state machine
|
|
146
|
+
dragState = 'idle';
|
|
147
|
+
pressPos = { row: 0, col: 0 };
|
|
148
|
+
dragPos = { row: 0, col: 0 };
|
|
149
|
+
/**
|
|
150
|
+
* Enable mouse tracking + screen buffer. Returns cleanup function.
|
|
151
|
+
*/
|
|
152
|
+
enable() {
|
|
153
|
+
if (this.enabled)
|
|
154
|
+
return () => { };
|
|
155
|
+
this.enabled = true;
|
|
156
|
+
// Start screen buffer capture
|
|
157
|
+
this.screen.start();
|
|
158
|
+
// Write enable sequences
|
|
159
|
+
process.stdout.write(ENABLE_MOUSE);
|
|
160
|
+
this.stdinListener = (data) => {
|
|
161
|
+
const str = data.toString('utf-8');
|
|
162
|
+
let match;
|
|
163
|
+
SGR_MOUSE_RE.lastIndex = 0;
|
|
164
|
+
while ((match = SGR_MOUSE_RE.exec(str)) !== null) {
|
|
165
|
+
const btnCode = parseInt(match[1], 10);
|
|
166
|
+
const col = parseInt(match[2], 10) - 1; // 1-indexed → 0-indexed
|
|
167
|
+
const row = parseInt(match[3], 10) - 1;
|
|
168
|
+
const isPress = match[4] === 'M';
|
|
169
|
+
// Decode button
|
|
170
|
+
const baseBtn = btnCode & 0x03;
|
|
171
|
+
const isWheel = (btnCode & 0x40) !== 0;
|
|
172
|
+
const isMotion = (btnCode & 0x20) !== 0; // Bit 5 = motion
|
|
173
|
+
let button;
|
|
174
|
+
if (isWheel) {
|
|
175
|
+
button = baseBtn === 0 ? 'wheel-up' : 'wheel-down';
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
button = baseBtn === 0 ? 'left' : baseBtn === 1 ? 'middle' : 'right';
|
|
179
|
+
}
|
|
180
|
+
const action = isMotion ? 'drag' : (isPress ? 'press' : 'release');
|
|
181
|
+
const event = { button, action, col, row };
|
|
182
|
+
this.emit('mouse', event);
|
|
183
|
+
// ── Drag state machine (left button only) ──
|
|
184
|
+
if (button === 'left') {
|
|
185
|
+
this.handleLeftButton(action, row, col);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
process.stdin.on('data', this.stdinListener);
|
|
190
|
+
return () => this.disable();
|
|
191
|
+
}
|
|
192
|
+
handleLeftButton(action, row, col) {
|
|
193
|
+
switch (this.dragState) {
|
|
194
|
+
case 'idle':
|
|
195
|
+
if (action === 'press') {
|
|
196
|
+
this.dragState = 'pressing';
|
|
197
|
+
this.pressPos = { row, col };
|
|
198
|
+
this.dragPos = { row, col };
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
case 'pressing':
|
|
202
|
+
if (action === 'drag') {
|
|
203
|
+
// Movement detected — it's a drag, not a click
|
|
204
|
+
const dist = Math.abs(row - this.pressPos.row) + Math.abs(col - this.pressPos.col);
|
|
205
|
+
if (dist >= 2) { // Threshold to distinguish drag from click
|
|
206
|
+
this.dragState = 'dragging';
|
|
207
|
+
this.dragPos = { row, col };
|
|
208
|
+
this.emit('drag-start', { ...this.pressPos });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else if (action === 'release') {
|
|
212
|
+
// Press → release without drag = click
|
|
213
|
+
this.dragState = 'idle';
|
|
214
|
+
this.emit('click', { button: 'left', action: 'press', col, row });
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
case 'dragging':
|
|
218
|
+
if (action === 'drag') {
|
|
219
|
+
this.dragPos = { row, col };
|
|
220
|
+
this.emit('drag-move', { row, col });
|
|
221
|
+
}
|
|
222
|
+
else if (action === 'release') {
|
|
223
|
+
// Drag complete — extract text and copy to clipboard
|
|
224
|
+
const text = this.screen.getTextInRange(this.pressPos.row, this.pressPos.col, row, col);
|
|
225
|
+
this.dragState = 'idle';
|
|
226
|
+
if (text.length > 0) {
|
|
227
|
+
const copied = copyToClipboard(text);
|
|
228
|
+
const selection = {
|
|
229
|
+
startRow: this.pressPos.row,
|
|
230
|
+
startCol: this.pressPos.col,
|
|
231
|
+
endRow: row,
|
|
232
|
+
endCol: col,
|
|
233
|
+
text,
|
|
234
|
+
};
|
|
235
|
+
this.emit('selection', selection);
|
|
236
|
+
if (copied) {
|
|
237
|
+
this.emit('copied', { text, length: text.length });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Disable mouse tracking and clean up.
|
|
246
|
+
*/
|
|
247
|
+
disable() {
|
|
248
|
+
if (!this.enabled)
|
|
249
|
+
return;
|
|
250
|
+
this.enabled = false;
|
|
251
|
+
this.dragState = 'idle';
|
|
252
|
+
if (this.stdinListener) {
|
|
253
|
+
process.stdin.removeListener('data', this.stdinListener);
|
|
254
|
+
this.stdinListener = null;
|
|
255
|
+
}
|
|
256
|
+
this.screen.stop();
|
|
257
|
+
try {
|
|
258
|
+
process.stdout.write(DISABLE_MOUSE);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// Ignore write errors during cleanup
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
isEnabled() { return this.enabled; }
|
|
265
|
+
}
|
|
266
|
+
/** Singleton mouse manager. */
|
|
267
|
+
export const mouse = new MouseManager();
|
package/package.json
CHANGED