@blockrun/franklin 3.6.2 → 3.6.4
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/compact.js +3 -3
- package/dist/agent/loop.js +28 -9
- package/dist/agent/types.d.ts +5 -0
- package/dist/tools/edit.js +11 -5
- package/dist/tools/subagent.js +16 -1
- package/dist/tools/task.js +19 -1
- package/dist/tools/websearch.js +25 -0
- package/dist/ui/app.js +8 -5
- package/dist/ui/mouse.d.ts +19 -5
- package/dist/ui/mouse.js +198 -20
- package/package.json +1 -1
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 }),
|
|
@@ -634,18 +642,29 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
634
642
|
console.error(`[franklin] Max tokens hit — escalating to ${maxTokensOverride}`);
|
|
635
643
|
}
|
|
636
644
|
}
|
|
637
|
-
// Append what we got + a continuation prompt
|
|
645
|
+
// Append what we got + a continuation prompt with last-line anchor
|
|
638
646
|
const partialAssistant = { role: 'assistant', content: responseParts };
|
|
647
|
+
// Extract last line of output to give the model a concrete resume point
|
|
648
|
+
const textParts = responseParts.filter(p => p.type === 'text');
|
|
649
|
+
const lastTextBlock = textParts[textParts.length - 1];
|
|
650
|
+
let lastLineAnchor = '';
|
|
651
|
+
if (lastTextBlock && lastTextBlock.type === 'text') {
|
|
652
|
+
const lastLine = lastTextBlock.text.split('\n').filter(l => l.trim()).pop() ?? '';
|
|
653
|
+
if (lastLine.length > 10) {
|
|
654
|
+
lastLineAnchor = `\nYour output ended with: "${lastLine.slice(0, 120)}"\nResume immediately after that point.`;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
639
657
|
const continuationPrompt = {
|
|
640
658
|
role: 'user',
|
|
641
659
|
content: [
|
|
642
|
-
'Output token limit hit. Continue
|
|
643
|
-
'1. Resume
|
|
644
|
-
'2. Do NOT repeat
|
|
645
|
-
'3.
|
|
646
|
-
'4.
|
|
647
|
-
'5.
|
|
648
|
-
|
|
660
|
+
'Output token limit hit. Continue:',
|
|
661
|
+
'1. Resume exactly where you stopped — your prior output is visible above.',
|
|
662
|
+
'2. Do NOT repeat, summarize, or recap anything already output.',
|
|
663
|
+
'3. If mid-code-block, continue the same block without restarting.',
|
|
664
|
+
'4. Prefer tool calls (Write, Edit) over large text output — they are more token-efficient.',
|
|
665
|
+
'5. Be concise — skip explanations, focus on completing the work.',
|
|
666
|
+
lastLineAnchor,
|
|
667
|
+
].filter(l => l).join('\n'),
|
|
649
668
|
};
|
|
650
669
|
history.push(partialAssistant);
|
|
651
670
|
persistSessionMessage(partialAssistant);
|
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/tools/edit.js
CHANGED
|
@@ -93,7 +93,7 @@ async function execute(input, ctx) {
|
|
|
93
93
|
const searchTerms = oldStr.split('\n').map(l => l.trim()).filter(l => l.length > 3);
|
|
94
94
|
const matchedLines = [];
|
|
95
95
|
if (searchTerms.length > 0) {
|
|
96
|
-
for (let i = 0; i < lines.length && matchedLines.length <
|
|
96
|
+
for (let i = 0; i < lines.length && matchedLines.length < 8; i++) {
|
|
97
97
|
if (searchTerms.some(term => lines[i].includes(term))) {
|
|
98
98
|
matchedLines.push({ num: i + 1, text: lines[i] });
|
|
99
99
|
}
|
|
@@ -101,12 +101,18 @@ async function execute(input, ctx) {
|
|
|
101
101
|
}
|
|
102
102
|
let hint;
|
|
103
103
|
if (matchedLines.length > 0) {
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
// Show matched lines with 1 line of context above for better orientation
|
|
105
|
+
const preview = matchedLines.map(m => {
|
|
106
|
+
const above = m.num > 1 ? ` ${m.num - 1}\t${lines[m.num - 2].slice(0, 80)}\n` : '';
|
|
107
|
+
return `${above}→ ${m.num}\t${m.text}`;
|
|
108
|
+
}).join('\n');
|
|
109
|
+
hint = `\n\nLines containing fragments of your old_string (${matchedLines.length} found):\n${preview}\n\nThe old_string must match EXACTLY — check indentation, quotes, and whitespace. Use Read to see the full region.`;
|
|
106
110
|
}
|
|
107
111
|
else {
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
// No matches — show the middle of the file (more useful than first 10 lines)
|
|
113
|
+
const mid = Math.max(0, Math.floor(lines.length / 2) - 5);
|
|
114
|
+
const preview = lines.slice(mid, mid + 12).map((l, i) => `${mid + i + 1}\t${l}`).join('\n');
|
|
115
|
+
hint = `\n\nNo matching fragments found in ${lines.length}-line file. Lines ${mid + 1}-${mid + 12}:\n${preview}\n\nUse Read to find the correct text.`;
|
|
110
116
|
}
|
|
111
117
|
return {
|
|
112
118
|
output: `Error: old_string not found in ${resolved}.${hint}`,
|
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/task.js
CHANGED
|
@@ -58,7 +58,25 @@ async function execute(input, _ctx) {
|
|
|
58
58
|
blocker.blocks.push(task.id);
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
-
|
|
61
|
+
// Rich feedback: show status transition and dependency impact
|
|
62
|
+
let feedback = `Updated task #${task.id}`;
|
|
63
|
+
if (status) {
|
|
64
|
+
feedback += ` → ${status}`;
|
|
65
|
+
// If completed, show which tasks are now unblocked
|
|
66
|
+
if (status === 'completed' && task.blocks.length > 0) {
|
|
67
|
+
const nowUnblocked = task.blocks
|
|
68
|
+
.map(id => tasks.find(t => t.id === id))
|
|
69
|
+
.filter(t => t && t.blockedBy.every(bid => {
|
|
70
|
+
const blocker = tasks.find(bt => bt.id === bid);
|
|
71
|
+
return blocker?.status === 'completed';
|
|
72
|
+
}))
|
|
73
|
+
.map(t => `#${t.id} ${t.subject}`);
|
|
74
|
+
if (nowUnblocked.length > 0) {
|
|
75
|
+
feedback += ` — unblocked: ${nowUnblocked.join(', ')}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { output: feedback };
|
|
62
80
|
}
|
|
63
81
|
case 'list': {
|
|
64
82
|
if (tasks.length === 0) {
|
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
|
@@ -395,19 +395,22 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
395
395
|
turnSavingsRef.current = undefined;
|
|
396
396
|
onSubmit(trimmed);
|
|
397
397
|
}, [ready, currentModel, totalCost, onSubmit, onModelChange, onAbort, onExit, exit, lastPrompt, inputHistory, showStatus]);
|
|
398
|
-
// Mouse support —
|
|
398
|
+
// Mouse support — clicks toggle tool results, drag selects text
|
|
399
399
|
useEffect(() => {
|
|
400
400
|
const cleanup = mouse.enable();
|
|
401
401
|
const handleClick = (_event) => {
|
|
402
|
-
// Click
|
|
403
|
-
// This is intentionally simple — we don't track exact coordinates of components.
|
|
404
|
-
// The expandable tool is always the most recent tool result, so any click is a
|
|
405
|
-
// reasonable toggle target. Tab key remains the precise alternative.
|
|
402
|
+
// Click: toggle expandable tool
|
|
406
403
|
setExpandableTool(prev => prev ? { ...prev, expanded: !prev.expanded } : null);
|
|
407
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
|
+
};
|
|
408
409
|
mouse.on('click', handleClick);
|
|
410
|
+
mouse.on('copied', handleCopied);
|
|
409
411
|
return () => {
|
|
410
412
|
mouse.removeListener('click', handleClick);
|
|
413
|
+
mouse.removeListener('copied', handleCopied);
|
|
411
414
|
cleanup();
|
|
412
415
|
};
|
|
413
416
|
}, []);
|
package/dist/ui/mouse.d.ts
CHANGED
|
@@ -1,23 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mouse event support for Ink terminal UI.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
5
8
|
*/
|
|
6
9
|
import { EventEmitter } from 'node:events';
|
|
7
10
|
export interface MouseEvent {
|
|
8
11
|
button: 'left' | 'middle' | 'right' | 'wheel-up' | 'wheel-down';
|
|
9
|
-
action: 'press' | 'release';
|
|
12
|
+
action: 'press' | 'release' | 'drag';
|
|
10
13
|
col: number;
|
|
11
14
|
row: number;
|
|
12
15
|
}
|
|
16
|
+
export interface Selection {
|
|
17
|
+
startRow: number;
|
|
18
|
+
startCol: number;
|
|
19
|
+
endRow: number;
|
|
20
|
+
endCol: number;
|
|
21
|
+
text: string;
|
|
22
|
+
}
|
|
13
23
|
declare class MouseManager extends EventEmitter {
|
|
14
24
|
private enabled;
|
|
15
25
|
private stdinListener;
|
|
26
|
+
private screen;
|
|
27
|
+
private dragState;
|
|
28
|
+
private pressPos;
|
|
29
|
+
private dragPos;
|
|
16
30
|
/**
|
|
17
|
-
* Enable mouse tracking
|
|
18
|
-
* Returns cleanup function to call on unmount.
|
|
31
|
+
* Enable mouse tracking + screen buffer. Returns cleanup function.
|
|
19
32
|
*/
|
|
20
33
|
enable(): () => void;
|
|
34
|
+
private handleLeftButton;
|
|
21
35
|
/**
|
|
22
36
|
* Disable mouse tracking and clean up.
|
|
23
37
|
*/
|
package/dist/ui/mouse.js
CHANGED
|
@@ -1,34 +1,162 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mouse event support for Ink terminal UI.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
5
8
|
*/
|
|
6
9
|
import { EventEmitter } from 'node:events';
|
|
10
|
+
import { execSync } from 'node:child_process';
|
|
7
11
|
// ─── Terminal escape sequences ────────────────────────────────────────────
|
|
8
12
|
const ENABLE_MOUSE = '\x1b[?1000h' + // Normal mouse tracking (clicks + wheel)
|
|
13
|
+
'\x1b[?1002h' + // Button-motion tracking (drag events)
|
|
9
14
|
'\x1b[?1006h'; // SGR extended format (readable coordinates)
|
|
10
15
|
const DISABLE_MOUSE = '\x1b[?1006l' +
|
|
16
|
+
'\x1b[?1002l' +
|
|
11
17
|
'\x1b[?1000l';
|
|
12
18
|
// SGR mouse event format: ESC [ < button ; col ; row M (press) or m (release)
|
|
13
19
|
const SGR_MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
14
|
-
//
|
|
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
|
+
}
|
|
15
141
|
class MouseManager extends EventEmitter {
|
|
16
142
|
enabled = false;
|
|
17
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 };
|
|
18
149
|
/**
|
|
19
|
-
* Enable mouse tracking
|
|
20
|
-
* Returns cleanup function to call on unmount.
|
|
150
|
+
* Enable mouse tracking + screen buffer. Returns cleanup function.
|
|
21
151
|
*/
|
|
22
152
|
enable() {
|
|
23
153
|
if (this.enabled)
|
|
24
154
|
return () => { };
|
|
25
155
|
this.enabled = true;
|
|
156
|
+
// Start screen buffer capture
|
|
157
|
+
this.screen.start();
|
|
26
158
|
// Write enable sequences
|
|
27
159
|
process.stdout.write(ENABLE_MOUSE);
|
|
28
|
-
// Listen on stdin for mouse sequences
|
|
29
|
-
// We use 'data' event at a higher priority than Ink's handler.
|
|
30
|
-
// Mouse sequences that we parse are still passed to Ink (we can't consume them),
|
|
31
|
-
// but Ink will ignore unrecognized escape sequences.
|
|
32
160
|
this.stdinListener = (data) => {
|
|
33
161
|
const str = data.toString('utf-8');
|
|
34
162
|
let match;
|
|
@@ -41,6 +169,7 @@ class MouseManager extends EventEmitter {
|
|
|
41
169
|
// Decode button
|
|
42
170
|
const baseBtn = btnCode & 0x03;
|
|
43
171
|
const isWheel = (btnCode & 0x40) !== 0;
|
|
172
|
+
const isMotion = (btnCode & 0x20) !== 0; // Bit 5 = motion
|
|
44
173
|
let button;
|
|
45
174
|
if (isWheel) {
|
|
46
175
|
button = baseBtn === 0 ? 'wheel-up' : 'wheel-down';
|
|
@@ -48,22 +177,70 @@ class MouseManager extends EventEmitter {
|
|
|
48
177
|
else {
|
|
49
178
|
button = baseBtn === 0 ? 'left' : baseBtn === 1 ? 'middle' : 'right';
|
|
50
179
|
}
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
action: isPress ? 'press' : 'release',
|
|
54
|
-
col,
|
|
55
|
-
row,
|
|
56
|
-
};
|
|
180
|
+
const action = isMotion ? 'drag' : (isPress ? 'press' : 'release');
|
|
181
|
+
const event = { button, action, col, row };
|
|
57
182
|
this.emit('mouse', event);
|
|
58
|
-
//
|
|
59
|
-
if (button === 'left'
|
|
60
|
-
this.
|
|
183
|
+
// ── Drag state machine (left button only) ──
|
|
184
|
+
if (button === 'left') {
|
|
185
|
+
this.handleLeftButton(action, row, col);
|
|
61
186
|
}
|
|
62
187
|
}
|
|
63
188
|
};
|
|
64
189
|
process.stdin.on('data', this.stdinListener);
|
|
65
190
|
return () => this.disable();
|
|
66
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
|
+
}
|
|
67
244
|
/**
|
|
68
245
|
* Disable mouse tracking and clean up.
|
|
69
246
|
*/
|
|
@@ -71,16 +248,17 @@ class MouseManager extends EventEmitter {
|
|
|
71
248
|
if (!this.enabled)
|
|
72
249
|
return;
|
|
73
250
|
this.enabled = false;
|
|
251
|
+
this.dragState = 'idle';
|
|
74
252
|
if (this.stdinListener) {
|
|
75
253
|
process.stdin.removeListener('data', this.stdinListener);
|
|
76
254
|
this.stdinListener = null;
|
|
77
255
|
}
|
|
78
|
-
|
|
256
|
+
this.screen.stop();
|
|
79
257
|
try {
|
|
80
258
|
process.stdout.write(DISABLE_MOUSE);
|
|
81
259
|
}
|
|
82
260
|
catch {
|
|
83
|
-
// Ignore write errors during cleanup
|
|
261
|
+
// Ignore write errors during cleanup
|
|
84
262
|
}
|
|
85
263
|
}
|
|
86
264
|
isEnabled() { return this.enabled; }
|
package/package.json
CHANGED