@causyne/agent 1.10.21 → 1.11.0
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/native-skills/media/professional-document-publishing/SKILL.md +44 -0
- package/native-skills/media/screenplay-and-shotlist-forge/SKILL.md +42 -0
- package/package.json +1 -1
- package/src/acp/client.js +235 -0
- package/src/acp/client.ts +282 -0
- package/src/acp/peerProfiles.js +122 -0
- package/src/acp/peerProfiles.ts +128 -0
- package/src/acp/protocol.js +54 -0
- package/src/acp/protocol.ts +434 -0
- package/src/acp/server.js +609 -0
- package/src/acp/server.ts +676 -0
- package/src/agentLoop.d.ts +43 -0
- package/src/agentLoop.js +142 -8
- package/src/commands.js +203 -0
- package/src/compiledSkills.js +10 -0
- package/src/documentEngine/antiSlopAuditor.js +156 -0
- package/src/documentEngine/antiSlopAuditor.ts +221 -0
- package/src/documentEngine/documentForge.js +443 -0
- package/src/documentEngine/documentForge.ts +519 -0
- package/src/documentEngine/screenplayFormatter.js +336 -0
- package/src/documentEngine/screenplayFormatter.ts +407 -0
- package/src/entrypoint.js +13 -0
- package/src/execSafe.js +10 -13
- package/src/execSafe.ts +25 -0
- package/src/modelRouter.js +56 -123
- package/src/modelRouter.ts +134 -0
- package/src/observer/advisory.js +164 -0
- package/src/observer/advisory.ts +171 -0
- package/src/observer/coldObserver.js +462 -0
- package/src/observer/coldObserver.ts +540 -0
- package/src/observer/evidence.js +435 -0
- package/src/observer/evidence.ts +499 -0
- package/src/perception.js +303 -118
- package/src/perception.ts +387 -0
- package/src/procSignal.js +15 -16
- package/src/procSignal.ts +31 -0
- package/src/promptMentions.js +170 -0
- package/src/promptMentions.ts +212 -0
- package/src/sandbox.js +1 -1
- package/src/textDiff.js +117 -109
- package/src/textDiff.ts +169 -0
- package/src/toolPacks.js +5 -2
- package/src/tools/documentForge.js +215 -0
- package/src/tools/documentForge.ts +265 -0
- package/src/tools/peerAgent.js +102 -0
- package/src/tools/peerAgent.ts +116 -0
- package/src/tools/registry.js +3 -1
- package/src/tui/completion.js +97 -41
- package/src/tui/prompt.js +17 -17
- package/src/uncertainty.js +93 -205
- package/src/uncertainty.ts +255 -0
- package/src/utils/paths.js +15 -18
- package/src/utils/paths.ts +36 -0
- package/src/vfeTracker.js +122 -181
- package/src/vfeTracker.ts +242 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Causyne Peer Agent Tool
|
|
3
|
+
*
|
|
4
|
+
* Delegates a bounded task to an external ACP agent that the user configured
|
|
5
|
+
* in `~/.causyne/peers.json`. The model chooses a profile and writes a prompt;
|
|
6
|
+
* it never chooses the executable, arguments, working directory, or
|
|
7
|
+
* environment. Those come from the user's file and nowhere else.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { AcpClient } from '../acp/client.js';
|
|
11
|
+
import type { RequestPermissionRequest, RequestPermissionResponse } from '../acp/protocol.js';
|
|
12
|
+
import { getPeerProfile, loadPeerProfiles, peerEnvironment } from '../acp/peerProfiles.js';
|
|
13
|
+
import type { PeerProfile } from '../acp/peerProfiles.js';
|
|
14
|
+
|
|
15
|
+
export interface PeerAgentArgs {
|
|
16
|
+
peer?: string;
|
|
17
|
+
prompt: string;
|
|
18
|
+
timeoutSeconds?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MAX_PROMPT_CHARS = 100_000;
|
|
22
|
+
const READ_ONLY_KINDS = new Set(['read', 'search', 'fetch', 'think']);
|
|
23
|
+
|
|
24
|
+
function selectPermission(profile: PeerProfile, request: RequestPermissionRequest): RequestPermissionResponse['outcome'] {
|
|
25
|
+
const kind = request.toolCall.kind;
|
|
26
|
+
const readLike = typeof kind === 'string' && READ_ONLY_KINDS.has(kind);
|
|
27
|
+
if (profile.policy === 'read-only' && !readLike) {
|
|
28
|
+
const reject = request.options.find((option) => option.kind === 'reject_once' || option.kind === 'reject_always');
|
|
29
|
+
return reject ? { outcome: 'selected', optionId: reject.optionId } : { outcome: 'cancelled' };
|
|
30
|
+
}
|
|
31
|
+
const allow = request.options.find((option) => option.kind === 'allow_once' || option.kind === 'allow_always');
|
|
32
|
+
return allow ? { outcome: 'selected', optionId: allow.optionId } : { outcome: 'cancelled' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const peerAgentTool = {
|
|
36
|
+
schema: {
|
|
37
|
+
name: 'peer_agent_delegate',
|
|
38
|
+
description: 'Delegate a bounded task to a peer ACP agent that the user configured in the peers file. The model supplies the peer id and the prompt only; the executable, arguments, working directory, environment, and permission policy come from the user configuration. Peers default to rejecting mutating permission requests; the external agent decides how to honor that policy.',
|
|
39
|
+
parameters: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
properties: {
|
|
42
|
+
peer: {
|
|
43
|
+
type: 'string',
|
|
44
|
+
description: 'Id of a configured peer profile from the peers file.'
|
|
45
|
+
},
|
|
46
|
+
prompt: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
description: 'Task instructions and context for the peer agent.'
|
|
49
|
+
},
|
|
50
|
+
timeoutSeconds: {
|
|
51
|
+
type: 'number',
|
|
52
|
+
description: 'Optional shorter timeout in seconds, bounded by the profile timeout.'
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
required: ['peer', 'prompt']
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
async execute(args: PeerAgentArgs): Promise<{ success: boolean; output?: string; error?: string }> {
|
|
59
|
+
const peerId = String(args.peer || '').trim();
|
|
60
|
+
const prompt = String(args.prompt || '').trim().slice(0, MAX_PROMPT_CHARS);
|
|
61
|
+
if (!peerId) {
|
|
62
|
+
const configured = loadPeerProfiles().map((profile) => profile.id);
|
|
63
|
+
return {
|
|
64
|
+
success: false,
|
|
65
|
+
error: configured.length > 0
|
|
66
|
+
? `A peer id is required. Configured peers: ${configured.join(', ')}.`
|
|
67
|
+
: 'No peer profiles are configured. Add one to the peers file first.'
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (!prompt) {
|
|
71
|
+
return { success: false, error: 'A prompt is required for peer delegation.' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const profile = getPeerProfile(peerId);
|
|
75
|
+
if (!profile) {
|
|
76
|
+
return { success: false, error: `Unknown peer profile: ${peerId}.` };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const requested = typeof args.timeoutSeconds === 'number' && Number.isFinite(args.timeoutSeconds)
|
|
80
|
+
? Math.round(args.timeoutSeconds)
|
|
81
|
+
: profile.timeoutSeconds;
|
|
82
|
+
const timeoutSeconds = Math.max(5, Math.min(Math.min(profile.timeoutSeconds, requested), 1800));
|
|
83
|
+
|
|
84
|
+
const client = new AcpClient({
|
|
85
|
+
command: profile.command,
|
|
86
|
+
args: profile.args,
|
|
87
|
+
cwd: profile.cwd ?? process.cwd(),
|
|
88
|
+
env: peerEnvironment(profile),
|
|
89
|
+
promptTimeoutMs: timeoutSeconds * 1000,
|
|
90
|
+
permissionHandler: async (request) => selectPermission(profile, request)
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await client.start();
|
|
95
|
+
const sessionId = await client.newSession(profile.cwd ?? process.cwd());
|
|
96
|
+
const result = await client.prompt(sessionId, prompt);
|
|
97
|
+
if (result.stopReason === 'cancelled') {
|
|
98
|
+
return { success: false, error: 'The peer agent cancelled the task.' };
|
|
99
|
+
}
|
|
100
|
+
if (result.stopReason === 'refusal') {
|
|
101
|
+
return { success: false, error: 'The peer agent refused the task.' };
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
success: true,
|
|
105
|
+
output: result.text.trim() || 'Peer agent completed the task without a written summary.'
|
|
106
|
+
};
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return {
|
|
109
|
+
success: false,
|
|
110
|
+
error: error instanceof Error ? error.message : 'Peer agent delegation failed.'
|
|
111
|
+
};
|
|
112
|
+
} finally {
|
|
113
|
+
await client.close();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
package/src/tools/registry.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { creativeApps } from './creativeApps.js';
|
|
2
2
|
import { computerUse } from './computerUse.js';
|
|
3
3
|
import { uiForge } from './uiForge.js';
|
|
4
|
+
import { documentForgeTool } from './documentForge.js';
|
|
5
|
+
import { peerAgentTool } from './peerAgent.js';
|
|
4
6
|
import { sysRun, sysRunBg } from './sysRun.js';
|
|
5
7
|
import { fsRead } from './fsRead.js';
|
|
6
8
|
import { fsWrite } from './fsWrite.js';
|
|
@@ -123,7 +125,7 @@ const TOOL_MODULES = [
|
|
|
123
125
|
gameShaderForge, gameSceneBuilder, gameEntitySpawner,
|
|
124
126
|
gameTilemapGenerator, gameUiHudBuilder, gameEconomyBalancer,
|
|
125
127
|
gameDataForge, gameWebRuntimeInspector, gameCanvasAudit, readNativeSkill, createSkill, creativeApps,
|
|
126
|
-
computerUse, uiForge
|
|
128
|
+
computerUse, uiForge, documentForgeTool, peerAgentTool
|
|
127
129
|
];
|
|
128
130
|
|
|
129
131
|
// Base static definitions (backwards compatible). Order is preserved from the
|
package/src/tui/completion.js
CHANGED
|
@@ -59,6 +59,7 @@ export function score(candidate, query) {
|
|
|
59
59
|
|
|
60
60
|
const base = haystack.slice(haystack.lastIndexOf('/') + 1);
|
|
61
61
|
if (base.startsWith(needle)) return 800 - candidate.length;
|
|
62
|
+
if (haystack.includes(needle)) return 700 - candidate.length;
|
|
62
63
|
if (base.includes(needle)) return 600 - candidate.length;
|
|
63
64
|
|
|
64
65
|
// Subsequence: every character of the query in order, gaps allowed. This is
|
|
@@ -79,17 +80,25 @@ export function score(candidate, query) {
|
|
|
79
80
|
/**
|
|
80
81
|
* Score, sort and cap — and report how many matched BEFORE the cap.
|
|
81
82
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* A count that is wrong is worse than no count: it tells the user to stop
|
|
86
|
-
* looking. The total travels with the items so the caller can say the truth.
|
|
83
|
+
* If focusDir is supplied (e.g. active subproject like 'pixel2d games'),
|
|
84
|
+
* items belonging to that directory receive a priority boost so the user's
|
|
85
|
+
* active workspace files are never buried by unrelated paths.
|
|
87
86
|
*/
|
|
88
|
-
function rank(items, query, limit = 50) {
|
|
87
|
+
function rank(items, query, limit = 50, focusDir = null) {
|
|
89
88
|
const scored = [];
|
|
89
|
+
const focusPrefix = focusDir
|
|
90
|
+
? (focusDir.endsWith('/') ? focusDir.toLowerCase() : focusDir.toLowerCase() + '/')
|
|
91
|
+
: null;
|
|
92
|
+
|
|
90
93
|
for (const item of items) {
|
|
91
|
-
const
|
|
92
|
-
|
|
94
|
+
const val = item.match !== undefined ? item.match : item.value;
|
|
95
|
+
let value = score(val, query);
|
|
96
|
+
if (value >= 0) {
|
|
97
|
+
if (focusPrefix && val.toLowerCase().startsWith(focusPrefix)) {
|
|
98
|
+
value += 3000;
|
|
99
|
+
}
|
|
100
|
+
scored.push({ item, value });
|
|
101
|
+
}
|
|
93
102
|
}
|
|
94
103
|
scored.sort((a, b) => b.value - a.value);
|
|
95
104
|
const ranked = scored.slice(0, limit).map((entry) => entry.item);
|
|
@@ -227,6 +236,8 @@ export class FileIndex {
|
|
|
227
236
|
if (entry.isDirectory()) {
|
|
228
237
|
if (SKIP_DIRECTORIES.has(entry.name) || depth >= MAX_INDEX_DEPTH) continue;
|
|
229
238
|
queue.push({ dir: full, depth: depth + 1 });
|
|
239
|
+
const relDir = path.relative(this.root, full) + '/';
|
|
240
|
+
if (isTerminalSafe(relDir)) found.push(relDir);
|
|
230
241
|
continue;
|
|
231
242
|
}
|
|
232
243
|
if (!entry.isFile()) continue;
|
|
@@ -321,10 +332,26 @@ export function detectTrigger(text, cursor) {
|
|
|
321
332
|
};
|
|
322
333
|
}
|
|
323
334
|
|
|
324
|
-
|
|
335
|
+
// 1. Quoted @ file mention: @"pixel2d games/ or @'pixel2d games/
|
|
336
|
+
// Supports spaces in directory or file names
|
|
337
|
+
const quotedMention = before.match(/(?:^|\s)@(["'])([^"']*)$/);
|
|
338
|
+
if (quotedMention) {
|
|
339
|
+
const quote = quotedMention[1];
|
|
340
|
+
const query = quotedMention[2];
|
|
341
|
+
return {
|
|
342
|
+
kind: 'file',
|
|
343
|
+
quote,
|
|
344
|
+
start: cursor - query.length - 2, // accounts for @ and quote char
|
|
345
|
+
query
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 2. Unquoted @ file mention: @alpha or @src/
|
|
350
|
+
const mention = before.match(/(?:^|\s)@([^\s@"']*)$/);
|
|
325
351
|
if (mention) {
|
|
326
352
|
return {
|
|
327
353
|
kind: 'file',
|
|
354
|
+
quote: null,
|
|
328
355
|
start: cursor - mention[1].length - 1,
|
|
329
356
|
query: mention[1]
|
|
330
357
|
};
|
|
@@ -356,15 +383,41 @@ export function suggest(text, cursor, options = {}) {
|
|
|
356
383
|
|
|
357
384
|
const root = options.root || process.cwd();
|
|
358
385
|
const index = options.index;
|
|
386
|
+
const focusDir = options.focusDir || null;
|
|
359
387
|
if (index && !index.isReady) index.ensure();
|
|
360
388
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
389
|
+
let pool;
|
|
390
|
+
if (index && index.isReady && trigger.query) {
|
|
391
|
+
pool = index.entries.map((relative) => ({
|
|
392
|
+
value: relative,
|
|
393
|
+
match: relative,
|
|
394
|
+
label: relative,
|
|
395
|
+
description: relative.endsWith('/') ? 'directory' : ''
|
|
396
|
+
}));
|
|
397
|
+
} else {
|
|
398
|
+
pool = directoryItems(root, trigger.query);
|
|
399
|
+
if (focusDir) {
|
|
400
|
+
const focusTarget = path.resolve(root, focusDir);
|
|
401
|
+
try {
|
|
402
|
+
if (fs.existsSync(focusTarget) && fs.statSync(focusTarget).isDirectory()) {
|
|
403
|
+
const focusedItems = directoryItems(focusTarget, trigger.query).map((item) => ({
|
|
404
|
+
...item,
|
|
405
|
+
value: `${focusDir}/${item.value}`,
|
|
406
|
+
match: `${focusDir}/${item.value}`,
|
|
407
|
+
label: `${focusDir}/${item.label}`
|
|
408
|
+
}));
|
|
409
|
+
pool = pool.concat(focusedItems);
|
|
410
|
+
}
|
|
411
|
+
} catch (e) {}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// If index had no ready matches for a path with slashes, fall back to directory items
|
|
416
|
+
if (pool.length === 0 && trigger.query.includes('/')) {
|
|
417
|
+
pool = directoryItems(root, trigger.query);
|
|
418
|
+
}
|
|
366
419
|
|
|
367
|
-
const items = rank(pool, trigger.query
|
|
420
|
+
const items = rank(pool, trigger.query, 50, focusDir);
|
|
368
421
|
if (items.length === 0) return null;
|
|
369
422
|
return { ...trigger, items, total: items.total };
|
|
370
423
|
}
|
|
@@ -374,39 +427,42 @@ export function applyCompletion(text, cursor, trigger, item) {
|
|
|
374
427
|
if (trigger.kind === 'command-argument') {
|
|
375
428
|
const head = text.slice(0, trigger.start);
|
|
376
429
|
const tail = text.slice(cursor);
|
|
377
|
-
|
|
378
|
-
// A COMPLETE argument gets a trailing space; a partial one does not.
|
|
379
|
-
//
|
|
380
|
-
// This was written the other way round, on the theory that a space would
|
|
381
|
-
// reopen the picker on a value still being edited. It does not — a
|
|
382
|
-
// completed argument followed by a space matches no trigger at all. What it
|
|
383
|
-
// actually did was run the user's next words straight into the argument:
|
|
384
|
-
// accepting `python/data-processing-pipelines` and typing a question gave
|
|
385
|
-
// `/skills python/data-processing-pipelineswhy my data is like this ?` and
|
|
386
|
-
// a "skill not found" error.
|
|
387
|
-
//
|
|
388
|
-
// The distinction is the same one file completion already makes between a
|
|
389
|
-
// directory and a file: a domain (`devops`) is a place you may want to
|
|
390
|
-
// descend into with `/`, so it gets no space; a full `domain/skill` id is
|
|
391
|
-
// finished, so it gets one and the next thing typed is the instruction.
|
|
392
430
|
const complete = item.value.includes('/');
|
|
393
431
|
const replaced = head + item.value + (complete ? ' ' : '');
|
|
394
432
|
return { text: replaced + tail, cursor: replaced.length };
|
|
395
433
|
}
|
|
396
434
|
|
|
397
|
-
|
|
435
|
+
if (trigger.kind === 'command') {
|
|
436
|
+
const head = text.slice(0, trigger.start);
|
|
437
|
+
const tail = text.slice(cursor);
|
|
438
|
+
const replaced = head + item.value + ' ';
|
|
439
|
+
return { text: replaced + tail, cursor: replaced.length };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// trigger.kind === 'file'
|
|
398
443
|
const head = text.slice(0, trigger.start);
|
|
399
444
|
const tail = text.slice(cursor);
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
445
|
+
const isDir = item.value.endsWith('/');
|
|
446
|
+
const hasSpace = item.value.includes(' ');
|
|
447
|
+
const quote = trigger.quote || (hasSpace ? '"' : '');
|
|
448
|
+
|
|
449
|
+
let value;
|
|
450
|
+
let suffix;
|
|
451
|
+
if (quote) {
|
|
452
|
+
if (isDir) {
|
|
453
|
+
// Drilling into directory: open quote without closing quote, no trailing space
|
|
454
|
+
value = `@${quote}${item.value}`;
|
|
455
|
+
suffix = '';
|
|
456
|
+
} else {
|
|
457
|
+
// Complete file: close quote and add trailing space
|
|
458
|
+
value = `@${quote}${item.value}${quote}`;
|
|
459
|
+
suffix = ' ';
|
|
460
|
+
}
|
|
461
|
+
} else {
|
|
462
|
+
value = '@' + item.value;
|
|
463
|
+
suffix = isDir ? '' : ' ';
|
|
464
|
+
}
|
|
465
|
+
|
|
410
466
|
const replaced = head + value + suffix;
|
|
411
467
|
return { text: replaced + tail, cursor: replaced.length };
|
|
412
468
|
}
|
package/src/tui/prompt.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { KeyDecoder, bracketedPaste } from './keys.js';
|
|
21
21
|
import { LineEditor } from './lineEditor.js';
|
|
22
22
|
import { PromptHistory } from './history.js';
|
|
23
|
-
import { FileIndex, suggest, applyCompletion, renderPicker, MAX_VISIBLE } from './completion.js';
|
|
23
|
+
import { FileIndex, suggest, applyCompletion, detectTrigger, renderPicker, MAX_VISIBLE } from './completion.js';
|
|
24
24
|
import { paint, dim, bold, GLYPHS, terminalWidth, HAS_COLOR } from './theme.js';
|
|
25
25
|
import { barBlock, GUTTER } from './block.js';
|
|
26
26
|
import { visibleWidth, truncate, wrapBlock, shortenHome, safeDisplay } from './text.js';
|
|
@@ -84,6 +84,7 @@ export class ChatPrompt {
|
|
|
84
84
|
this.out = options.out || process.stdout;
|
|
85
85
|
this.input = options.input || process.stdin;
|
|
86
86
|
this.root = options.root || process.cwd();
|
|
87
|
+
this.focusDir = options.focusDir || null;
|
|
87
88
|
this.onInterrupt = options.onInterrupt || (() => {});
|
|
88
89
|
|
|
89
90
|
// The Stage, when one is running. Null means the scrollback renderer, which
|
|
@@ -920,6 +921,14 @@ export class ChatPrompt {
|
|
|
920
921
|
// Completion
|
|
921
922
|
// -------------------------------------------------------------------------
|
|
922
923
|
|
|
924
|
+
/** Dynamically re-target the prompt root and optional focused subproject. */
|
|
925
|
+
setWorkingRoot(newRoot, focusDir = null) {
|
|
926
|
+
this.root = newRoot;
|
|
927
|
+
this.focusDir = focusDir;
|
|
928
|
+
this.index = new FileIndex(newRoot);
|
|
929
|
+
this.index.ensure();
|
|
930
|
+
}
|
|
931
|
+
|
|
923
932
|
cursorOffset() {
|
|
924
933
|
const before = this.editor.lines
|
|
925
934
|
.slice(0, this.editor.line)
|
|
@@ -929,13 +938,14 @@ export class ChatPrompt {
|
|
|
929
938
|
|
|
930
939
|
openCompletion(force = false) {
|
|
931
940
|
const text = this.editor.getText();
|
|
932
|
-
const result = suggest(text, this.cursorOffset(), {
|
|
941
|
+
const result = suggest(text, this.cursorOffset(), {
|
|
942
|
+
root: this.root,
|
|
943
|
+
index: this.index,
|
|
944
|
+
focusDir: this.focusDir
|
|
945
|
+
});
|
|
933
946
|
if (!result) {
|
|
934
947
|
if (force) this.notice = 'nothing to complete here';
|
|
935
|
-
// Close a picker whose trigger has gone.
|
|
936
|
-
// this ("close it when the trigger is gone") but this early return never
|
|
937
|
-
// did it — so typing past the end of an argument left the suggestions on
|
|
938
|
-
// screen, still offering matches for text that was no longer there.
|
|
948
|
+
// Close a picker whose trigger has gone.
|
|
939
949
|
this.completion = null;
|
|
940
950
|
return;
|
|
941
951
|
}
|
|
@@ -946,19 +956,9 @@ export class ChatPrompt {
|
|
|
946
956
|
/** Re-run completion after an edit; close it when the trigger is gone. */
|
|
947
957
|
refreshCompletion() {
|
|
948
958
|
if (!this.completion) {
|
|
949
|
-
// `/` at the start and `@` anywhere open the picker without a keystroke,
|
|
950
|
-
// which is the behaviour both openclaw and opencode settled on.
|
|
951
959
|
const text = this.editor.getText();
|
|
952
960
|
const offset = this.cursorOffset();
|
|
953
|
-
const
|
|
954
|
-
// `/` at the start, `@` anywhere, and the argument of a slash command all
|
|
955
|
-
// open the picker without a keystroke. The third is what makes `/skills d`
|
|
956
|
-
// suggest as you type instead of only after Tab — the behaviour both
|
|
957
|
-
// references settled on, and the reason a hundred-skill library is usable
|
|
958
|
-
// by someone who already knows the name they want.
|
|
959
|
-
const opens = /^\/[a-z0-9_?-]*$/i.test(tail)
|
|
960
|
-
|| /(?:^|\s)@[^\s@]*$/.test(tail)
|
|
961
|
-
|| /^\/[a-z0-9_?-]+[ \t]+\S*$/i.test(tail);
|
|
961
|
+
const opens = Boolean(detectTrigger(text, offset));
|
|
962
962
|
if (opens) this.openCompletion(false);
|
|
963
963
|
return;
|
|
964
964
|
}
|