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.
Files changed (125) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/cli.js +3179 -3140
  3. package/dist/types/advisor/config.d.ts +4 -3
  4. package/dist/types/cli/args.d.ts +2 -0
  5. package/dist/types/cli/update-cli.d.ts +4 -1
  6. package/dist/types/cli/usage-error.d.ts +4 -0
  7. package/dist/types/commit/agentic/agent.d.ts +1 -0
  8. package/dist/types/config/model-resolver.d.ts +7 -1
  9. package/dist/types/config/settings-schema.d.ts +3 -3
  10. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  11. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  12. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  13. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  14. package/dist/types/internal-urls/memory-protocol.d.ts +6 -6
  15. package/dist/types/internal-urls/types.d.ts +7 -0
  16. package/dist/types/irc/bus.d.ts +4 -0
  17. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  18. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  19. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  20. package/dist/types/modes/components/welcome.d.ts +4 -0
  21. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  22. package/dist/types/modes/orchestrate.d.ts +1 -1
  23. package/dist/types/modes/ultrathink.d.ts +1 -1
  24. package/dist/types/modes/workflow.d.ts +1 -1
  25. package/dist/types/session/agent-session.d.ts +5 -0
  26. package/dist/types/session/auth-broker-config.d.ts +2 -2
  27. package/dist/types/tools/bash-skill-urls.d.ts +3 -2
  28. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  29. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  30. package/dist/types/tools/browser/launch.d.ts +1 -0
  31. package/dist/types/tools/browser/run-cancellation.d.ts +17 -2
  32. package/dist/types/tools/browser/run-output.d.ts +25 -0
  33. package/dist/types/tools/browser/tab-worker.d.ts +22 -0
  34. package/dist/types/utils/changelog.d.ts +35 -1
  35. package/package.json +12 -12
  36. package/src/advisor/__tests__/advisor.test.ts +13 -0
  37. package/src/advisor/__tests__/config.test.ts +36 -0
  38. package/src/advisor/config.ts +11 -8
  39. package/src/autolearn/controller.ts +14 -2
  40. package/src/cli/args.ts +12 -0
  41. package/src/cli/flag-tables.ts +21 -7
  42. package/src/cli/update-cli.ts +63 -13
  43. package/src/cli/usage-error.ts +7 -0
  44. package/src/commands/acp.ts +11 -2
  45. package/src/commands/launch.ts +12 -3
  46. package/src/commit/agentic/agent.ts +4 -0
  47. package/src/commit/agentic/index.ts +46 -21
  48. package/src/config/model-resolver.ts +24 -6
  49. package/src/config/settings-schema.ts +3 -3
  50. package/src/edit/index.ts +10 -76
  51. package/src/edit/renderer.ts +13 -10
  52. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  53. package/src/eval/__tests__/bridge-timeout.test.ts +60 -0
  54. package/src/eval/executor-base.ts +84 -7
  55. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  56. package/src/eval/py/tool-bridge.ts +13 -14
  57. package/src/exec/non-interactive-env.ts +0 -1
  58. package/src/export/ttsr.ts +0 -3
  59. package/src/extensibility/custom-tools/loader.ts +70 -37
  60. package/src/extensibility/custom-tools/types.ts +3 -0
  61. package/src/extensibility/extensions/runner.ts +3 -0
  62. package/src/extensibility/extensions/types.ts +3 -0
  63. package/src/extensibility/plugins/manager.ts +21 -0
  64. package/src/internal-urls/docs-index.generated.txt +1 -1
  65. package/src/internal-urls/memory-protocol.ts +13 -8
  66. package/src/internal-urls/skill-protocol.ts +1 -1
  67. package/src/internal-urls/types.ts +7 -0
  68. package/src/irc/bus.ts +48 -18
  69. package/src/lsp/client.ts +7 -1
  70. package/src/lsp/deferred-diagnostics.ts +66 -0
  71. package/src/main.ts +13 -13
  72. package/src/mcp/tool-bridge.ts +57 -6
  73. package/src/mcp/transports/stdio.test.ts +45 -1
  74. package/src/mcp/transports/stdio.ts +6 -3
  75. package/src/modes/acp/acp-agent.ts +73 -2
  76. package/src/modes/acp/acp-event-mapper.ts +5 -0
  77. package/src/modes/acp/acp-mode.ts +11 -0
  78. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  79. package/src/modes/components/advisor-config.ts +15 -7
  80. package/src/modes/components/move-overlay.ts +2 -3
  81. package/src/modes/components/plan-review-overlay.ts +7 -0
  82. package/src/modes/components/tips.txt +1 -1
  83. package/src/modes/components/welcome.ts +13 -14
  84. package/src/modes/controllers/command-controller.ts +8 -10
  85. package/src/modes/controllers/event-controller.ts +17 -30
  86. package/src/modes/controllers/input-controller.ts +9 -6
  87. package/src/modes/controllers/selector-controller.ts +3 -2
  88. package/src/modes/controllers/tan-command-controller.ts +40 -1
  89. package/src/modes/interactive-mode.ts +13 -0
  90. package/src/modes/magic-keyword-boundary.ts +23 -0
  91. package/src/modes/orchestrate.ts +6 -5
  92. package/src/modes/print-mode.ts +9 -0
  93. package/src/modes/ultrathink.ts +6 -5
  94. package/src/modes/workflow.ts +5 -4
  95. package/src/prompts/system/eager-task.md +2 -2
  96. package/src/prompts/system/tan-context-switch.md +17 -0
  97. package/src/prompts/tools/browser.md +5 -5
  98. package/src/sdk.ts +3 -0
  99. package/src/session/agent-session.ts +172 -25
  100. package/src/session/auth-broker-config.ts +4 -4
  101. package/src/session/snapcompact-inline.ts +3 -19
  102. package/src/session/streaming-output.ts +40 -12
  103. package/src/slash-commands/builtin-registry.ts +8 -8
  104. package/src/task/executor.ts +8 -0
  105. package/src/tools/bash-skill-urls.ts +7 -4
  106. package/src/tools/bash.ts +30 -22
  107. package/src/tools/browser/cmux/cmux-tab.ts +49 -50
  108. package/src/tools/browser/cmux/rpc.ts +50 -0
  109. package/src/tools/browser/launch.ts +8 -4
  110. package/src/tools/browser/run-cancellation.ts +44 -5
  111. package/src/tools/browser/run-output.ts +76 -0
  112. package/src/tools/browser/tab-worker.ts +280 -133
  113. package/src/tools/eval-render.ts +16 -14
  114. package/src/tools/glob.ts +20 -6
  115. package/src/tools/grep.ts +1 -0
  116. package/src/tools/image-gen.ts +3 -4
  117. package/src/tools/irc.ts +27 -13
  118. package/src/tools/read.ts +14 -7
  119. package/src/tools/write.ts +22 -5
  120. package/src/tools/yield.ts +29 -1
  121. package/src/utils/changelog.ts +106 -9
  122. package/src/utils/git.ts +0 -1
  123. package/src/utils/title-generator.ts +68 -7
  124. package/src/web/search/index.ts +14 -6
  125. 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 strip = (lines: readonly string[]): string => lines.join("\n").replace(/\x1b\[[0-9;]*m/g, "");
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
- /** Default when the set is empty or exactly read/grep/glob; else the available-ordered subset. */
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 undefined;
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?.length ? advisor.tools.join(", ") : "read, grep, glob (default)";
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" && !advisor.model?.trim() && !advisor.tools?.length && !advisor.instructions?.trim()
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?.length ? advisor.tools.join(", ") : "(default: read/grep/glob)";
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?.length ? advisor.tools.join(", ") : "(default: read/grep/glob)";
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 or read/grep/glob = default)",
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(_width: number): readonly string[] {
222
- const w = OVERLAY_WIDTH;
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 [NEW]
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
- /** Per-tip selection weights, parallel to {@link TIPS}. */
48
- const TIP_WEIGHTS: readonly number[] = TIPS.map(tip => (NEW_TIP_MARKER.test(tip) ? NEW_TIP_WEIGHT : 1));
49
- const TIP_WEIGHT_TOTAL = TIP_WEIGHTS.reduce((sum, weight) => sum + weight, 0);
50
-
51
- /** Pick a tip at random, biased toward "[NEW]" tips by {@link NEW_TIP_WEIGHT}.
52
- * Returns "" when no tips are embedded. */
53
- function pickWeightedTip(): string {
54
- if (TIPS.length === 0) return "";
55
- let r = Math.random() * TIP_WEIGHT_TOTAL;
56
- for (let i = 0; i < TIPS.length; i++) {
57
- r -= TIP_WEIGHTS[i] ?? 1;
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 TIPS[TIPS.length - 1] ?? "";
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 { getChangelogPath, parseChangelog } from "../../utils/changelog";
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
- // Default to showing only the latest 3 versions unless --full is specified
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
- #backgroundToolCallIds = new Set<string>();
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.#backgroundToolCallIds.clear();
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 tools keep updating.
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.#backgroundToolCallIds.has(toolCallId) && component instanceof ToolExecutionComponent) {
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.#backgroundToolCallIds.delete(event.toolCallId);
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
- const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
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
- const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
929
- const isBackgroundRunning = asyncState === "running";
930
- component.updateResult({ ...event.result, isError: event.isError }, isBackgroundRunning, event.toolCallId);
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 isBackgroundRunning = asyncState === "running";
945
- component.updateResult({ ...event.result, isError: event.isError }, isBackgroundRunning, event.toolCallId);
946
- if (isBackgroundRunning) {
947
- this.#backgroundToolCallIds.add(event.toolCallId);
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.#backgroundToolCallIds.delete(event.toolCallId);
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.#backgroundToolCallIds.has(toolCallId)) {
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.#backgroundToolCallIds = new Set(
1043
- Array.from(this.#backgroundToolCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)),
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
- await this.ctx.session.setModelTemporary(model);
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: parentSessionId,
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
+ }
@@ -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 whitespace-delimited and
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. Non-global so `.test` stays stateless.
18
- const ORCHESTRATE_WORD = /(?<!\S)orchestrate(?!\S)/;
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
- * whitespace-delimited) in prose — never inside a code block, inline code span,
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: /(?<!\S)orchestrate(?!\S)/g,
40
+ highlight: magicKeywordRegex("orchestrate", "g"),
40
41
  stops: 14,
41
42
  hue: t => 150 + t * 130,
42
43
  });
@@ -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
 
@@ -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 whitespace-delimited and case-sensitive
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. Non-global so `.test` stays stateless.
17
- const ULTRATHINK_WORD = /(?<!\S)ultrathink(?!\S)/;
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
- * whitespace-delimited) in prose — never inside a code block, inline code span,
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: /(?<!\S)ultrathink(?!\S)/g,
39
+ highlight: magicKeywordRegex("ultrathink", "g"),
39
40
  stops: 14,
40
41
  hue: t => t * 330,
41
42
  });