jeopi-cli 16.4.2 → 16.4.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/CHANGELOG.md +74 -0
- package/dist/cli.js +3179 -3140
- package/dist/types/advisor/config.d.ts +4 -3
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/update-cli.d.ts +4 -1
- package/dist/types/cli/usage-error.d.ts +4 -0
- package/dist/types/commit/agentic/agent.d.ts +1 -0
- package/dist/types/config/model-resolver.d.ts +7 -1
- package/dist/types/config/settings-schema.d.ts +3 -3
- package/dist/types/eval/py/tool-bridge.d.ts +1 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -1
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/internal-urls/memory-protocol.d.ts +6 -6
- package/dist/types/internal-urls/types.d.ts +7 -0
- package/dist/types/irc/bus.d.ts +4 -0
- package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
- package/dist/types/modes/components/move-overlay.d.ts +1 -1
- package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
- package/dist/types/modes/components/welcome.d.ts +4 -0
- package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
- package/dist/types/modes/orchestrate.d.ts +1 -1
- package/dist/types/modes/ultrathink.d.ts +1 -1
- package/dist/types/modes/workflow.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +5 -0
- package/dist/types/session/auth-broker-config.d.ts +2 -2
- package/dist/types/tools/bash-skill-urls.d.ts +3 -2
- package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
- package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
- package/dist/types/tools/browser/launch.d.ts +1 -0
- package/dist/types/tools/browser/run-cancellation.d.ts +17 -2
- package/dist/types/tools/browser/run-output.d.ts +25 -0
- package/dist/types/tools/browser/tab-worker.d.ts +22 -0
- package/dist/types/utils/changelog.d.ts +35 -1
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +13 -0
- package/src/advisor/__tests__/config.test.ts +36 -0
- package/src/advisor/config.ts +11 -8
- package/src/autolearn/controller.ts +14 -2
- package/src/cli/args.ts +12 -0
- package/src/cli/flag-tables.ts +21 -7
- package/src/cli/update-cli.ts +63 -13
- package/src/cli/usage-error.ts +7 -0
- package/src/commands/acp.ts +11 -2
- package/src/commands/launch.ts +12 -3
- package/src/commit/agentic/agent.ts +4 -0
- package/src/commit/agentic/index.ts +46 -21
- package/src/config/model-resolver.ts +24 -6
- package/src/config/settings-schema.ts +3 -3
- package/src/edit/index.ts +10 -76
- package/src/edit/renderer.ts +13 -10
- package/src/eval/__tests__/agent-bridge.test.ts +19 -14
- package/src/eval/__tests__/bridge-timeout.test.ts +60 -0
- package/src/eval/executor-base.ts +84 -7
- package/src/eval/js/shared/rewrite-imports.ts +31 -13
- package/src/eval/py/tool-bridge.ts +13 -14
- package/src/exec/non-interactive-env.ts +0 -1
- package/src/export/ttsr.ts +0 -3
- package/src/extensibility/custom-tools/loader.ts +70 -37
- package/src/extensibility/custom-tools/types.ts +3 -0
- package/src/extensibility/extensions/runner.ts +3 -0
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/extensibility/plugins/manager.ts +21 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/memory-protocol.ts +13 -8
- package/src/internal-urls/skill-protocol.ts +1 -1
- package/src/internal-urls/types.ts +7 -0
- package/src/irc/bus.ts +48 -18
- package/src/lsp/client.ts +7 -1
- package/src/lsp/deferred-diagnostics.ts +66 -0
- package/src/main.ts +13 -13
- package/src/mcp/tool-bridge.ts +57 -6
- package/src/mcp/transports/stdio.test.ts +45 -1
- package/src/mcp/transports/stdio.ts +6 -3
- package/src/modes/acp/acp-agent.ts +73 -2
- package/src/modes/acp/acp-event-mapper.ts +5 -0
- package/src/modes/acp/acp-mode.ts +11 -0
- package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
- package/src/modes/components/advisor-config.ts +15 -7
- package/src/modes/components/move-overlay.ts +2 -3
- package/src/modes/components/plan-review-overlay.ts +7 -0
- package/src/modes/components/tips.txt +1 -1
- package/src/modes/components/welcome.ts +13 -14
- package/src/modes/controllers/command-controller.ts +8 -10
- package/src/modes/controllers/event-controller.ts +17 -30
- package/src/modes/controllers/input-controller.ts +9 -6
- package/src/modes/controllers/selector-controller.ts +3 -2
- package/src/modes/controllers/tan-command-controller.ts +40 -1
- package/src/modes/interactive-mode.ts +13 -0
- package/src/modes/magic-keyword-boundary.ts +23 -0
- package/src/modes/orchestrate.ts +6 -5
- package/src/modes/print-mode.ts +9 -0
- package/src/modes/ultrathink.ts +6 -5
- package/src/modes/workflow.ts +5 -4
- package/src/prompts/system/eager-task.md +2 -2
- package/src/prompts/system/tan-context-switch.md +17 -0
- package/src/prompts/tools/browser.md +5 -5
- package/src/sdk.ts +3 -0
- package/src/session/agent-session.ts +172 -25
- package/src/session/auth-broker-config.ts +4 -4
- package/src/session/snapcompact-inline.ts +3 -19
- package/src/session/streaming-output.ts +40 -12
- package/src/slash-commands/builtin-registry.ts +8 -8
- package/src/task/executor.ts +8 -0
- package/src/tools/bash-skill-urls.ts +7 -4
- package/src/tools/bash.ts +30 -22
- package/src/tools/browser/cmux/cmux-tab.ts +49 -50
- package/src/tools/browser/cmux/rpc.ts +50 -0
- package/src/tools/browser/launch.ts +8 -4
- package/src/tools/browser/run-cancellation.ts +44 -5
- package/src/tools/browser/run-output.ts +76 -0
- package/src/tools/browser/tab-worker.ts +280 -133
- package/src/tools/eval-render.ts +16 -14
- package/src/tools/glob.ts +20 -6
- package/src/tools/grep.ts +1 -0
- package/src/tools/image-gen.ts +3 -4
- package/src/tools/irc.ts +27 -13
- package/src/tools/read.ts +14 -7
- package/src/tools/write.ts +22 -5
- package/src/tools/yield.ts +29 -1
- package/src/utils/changelog.ts +106 -9
- package/src/utils/git.ts +0 -1
- package/src/utils/title-generator.ts +68 -7
- package/src/web/search/index.ts +14 -6
- package/src/web/search/providers/perplexity.ts +18 -2
|
@@ -3,12 +3,14 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as fsp from "node:fs/promises";
|
|
4
4
|
import * as os from "node:os";
|
|
5
5
|
import * as path from "node:path";
|
|
6
|
+
import { visibleWidth } from "jeopi-tui";
|
|
6
7
|
import { Settings } from "../../../config/settings";
|
|
7
8
|
import { getThemeByName, setThemeInstance, type Theme } from "../../theme/theme";
|
|
8
9
|
import { MoveOverlay, type MoveOverlayResult, resolveExistingDirectory, resolveMovePath } from "../move-overlay";
|
|
9
10
|
|
|
10
11
|
// Strip SGR colors so assertions see visible text only.
|
|
11
|
-
const
|
|
12
|
+
const stripAnsi = (text: string): string => text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
13
|
+
const strip = (lines: readonly string[]): string => lines.map(stripAnsi).join("\n");
|
|
12
14
|
|
|
13
15
|
describe("resolveMovePath", () => {
|
|
14
16
|
it("expands ~ to homedir", () => {
|
|
@@ -82,6 +84,19 @@ describe("MoveOverlay", () => {
|
|
|
82
84
|
expect(text).toContain("Path:");
|
|
83
85
|
});
|
|
84
86
|
|
|
87
|
+
it("renders every frame row at the assigned overlay width", () => {
|
|
88
|
+
const overlay = new MoveOverlay(cwd, () => {});
|
|
89
|
+
const lines = overlay.render(72);
|
|
90
|
+
const plainLines = lines.map(stripAnsi);
|
|
91
|
+
|
|
92
|
+
expect(lines.map(line => visibleWidth(line))).toEqual(Array(lines.length).fill(72));
|
|
93
|
+
expect(plainLines[0]!.endsWith(uiTheme.boxRound.topRight)).toBe(true);
|
|
94
|
+
expect(plainLines.at(-1)!.endsWith(uiTheme.boxRound.bottomRight)).toBe(true);
|
|
95
|
+
for (const line of plainLines.slice(1, -1)) {
|
|
96
|
+
expect(line.endsWith(uiTheme.boxRound.vertical)).toBe(true);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
85
100
|
it("lists child directories (excluding hidden and files) on empty input", () => {
|
|
86
101
|
const overlay = new MoveOverlay(cwd, () => {});
|
|
87
102
|
const text = strip(overlay.render(80));
|
|
@@ -82,9 +82,9 @@ function previewLine(text: string | undefined): string {
|
|
|
82
82
|
return first.length > PREVIEW_WIDTH ? `${first.slice(0, PREVIEW_WIDTH - 1)}…` : first;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
/**
|
|
85
|
+
/** Omitted means default read/grep/glob; an explicit empty set means no tools. */
|
|
86
86
|
function commitTools(selected: ReadonlySet<string>, all: readonly string[]): string[] | undefined {
|
|
87
|
-
if (selected.size === 0) return
|
|
87
|
+
if (selected.size === 0) return [];
|
|
88
88
|
if (selected.size === ADVISOR_DEFAULT_TOOL_NAMES.size) {
|
|
89
89
|
let matchesDefault = true;
|
|
90
90
|
for (const name of ADVISOR_DEFAULT_TOOL_NAMES) {
|
|
@@ -98,6 +98,11 @@ function commitTools(selected: ReadonlySet<string>, all: readonly string[]): str
|
|
|
98
98
|
return all.filter(name => selected.has(name));
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
function formatAdvisorTools(tools: readonly string[] | undefined, emptyLabel: string): string {
|
|
102
|
+
if (tools === undefined) return "read, grep, glob (default)";
|
|
103
|
+
return tools.length > 0 ? tools.join(", ") : emptyLabel;
|
|
104
|
+
}
|
|
105
|
+
|
|
101
106
|
/** Soft-wrap plain text to `width`, returning at least one (possibly empty) line. */
|
|
102
107
|
function wrap(text: string, width: number): string[] {
|
|
103
108
|
if (!text) return [""];
|
|
@@ -266,7 +271,7 @@ export class AdvisorConfigOverlayComponent implements Component {
|
|
|
266
271
|
|
|
267
272
|
#advisorPreview(advisor: AdvisorConfig, bodyWidth: number): string[] {
|
|
268
273
|
const model = advisor.model?.trim() || this.#defaultModelLabel || "advisor role default";
|
|
269
|
-
const tools = advisor.tools
|
|
274
|
+
const tools = formatAdvisorTools(advisor.tools, "no tools");
|
|
270
275
|
const lines = [
|
|
271
276
|
theme.bold(advisor.name || "(unnamed)"),
|
|
272
277
|
"",
|
|
@@ -303,13 +308,16 @@ export class AdvisorConfigOverlayComponent implements Component {
|
|
|
303
308
|
const advisor = doc.advisors[0];
|
|
304
309
|
if (!advisor) return false;
|
|
305
310
|
return (
|
|
306
|
-
advisor.name === "default" &&
|
|
311
|
+
advisor.name === "default" &&
|
|
312
|
+
!advisor.model?.trim() &&
|
|
313
|
+
advisor.tools === undefined &&
|
|
314
|
+
!advisor.instructions?.trim()
|
|
307
315
|
);
|
|
308
316
|
}
|
|
309
317
|
|
|
310
318
|
#advisorSummary(advisor: AdvisorConfig): string {
|
|
311
319
|
const model = advisor.model?.trim() || this.#defaultModelLabel || "advisor role default";
|
|
312
|
-
const tools = advisor.tools
|
|
320
|
+
const tools = formatAdvisorTools(advisor.tools, "no tools");
|
|
313
321
|
return `${model} · ${tools}`;
|
|
314
322
|
}
|
|
315
323
|
|
|
@@ -384,7 +392,7 @@ export class AdvisorConfigOverlayComponent implements Component {
|
|
|
384
392
|
return;
|
|
385
393
|
}
|
|
386
394
|
const modelDescription = advisor.model?.trim() || this.#defaultModelLabel || "advisor role default";
|
|
387
|
-
const toolsDescription = advisor.tools
|
|
395
|
+
const toolsDescription = formatAdvisorTools(advisor.tools, "no tools");
|
|
388
396
|
const items: SelectItem[] = [
|
|
389
397
|
{ value: "name", label: "Name", description: advisor.name },
|
|
390
398
|
{ value: "model", label: "Model", description: modelDescription },
|
|
@@ -524,7 +532,7 @@ export class AdvisorConfigOverlayComponent implements Component {
|
|
|
524
532
|
this.#setScreen(
|
|
525
533
|
"tools",
|
|
526
534
|
list,
|
|
527
|
-
"Enter / click toggle · select Done or Esc to apply (empty
|
|
535
|
+
"Enter / click toggle · select Done or Esc to apply (empty = no tools; read/grep/glob = default)",
|
|
528
536
|
);
|
|
529
537
|
}
|
|
530
538
|
|
|
@@ -25,7 +25,6 @@ interface DirEntry {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const MAX_RESULTS = 15;
|
|
28
|
-
const OVERLAY_WIDTH = 68;
|
|
29
28
|
|
|
30
29
|
/** TTL for the directory listing cache (ms). */
|
|
31
30
|
const DIR_CACHE_TTL = 500;
|
|
@@ -218,8 +217,8 @@ export class MoveOverlay implements Component, Focusable {
|
|
|
218
217
|
}
|
|
219
218
|
}
|
|
220
219
|
|
|
221
|
-
render(
|
|
222
|
-
const w =
|
|
220
|
+
render(width: number): readonly string[] {
|
|
221
|
+
const w = width;
|
|
223
222
|
const lines: string[] = [];
|
|
224
223
|
|
|
225
224
|
lines.push(topBorder(w, "Move to directory"));
|
|
@@ -84,6 +84,8 @@ export interface PlanReviewOverlayCallbacks {
|
|
|
84
84
|
onCancel: () => void;
|
|
85
85
|
/** Invoked when the external-editor key is pressed (overlay stays open). */
|
|
86
86
|
onExternalEditor?: () => void;
|
|
87
|
+
/** Invoked with the current full plan text when the copy hotkey is pressed. */
|
|
88
|
+
onCopyPlan?: (content: string) => void | Promise<void>;
|
|
87
89
|
/** Invoked when the external-editor key edits the active annotation draft. */
|
|
88
90
|
onAnnotationExternalEditor?: (draft: string, commit: (text: string | null) => void) => void;
|
|
89
91
|
/** Invoked with the new full plan text after an in-overlay delete/undo. */
|
|
@@ -302,6 +304,10 @@ export class PlanReviewOverlay implements Component {
|
|
|
302
304
|
this.callbacks.onExternalEditor();
|
|
303
305
|
return;
|
|
304
306
|
}
|
|
307
|
+
if (this.callbacks.onCopyPlan && keyData === "c") {
|
|
308
|
+
void this.callbacks.onCopyPlan(joinPlanSections(this.#sections));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
305
311
|
if (matchesKey(keyData, "tab") || keyData === "\t") {
|
|
306
312
|
this.#cycleRegion(1);
|
|
307
313
|
return;
|
|
@@ -677,6 +683,7 @@ export class PlanReviewOverlay implements Component {
|
|
|
677
683
|
parts.push("↑↓ scroll", "⇧ faster", "pgup/pgdn", "g/G ends");
|
|
678
684
|
break;
|
|
679
685
|
}
|
|
686
|
+
if (this.callbacks.onCopyPlan) parts.push("c copy");
|
|
680
687
|
parts.push("tab regions");
|
|
681
688
|
if (this.#externalEditorLabel && this.#focus !== "toc") parts.push(`${this.#externalEditorLabel} editor`);
|
|
682
689
|
parts.push(this.#helpSuffix);
|
|
@@ -21,4 +21,4 @@ Pair up live: `/collab` shares your session through an end-to-end encrypted rela
|
|
|
21
21
|
Press ← ← to drill into a running or finished agent and inspect its tool calls and transcript
|
|
22
22
|
Hit a Codex rate limit? `/usage reset` spends a saved reset credit to immediately restore your quota
|
|
23
23
|
No native tool_calling? Inference provider botches parsing them? `PI_DIALECT=glm|kimi|anthropic…` rolls it locally for them!
|
|
24
|
-
Turn on `/advisor` to attach a second model that reviews every turn and quietly injects advice
|
|
24
|
+
Turn on `/advisor` to attach a second model that reviews every turn and quietly injects advice
|
|
@@ -44,20 +44,19 @@ const NEW_GLOW_PERIOD_MS = 1500;
|
|
|
44
44
|
* affordance surfaces this many times as often. */
|
|
45
45
|
const NEW_TIP_WEIGHT = 4;
|
|
46
46
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
let
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (r < 0) return TIPS[i] ?? "";
|
|
47
|
+
/** Pick a tip from `tips`, biased toward "[NEW]" tips by {@link NEW_TIP_WEIGHT};
|
|
48
|
+
* `r` is a uniform sample in [0, 1). Returns "" when `tips` is empty.
|
|
49
|
+
* Exported for tests. */
|
|
50
|
+
export function pickWeightedTip(tips: readonly string[], r: number): string {
|
|
51
|
+
if (tips.length === 0) return "";
|
|
52
|
+
const weights = tips.map(tip => (NEW_TIP_MARKER.test(tip) ? NEW_TIP_WEIGHT : 1));
|
|
53
|
+
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
|
54
|
+
let acc = r * total;
|
|
55
|
+
for (let i = 0; i < tips.length; i++) {
|
|
56
|
+
acc -= weights[i] ?? 1;
|
|
57
|
+
if (acc < 0) return tips[i] ?? "";
|
|
59
58
|
}
|
|
60
|
-
return
|
|
59
|
+
return tips[tips.length - 1] ?? "";
|
|
61
60
|
}
|
|
62
61
|
|
|
63
62
|
type ColorEncoding = "ansi-16m" | "ansi-256";
|
|
@@ -161,7 +160,7 @@ export class WelcomeComponent implements Component {
|
|
|
161
160
|
if (theme.getSymbolPreset() === "unicode" && Math.random() < 0.1) {
|
|
162
161
|
this.#selectedTip = "Please use nerdfont 😭.";
|
|
163
162
|
} else {
|
|
164
|
-
this.#selectedTip = pickWeightedTip();
|
|
163
|
+
this.#selectedTip = pickWeightedTip(TIPS, Math.random());
|
|
165
164
|
}
|
|
166
165
|
}
|
|
167
166
|
return this.#selectedTip || undefined;
|
|
@@ -47,7 +47,12 @@ import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-o
|
|
|
47
47
|
import { outputMeta } from "../../tools/output-meta";
|
|
48
48
|
import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils";
|
|
49
49
|
import { replaceTabs, truncateToWidth } from "../../tools/render-utils";
|
|
50
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
getChangelogPath,
|
|
52
|
+
parseChangelog,
|
|
53
|
+
RECENT_CHANGELOG_ENTRY_LIMIT,
|
|
54
|
+
renderChangelogEntries,
|
|
55
|
+
} from "../../utils/changelog";
|
|
51
56
|
import { copyToClipboard } from "../../utils/clipboard";
|
|
52
57
|
import { openPath } from "../../utils/open";
|
|
53
58
|
import { setSessionTerminalTitle } from "../../utils/title-generator";
|
|
@@ -487,16 +492,9 @@ export class CommandController {
|
|
|
487
492
|
async handleChangelogCommand(showFull = false): Promise<void> {
|
|
488
493
|
const changelogPath = getChangelogPath();
|
|
489
494
|
const allEntries = await parseChangelog(changelogPath);
|
|
490
|
-
|
|
491
|
-
// allEntries comes from parseChangelog with newest first, reverse to show oldest->newest
|
|
492
|
-
const entriesToShow = showFull ? allEntries : allEntries.slice(0, 3);
|
|
495
|
+
const entriesToShow = showFull ? allEntries : allEntries.slice(0, RECENT_CHANGELOG_ENTRY_LIMIT);
|
|
493
496
|
const changelogMarkdown =
|
|
494
|
-
entriesToShow.length > 0
|
|
495
|
-
? [...entriesToShow]
|
|
496
|
-
.reverse()
|
|
497
|
-
.map(e => e.content)
|
|
498
|
-
.join("\n\n")
|
|
499
|
-
: "No changelog entries found.";
|
|
497
|
+
entriesToShow.length > 0 ? renderChangelogEntries(entriesToShow).markdown : "No changelog entries found.";
|
|
500
498
|
const title = showFull ? "Full Changelog" : "Recent Changes";
|
|
501
499
|
const hint = showFull
|
|
502
500
|
? ""
|
|
@@ -74,7 +74,7 @@ export class EventController {
|
|
|
74
74
|
#lastVisibleBlockCount = 0;
|
|
75
75
|
#renderedCustomMessages = new Set<string>();
|
|
76
76
|
#lastIntent: string | undefined = undefined;
|
|
77
|
-
#
|
|
77
|
+
#backgroundTaskCallIds = new Set<string>();
|
|
78
78
|
#readToolCallArgs = new Map<string, Record<string, unknown>>();
|
|
79
79
|
#readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
|
|
80
80
|
#lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
|
|
@@ -262,7 +262,7 @@ export class EventController {
|
|
|
262
262
|
this.#lastVisibleBlockCount = 0;
|
|
263
263
|
this.#renderedCustomMessages.clear();
|
|
264
264
|
this.#lastIntent = undefined;
|
|
265
|
-
this.#
|
|
265
|
+
this.#backgroundTaskCallIds.clear();
|
|
266
266
|
this.#readToolCallArgs.clear();
|
|
267
267
|
this.#readToolCallAssistantComponents.clear();
|
|
268
268
|
this.#lastAssistantComponent = undefined;
|
|
@@ -763,9 +763,9 @@ export class EventController {
|
|
|
763
763
|
// The turn ended without running these calls (abort/error/TTSR rewind),
|
|
764
764
|
// so they will never produce a result. Seal them so they stop animating
|
|
765
765
|
// and freeze instead of pinning the transcript live region while a retry
|
|
766
|
-
// streams fresh blocks below them. Background
|
|
766
|
+
// streams fresh blocks below them. Background task calls keep updating.
|
|
767
767
|
for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
|
|
768
|
-
if (!this.#
|
|
768
|
+
if (!this.#backgroundTaskCallIds.has(toolCallId) && component instanceof ToolExecutionComponent) {
|
|
769
769
|
component.seal();
|
|
770
770
|
}
|
|
771
771
|
}
|
|
@@ -885,7 +885,7 @@ export class EventController {
|
|
|
885
885
|
);
|
|
886
886
|
if (isFinalAsyncState) {
|
|
887
887
|
this.ctx.pendingTools.delete(event.toolCallId);
|
|
888
|
-
this.#
|
|
888
|
+
this.#backgroundTaskCallIds.delete(event.toolCallId);
|
|
889
889
|
}
|
|
890
890
|
this.ctx.ui.requestRender();
|
|
891
891
|
}
|
|
@@ -906,13 +906,7 @@ export class EventController {
|
|
|
906
906
|
component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId);
|
|
907
907
|
this.ctx.pendingTools.delete(event.toolCallId);
|
|
908
908
|
}
|
|
909
|
-
|
|
910
|
-
if (asyncState === "running") {
|
|
911
|
-
this.#backgroundToolCallIds.add(event.toolCallId);
|
|
912
|
-
} else {
|
|
913
|
-
this.#backgroundToolCallIds.delete(event.toolCallId);
|
|
914
|
-
this.#clearReadToolCall(event.toolCallId);
|
|
915
|
-
}
|
|
909
|
+
this.#clearReadToolCall(event.toolCallId);
|
|
916
910
|
this.ctx.ui.requestRender();
|
|
917
911
|
} else {
|
|
918
912
|
let component = this.ctx.pendingTools.get(event.toolCallId);
|
|
@@ -925,29 +919,22 @@ export class EventController {
|
|
|
925
919
|
component = group;
|
|
926
920
|
this.ctx.pendingTools.set(event.toolCallId, group);
|
|
927
921
|
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
if (isBackgroundRunning) {
|
|
932
|
-
this.#backgroundToolCallIds.add(event.toolCallId);
|
|
933
|
-
} else {
|
|
934
|
-
this.ctx.pendingTools.delete(event.toolCallId);
|
|
935
|
-
this.#backgroundToolCallIds.delete(event.toolCallId);
|
|
936
|
-
this.#clearReadToolCall(event.toolCallId);
|
|
937
|
-
}
|
|
922
|
+
component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId);
|
|
923
|
+
this.ctx.pendingTools.delete(event.toolCallId);
|
|
924
|
+
this.#clearReadToolCall(event.toolCallId);
|
|
938
925
|
this.ctx.ui.requestRender();
|
|
939
926
|
}
|
|
940
927
|
} else {
|
|
941
928
|
const component = this.ctx.pendingTools.get(event.toolCallId);
|
|
942
929
|
if (component) {
|
|
943
930
|
const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
|
|
944
|
-
const
|
|
945
|
-
component.updateResult({ ...event.result, isError: event.isError },
|
|
946
|
-
if (
|
|
947
|
-
this.#
|
|
931
|
+
const isBackgroundTask = event.toolName === "task" && asyncState === "running";
|
|
932
|
+
component.updateResult({ ...event.result, isError: event.isError }, isBackgroundTask, event.toolCallId);
|
|
933
|
+
if (isBackgroundTask) {
|
|
934
|
+
this.#backgroundTaskCallIds.add(event.toolCallId);
|
|
948
935
|
} else {
|
|
949
936
|
this.ctx.pendingTools.delete(event.toolCallId);
|
|
950
|
-
this.#
|
|
937
|
+
this.#backgroundTaskCallIds.delete(event.toolCallId);
|
|
951
938
|
}
|
|
952
939
|
if (component instanceof ToolExecutionComponent && component.isDisplaceableBlock()) {
|
|
953
940
|
if (event.toolName === "job" && component.canBeDisplacedBy("job")) {
|
|
@@ -1025,7 +1012,7 @@ export class EventController {
|
|
|
1025
1012
|
}
|
|
1026
1013
|
await this.ctx.flushPendingModelSwitch();
|
|
1027
1014
|
for (const toolCallId of Array.from(this.ctx.pendingTools.keys())) {
|
|
1028
|
-
if (!this.#
|
|
1015
|
+
if (!this.#backgroundTaskCallIds.has(toolCallId)) {
|
|
1029
1016
|
// A foreground tool still pending at turn end never delivered a result;
|
|
1030
1017
|
// seal it so it freezes (and stops animating) rather than lingering in
|
|
1031
1018
|
// the transcript live region as a streaming preview until the next thaw.
|
|
@@ -1039,8 +1026,8 @@ export class EventController {
|
|
|
1039
1026
|
this.ctx.pendingTools.delete(toolCallId);
|
|
1040
1027
|
}
|
|
1041
1028
|
}
|
|
1042
|
-
this.#
|
|
1043
|
-
Array.from(this.#
|
|
1029
|
+
this.#backgroundTaskCallIds = new Set(
|
|
1030
|
+
Array.from(this.#backgroundTaskCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)),
|
|
1044
1031
|
);
|
|
1045
1032
|
this.#readToolCallArgs.clear();
|
|
1046
1033
|
this.#readToolCallAssistantComponents.clear();
|
|
@@ -298,6 +298,8 @@ export class InputController {
|
|
|
298
298
|
});
|
|
299
299
|
}
|
|
300
300
|
this.ctx.editor.onEscape = () => {
|
|
301
|
+
// Side-channel panels are the topmost view. Esc dismisses them before
|
|
302
|
+
// touching loop mode, maintenance, or the underlying main turn.
|
|
301
303
|
// Active context maintenance owns Esc: auto/manual compaction,
|
|
302
304
|
// handoff generation, and auto-retry backoff all advertise
|
|
303
305
|
// "(esc to cancel)". Dispatch on live session state instead of
|
|
@@ -313,6 +315,13 @@ export class InputController {
|
|
|
313
315
|
// (see EventController). Main-session maintenance still owns Esc and
|
|
314
316
|
// stays cancellable from the main view (focused submit gates /compact
|
|
315
317
|
// and handoff, so manual maintenance is main-only anyway).
|
|
318
|
+
if (this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
316
325
|
if (!this.ctx.focusedAgentId) {
|
|
317
326
|
const viewSession = this.ctx.viewSession;
|
|
318
327
|
let aborted = false;
|
|
@@ -340,12 +349,6 @@ export class InputController {
|
|
|
340
349
|
}
|
|
341
350
|
return;
|
|
342
351
|
}
|
|
343
|
-
if (this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) {
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) {
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
352
|
if (this.ctx.focusedAgentId) {
|
|
350
353
|
// Esc never interrupts the focused agent's turn: clear typed text,
|
|
351
354
|
// else return the view to the main session. Interrupt via empty
|
|
@@ -601,8 +601,9 @@ export class SelectorController {
|
|
|
601
601
|
const concreteThinking = isAuto ? undefined : thinkingLevel;
|
|
602
602
|
try {
|
|
603
603
|
if (role === null) {
|
|
604
|
-
// Temporary: update agent state but don't persist the model to settings
|
|
605
|
-
|
|
604
|
+
// Temporary: update agent state but don't persist the model to settings.
|
|
605
|
+
const roleThinkingLevel = this.ctx.session.resolveTemporaryModelThinkingLevel(model);
|
|
606
|
+
await this.ctx.session.setModelTemporary(model, roleThinkingLevel);
|
|
606
607
|
if (isAuto) {
|
|
607
608
|
this.ctx.session.setThinkingLevel(AUTO_THINKING, true);
|
|
608
609
|
}
|
|
@@ -3,12 +3,14 @@ import * as path from "node:path";
|
|
|
3
3
|
import type { AssistantMessage } from "jeopi-ai";
|
|
4
4
|
import { prompt, Snowflake } from "jeopi-utils";
|
|
5
5
|
import backgroundTanDispatchPrompt from "../../prompts/system/background-tan-dispatch.md" with { type: "text" };
|
|
6
|
+
import tanContextSwitchPrompt from "../../prompts/system/tan-context-switch.md" with { type: "text" };
|
|
6
7
|
import { AgentRegistry, MAIN_AGENT_ID } from "../../registry/agent-registry";
|
|
7
8
|
import * as sdk from "../../sdk";
|
|
8
9
|
import type { AgentSession } from "../../session/agent-session";
|
|
9
10
|
import { BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE } from "../../session/messages";
|
|
10
11
|
import { SessionManager } from "../../session/session-manager";
|
|
11
12
|
import { createMCPProxyTools, createSubagentSettings } from "../../task/executor";
|
|
13
|
+
import { USER_TODO_EDIT_CUSTOM_TYPE } from "../../tools/todo";
|
|
12
14
|
import type { InteractiveModeContext } from "../types";
|
|
13
15
|
|
|
14
16
|
const TAN_LABEL_PREVIEW_LENGTH = 80;
|
|
@@ -66,6 +68,11 @@ export class TanCommandController {
|
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
const parentSessionId = session.sessionId;
|
|
71
|
+
// Providers route on `promptCacheKey ?? sessionId`, so the parent's live
|
|
72
|
+
// requests may cache under a pinned key that differs from its session id
|
|
73
|
+
// (the parent being itself a fork/tan). Mirror exactly what the parent
|
|
74
|
+
// populated the cache under — same rule as advisor and handoff calls.
|
|
75
|
+
const parentPromptCacheKey = session.agent.promptCacheKey ?? parentSessionId;
|
|
69
76
|
const thinkingLevel = session.configuredThinkingLevel();
|
|
70
77
|
const systemPrompt = [...session.systemPrompt];
|
|
71
78
|
const toolNames = session.getActiveToolNames();
|
|
@@ -111,7 +118,7 @@ export class TanCommandController {
|
|
|
111
118
|
systemPrompt,
|
|
112
119
|
toolNames,
|
|
113
120
|
providerSessionId: `${parentSessionId}:tan:${Snowflake.next()}`,
|
|
114
|
-
providerPromptCacheKey:
|
|
121
|
+
providerPromptCacheKey: parentPromptCacheKey,
|
|
115
122
|
modelRegistry,
|
|
116
123
|
authStorage: modelRegistry.authStorage,
|
|
117
124
|
settings,
|
|
@@ -127,19 +134,51 @@ export class TanCommandController {
|
|
|
127
134
|
disableExtensionDiscovery: true,
|
|
128
135
|
});
|
|
129
136
|
clone = created.session;
|
|
137
|
+
clone.sessionManager.appendSessionInit({
|
|
138
|
+
systemPrompt: clone.systemPrompt.join("\n\n"),
|
|
139
|
+
task: trimmedWork,
|
|
140
|
+
tools: clone.getActiveToolNames(),
|
|
141
|
+
});
|
|
130
142
|
const abortClone = () => {
|
|
131
143
|
void clone?.abort();
|
|
132
144
|
};
|
|
133
145
|
signal.addEventListener("abort", abortClone, { once: true });
|
|
146
|
+
// The fork inherits the parent's todo list via session entries;
|
|
147
|
+
// its reminders would drag the tan back onto the parent's task.
|
|
148
|
+
// Clear runtime state and persist an empty edit so reloads agree.
|
|
149
|
+
clone.setTodoPhases([]);
|
|
150
|
+
cloneManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, { phases: [] });
|
|
151
|
+
const injectContextSwitch = () => {
|
|
152
|
+
clone?.agent.appendMessage({
|
|
153
|
+
role: "developer",
|
|
154
|
+
content: tanContextSwitchPrompt,
|
|
155
|
+
attribution: "agent",
|
|
156
|
+
timestamp: Date.now(),
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
// Compaction summarizes the fork notice away with the rest of the
|
|
160
|
+
// history, after which the clone re-adopts the parent's task as its
|
|
161
|
+
// own (the summary blends both). Re-inject after every successful
|
|
162
|
+
// compaction so the fork boundary survives summarization.
|
|
163
|
+
const unsubscribeCompaction = clone.subscribe(event => {
|
|
164
|
+
if (event.type === "auto_compaction_end" && event.result && !event.aborted) {
|
|
165
|
+
injectContextSwitch();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
134
168
|
try {
|
|
135
169
|
if (signal.aborted) {
|
|
136
170
|
abortClone();
|
|
137
171
|
throw new Error("Aborted before execution");
|
|
138
172
|
}
|
|
173
|
+
// Inject a context-switch developer message so the clone knows
|
|
174
|
+
// it is a tangential fork — its parent owns the prior conversation;
|
|
175
|
+
// this agent must focus exclusively on the user's request.
|
|
176
|
+
injectContextSwitch();
|
|
139
177
|
await clone.prompt(trimmedWork, { attribution: "user" });
|
|
140
178
|
await clone.waitForIdle();
|
|
141
179
|
return extractAssistantText(clone.getLastAssistantMessage()) || "(no output)";
|
|
142
180
|
} finally {
|
|
181
|
+
unsubscribeCompaction();
|
|
143
182
|
signal.removeEventListener("abort", abortClone);
|
|
144
183
|
}
|
|
145
184
|
} finally {
|
|
@@ -118,6 +118,7 @@ import { formatPhaseDisplayName, todoMatchesAnyDescription } from "../tools/todo
|
|
|
118
118
|
import { ToolError } from "../tools/tool-errors";
|
|
119
119
|
import { vocalizer } from "../tts/vocalizer";
|
|
120
120
|
import { renderTreeList } from "../tui/tree-list";
|
|
121
|
+
import { copyToClipboard } from "../utils/clipboard";
|
|
121
122
|
import type { EventBus } from "../utils/event-bus";
|
|
122
123
|
import { getEditorCommand, openInEditor } from "../utils/external-editor";
|
|
123
124
|
import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
|
|
@@ -2391,6 +2392,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2391
2392
|
{
|
|
2392
2393
|
onPick: choice => finish(choice),
|
|
2393
2394
|
onCancel: () => finish(undefined),
|
|
2395
|
+
onCopyPlan: content => void this.#copyPlanToClipboard(content),
|
|
2394
2396
|
onExternalEditor: dialogOptions?.onExternalEditor,
|
|
2395
2397
|
onAnnotationExternalEditor: (draft, commit) => void this.#openPlanAnnotationInExternalEditor(draft, commit),
|
|
2396
2398
|
onPlanEdited: dialogOptions?.onPlanEdited,
|
|
@@ -2457,6 +2459,17 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2457
2459
|
return contextUsage !== undefined && contextUsage.percent > PLAN_KEEP_CONTEXT_DISABLE_THRESHOLD_PERCENT;
|
|
2458
2460
|
}
|
|
2459
2461
|
|
|
2462
|
+
async #copyPlanToClipboard(content: string): Promise<void> {
|
|
2463
|
+
try {
|
|
2464
|
+
await copyToClipboard(content);
|
|
2465
|
+
this.showStatus("Copied plan to clipboard");
|
|
2466
|
+
} catch (error) {
|
|
2467
|
+
this.showWarning(
|
|
2468
|
+
`Failed to copy plan to clipboard: ${error instanceof Error ? error.message : String(error)}`,
|
|
2469
|
+
);
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2460
2473
|
async #openPlanInExternalEditor(planFilePath: string): Promise<void> {
|
|
2461
2474
|
const editorCmd = getEditorCommand();
|
|
2462
2475
|
if (!editorCmd) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Characters that bind a magic keyword into an identifier or path segment. */
|
|
2
|
+
const LEFT_BOUNDARY = String.raw`(?<![\p{L}\p{N}_./\\-])(?<!::)`;
|
|
3
|
+
|
|
4
|
+
/** Characters that cannot immediately follow a standalone magic keyword. */
|
|
5
|
+
const RIGHT_BOUNDARY = String.raw`(?![\p{L}\p{N}_/\\-])(?!\.[\p{L}\p{N}_-])(?!\()`;
|
|
6
|
+
|
|
7
|
+
/** Escape a literal string for safe insertion into a RegExp source. */
|
|
8
|
+
function escapeRegExp(value: string): string {
|
|
9
|
+
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build a case-sensitive magic-keyword matcher for prose punctuation boundaries.
|
|
14
|
+
*
|
|
15
|
+
* Sentence punctuation and quotes may touch the keyword, but letters, digits,
|
|
16
|
+
* underscores, slashes, backslashes, hyphens, file-extension dots, symbol
|
|
17
|
+
* references (`foo::keyword`), and immediate call parentheses (`keyword()`)
|
|
18
|
+
* keep the occurrence embedded in code rather than prose.
|
|
19
|
+
*/
|
|
20
|
+
export function magicKeywordRegex(keyword: string, flags = ""): RegExp {
|
|
21
|
+
const normalizedFlags = flags.includes("u") ? flags : `${flags}u`;
|
|
22
|
+
return new RegExp(`${LEFT_BOUNDARY}${escapeRegExp(keyword)}${RIGHT_BOUNDARY}`, normalizedFlags);
|
|
23
|
+
}
|
package/src/modes/orchestrate.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import orchestrateNotice from "../prompts/system/orchestrate-notice.md" with { type: "text" };
|
|
2
2
|
import { createGradientHighlighter, type KeywordHighlighter } from "./gradient-highlight";
|
|
3
|
+
import { magicKeywordRegex } from "./magic-keyword-boundary";
|
|
3
4
|
import { keywordInProse } from "./markdown-prose";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -8,21 +9,21 @@ import { keywordInProse } from "./markdown-prose";
|
|
|
8
9
|
* Typing the standalone word in the input editor paints it with a cool
|
|
9
10
|
* teal→violet gradient ({@link highlightOrchestrate}); submitting a message that
|
|
10
11
|
* mentions it appends a hidden {@link ORCHESTRATE_NOTICE} that switches the model
|
|
11
|
-
* into multi-agent orchestration mode. Matching is
|
|
12
|
+
* into multi-agent orchestration mode. Matching is prose-delimited and
|
|
12
13
|
* case-sensitive (lowercase only), so "orchestrated", "Orchestrate", or a path
|
|
13
14
|
* like "orchestrate.ts" never trigger either behavior. Replaces the former
|
|
14
15
|
* `/orchestrate` slash command.
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
|
-
// Detection: lowercase keyword flanked by whitespace or a string edge.
|
|
18
|
-
const ORCHESTRATE_WORD =
|
|
18
|
+
// Detection: lowercase keyword flanked by prose punctuation, whitespace, or a string edge.
|
|
19
|
+
const ORCHESTRATE_WORD = magicKeywordRegex("orchestrate");
|
|
19
20
|
|
|
20
21
|
/** Hidden system notice appended after a user message that mentions "orchestrate". */
|
|
21
22
|
export const ORCHESTRATE_NOTICE: string = orchestrateNotice.trim();
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Whether `text` contains the standalone keyword "orchestrate" (lowercase,
|
|
25
|
-
*
|
|
26
|
+
* prose-delimited) in prose — never inside a code block, inline code span,
|
|
26
27
|
* or XML/HTML section.
|
|
27
28
|
*/
|
|
28
29
|
export function containsOrchestrate(text: string): boolean {
|
|
@@ -36,7 +37,7 @@ export function containsOrchestrate(text: string): boolean {
|
|
|
36
37
|
*/
|
|
37
38
|
export const highlightOrchestrate: KeywordHighlighter = createGradientHighlighter({
|
|
38
39
|
probe: /orchestrate/,
|
|
39
|
-
highlight:
|
|
40
|
+
highlight: magicKeywordRegex("orchestrate", "g"),
|
|
40
41
|
stops: 14,
|
|
41
42
|
hue: t => 150 + t * 130,
|
|
42
43
|
});
|
package/src/modes/print-mode.ts
CHANGED
|
@@ -62,13 +62,22 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
|
|
|
62
62
|
}
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
let wroteTextWorkingIndicator = false;
|
|
66
|
+
const writeTextWorkingIndicator = (): void => {
|
|
67
|
+
if (mode !== "text" || wroteTextWorkingIndicator) return;
|
|
68
|
+
process.stderr.write("Working...\n");
|
|
69
|
+
wroteTextWorkingIndicator = true;
|
|
70
|
+
};
|
|
71
|
+
|
|
65
72
|
// Send initial message with attachments
|
|
66
73
|
if (initialMessage !== undefined) {
|
|
74
|
+
writeTextWorkingIndicator();
|
|
67
75
|
await logger.time("print:prompt:initial", () => session.prompt(initialMessage, { images: initialImages }));
|
|
68
76
|
}
|
|
69
77
|
|
|
70
78
|
// Send remaining messages
|
|
71
79
|
for (const message of messages) {
|
|
80
|
+
writeTextWorkingIndicator();
|
|
72
81
|
await logger.time("print:prompt:next", () => session.prompt(message));
|
|
73
82
|
}
|
|
74
83
|
|
package/src/modes/ultrathink.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ultrathinkNotice from "../prompts/system/ultrathink-notice.md" with { type: "text" };
|
|
2
2
|
import { createGradientHighlighter, type KeywordHighlighter } from "./gradient-highlight";
|
|
3
|
+
import { magicKeywordRegex } from "./magic-keyword-boundary";
|
|
3
4
|
import { keywordInProse } from "./markdown-prose";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -8,20 +9,20 @@ import { keywordInProse } from "./markdown-prose";
|
|
|
8
9
|
* Typing the standalone word in the input editor paints it with a rainbow
|
|
9
10
|
* gradient ({@link highlightUltrathink}); submitting a message that mentions it
|
|
10
11
|
* appends a hidden {@link ULTRATHINK_NOTICE} nudging the model toward careful
|
|
11
|
-
* multi-step reasoning. Matching is
|
|
12
|
+
* multi-step reasoning. Matching is prose-delimited and case-sensitive
|
|
12
13
|
* (lowercase only), so "ultrathinking", "Ultrathink", or "ultrathink.ts" never
|
|
13
14
|
* trigger either behavior.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
|
-
// Detection: lowercase keyword flanked by whitespace or a string edge.
|
|
17
|
-
const ULTRATHINK_WORD =
|
|
17
|
+
// Detection: lowercase keyword flanked by prose punctuation, whitespace, or a string edge.
|
|
18
|
+
const ULTRATHINK_WORD = magicKeywordRegex("ultrathink");
|
|
18
19
|
|
|
19
20
|
/** Hidden system notice appended after a user message that mentions "ultrathink". */
|
|
20
21
|
export const ULTRATHINK_NOTICE: string = ultrathinkNotice.trim();
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Whether `text` contains the standalone keyword "ultrathink" (lowercase,
|
|
24
|
-
*
|
|
25
|
+
* prose-delimited) in prose — never inside a code block, inline code span,
|
|
25
26
|
* or XML/HTML section.
|
|
26
27
|
*/
|
|
27
28
|
export function containsUltrathink(text: string): boolean {
|
|
@@ -35,7 +36,7 @@ export function containsUltrathink(text: string): boolean {
|
|
|
35
36
|
*/
|
|
36
37
|
export const highlightUltrathink: KeywordHighlighter = createGradientHighlighter({
|
|
37
38
|
probe: /ultrathink/,
|
|
38
|
-
highlight:
|
|
39
|
+
highlight: magicKeywordRegex("ultrathink", "g"),
|
|
39
40
|
stops: 14,
|
|
40
41
|
hue: t => t * 330,
|
|
41
42
|
});
|