@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.
@@ -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 with their rationale (not just the decision)
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 that led to decisions only the decisions themselves
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
- [Key decisions made, each with its rationale]
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]
@@ -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: { workingDir: workDir, abortSignal: abort.signal, onAskUser: config.onAskUser },
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 (text already streamed)
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 with these rules:',
643
- '1. Resume directly no apology, no recap of what you already said. Pick up mid-sentence if that is where the cut happened.',
644
- '2. Do NOT repeat any text or code that was already output above.',
645
- '3. Break remaining work into smaller pieces use multiple tool calls if needed instead of one large output.',
646
- '4. Skip extended reasoning for the continuationfocus on executing.',
647
- '5. If you were in the middle of outputting code, finish the code block first.',
648
- ].join('\n'),
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);
@@ -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';
@@ -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 < 5; i++) {
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
- const preview = matchedLines.map(m => `${m.num}\t${m.text}`).join('\n');
105
- hint = `\n\nSimilar lines found:\n${preview}\n\nCheck for whitespace or formatting differences.`;
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
- const preview = lines.slice(0, 10).map((l, i) => `${i + 1}\t${l}`).join('\n');
109
- hint = `\n\nFirst 10 lines of file:\n${preview}`;
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}`,
@@ -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
- const systemPrompt = systemInstructions.join('\n\n');
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
  ];
@@ -58,7 +58,25 @@ async function execute(input, _ctx) {
58
58
  blocker.blocks.push(task.id);
59
59
  }
60
60
  }
61
- return { output: `Updated task #${task.id} status` };
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) {
@@ -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 — enable tracking and handle clicks on tool results
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 anywhere toggles the expandable tool (if one exists)
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
  }, []);
@@ -1,23 +1,37 @@
1
1
  /**
2
2
  * Mouse event support for Ink terminal UI.
3
- * Enables SGR extended mouse tracking (DECSET 1000+1006) and parses events from stdin.
4
- * Lightweight only handles clicks, not drag/hover/selection.
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. Call once on app startup.
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
- * Enables SGR extended mouse tracking (DECSET 1000+1006) and parses events from stdin.
4
- * Lightweight only handles clicks, not drag/hover/selection.
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
- // ─── Mouse Manager ───────────────────────────────────────────────────────
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. Call once on app startup.
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 event = {
52
- button,
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
- // Emit convenience events
59
- if (button === 'left' && isPress) {
60
- this.emit('click', event);
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
- // Best-effort: disable mouse tracking
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 (stdout may be closed)
261
+ // Ignore write errors during cleanup
84
262
  }
85
263
  }
86
264
  isEnabled() { return this.enabled; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.6.2",
3
+ "version": "3.6.4",
4
4
  "description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {