jeopi-cli 16.4.2 → 16.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/cli.js +3033 -2991
  3. package/dist/types/advisor/advise-tool.d.ts +5 -1
  4. package/dist/types/advisor/config.d.ts +4 -3
  5. package/dist/types/advisor/runtime.d.ts +15 -0
  6. package/dist/types/cli/args.d.ts +2 -0
  7. package/dist/types/cli/update-cli.d.ts +4 -1
  8. package/dist/types/cli/usage-error.d.ts +4 -0
  9. package/dist/types/commit/agentic/agent.d.ts +1 -0
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/model-resolver.d.ts +7 -1
  12. package/dist/types/config/models-config.d.ts +1 -1
  13. package/dist/types/config/settings-schema.d.ts +3 -13
  14. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  15. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  16. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  17. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  18. package/dist/types/internal-urls/memory-protocol.d.ts +6 -6
  19. package/dist/types/internal-urls/types.d.ts +7 -0
  20. package/dist/types/irc/bus.d.ts +4 -0
  21. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  22. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  23. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  24. package/dist/types/modes/components/welcome.d.ts +4 -0
  25. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  26. package/dist/types/modes/orchestrate.d.ts +1 -1
  27. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  28. package/dist/types/modes/rpc/rpc-mode.d.ts +27 -7
  29. package/dist/types/modes/ultrathink.d.ts +1 -1
  30. package/dist/types/modes/utils/transcript-render-helpers.d.ts +9 -0
  31. package/dist/types/modes/workflow.d.ts +1 -1
  32. package/dist/types/session/agent-session.d.ts +6 -0
  33. package/dist/types/session/auth-broker-config.d.ts +2 -2
  34. package/dist/types/tools/bash-skill-urls.d.ts +3 -2
  35. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  36. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  37. package/dist/types/tools/browser/launch.d.ts +1 -0
  38. package/dist/types/tools/browser/run-cancellation.d.ts +17 -2
  39. package/dist/types/tools/browser/run-output.d.ts +25 -0
  40. package/dist/types/tools/browser/tab-worker.d.ts +22 -0
  41. package/dist/types/utils/changelog.d.ts +35 -1
  42. package/package.json +12 -12
  43. package/src/advisor/__tests__/advisor.test.ts +13 -0
  44. package/src/advisor/__tests__/config.test.ts +36 -0
  45. package/src/advisor/advise-tool.ts +6 -1
  46. package/src/advisor/config.ts +11 -8
  47. package/src/advisor/runtime.ts +127 -0
  48. package/src/autolearn/controller.ts +15 -3
  49. package/src/cli/args.ts +12 -0
  50. package/src/cli/flag-tables.ts +21 -7
  51. package/src/cli/update-cli.ts +63 -13
  52. package/src/cli/usage-error.ts +7 -0
  53. package/src/commands/acp.ts +11 -2
  54. package/src/commands/launch.ts +12 -3
  55. package/src/commit/agentic/agent.ts +4 -0
  56. package/src/commit/agentic/index.ts +46 -21
  57. package/src/config/config-file.ts +30 -12
  58. package/src/config/model-registry.ts +7 -7
  59. package/src/config/model-resolver.ts +24 -6
  60. package/src/config/models-config.ts +1 -1
  61. package/src/config/settings-schema.ts +3 -15
  62. package/src/edit/index.ts +10 -76
  63. package/src/edit/renderer.ts +13 -10
  64. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  65. package/src/eval/__tests__/bridge-timeout.test.ts +60 -0
  66. package/src/eval/executor-base.ts +84 -7
  67. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  68. package/src/eval/py/tool-bridge.ts +13 -14
  69. package/src/exec/non-interactive-env.ts +0 -1
  70. package/src/export/ttsr.ts +0 -3
  71. package/src/extensibility/custom-tools/loader.ts +70 -37
  72. package/src/extensibility/custom-tools/types.ts +3 -0
  73. package/src/extensibility/extensions/runner.ts +3 -0
  74. package/src/extensibility/extensions/types.ts +3 -0
  75. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +9 -1
  76. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +30 -3
  77. package/src/extensibility/plugins/manager.ts +21 -0
  78. package/src/internal-urls/docs-index.generated.txt +1 -1
  79. package/src/internal-urls/memory-protocol.ts +13 -8
  80. package/src/internal-urls/skill-protocol.ts +1 -1
  81. package/src/internal-urls/types.ts +7 -0
  82. package/src/irc/bus.ts +48 -18
  83. package/src/lsp/client.ts +7 -1
  84. package/src/lsp/deferred-diagnostics.ts +66 -0
  85. package/src/main.ts +13 -13
  86. package/src/mcp/tool-bridge.ts +57 -6
  87. package/src/mcp/transports/stdio.test.ts +45 -1
  88. package/src/mcp/transports/stdio.ts +6 -3
  89. package/src/modes/acp/acp-agent.ts +73 -2
  90. package/src/modes/acp/acp-event-mapper.ts +5 -0
  91. package/src/modes/acp/acp-mode.ts +11 -0
  92. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  93. package/src/modes/components/advisor-config.ts +15 -7
  94. package/src/modes/components/chat-transcript-builder.ts +31 -11
  95. package/src/modes/components/move-overlay.ts +2 -3
  96. package/src/modes/components/plan-review-overlay.ts +7 -0
  97. package/src/modes/components/tips.txt +1 -1
  98. package/src/modes/components/welcome.ts +13 -14
  99. package/src/modes/controllers/command-controller.ts +8 -10
  100. package/src/modes/controllers/event-controller.ts +58 -42
  101. package/src/modes/controllers/input-controller.ts +9 -6
  102. package/src/modes/controllers/selector-controller.ts +3 -2
  103. package/src/modes/controllers/tan-command-controller.ts +40 -1
  104. package/src/modes/interactive-mode.ts +13 -0
  105. package/src/modes/magic-keyword-boundary.ts +23 -0
  106. package/src/modes/orchestrate.ts +6 -5
  107. package/src/modes/print-mode.ts +9 -0
  108. package/src/modes/rpc/host-tools.ts +13 -0
  109. package/src/modes/rpc/rpc-mode.ts +130 -56
  110. package/src/modes/ultrathink.ts +6 -5
  111. package/src/modes/utils/transcript-render-helpers.ts +44 -0
  112. package/src/modes/utils/ui-helpers.ts +16 -1
  113. package/src/modes/workflow.ts +5 -4
  114. package/src/prompts/system/eager-task.md +2 -2
  115. package/src/prompts/system/tan-context-switch.md +17 -0
  116. package/src/prompts/tools/browser.md +5 -5
  117. package/src/sdk.ts +3 -0
  118. package/src/session/agent-session.ts +267 -36
  119. package/src/session/auth-broker-config.ts +4 -4
  120. package/src/session/snapcompact-inline.ts +3 -19
  121. package/src/session/streaming-output.ts +40 -12
  122. package/src/slash-commands/builtin-registry.ts +8 -8
  123. package/src/task/executor.ts +8 -0
  124. package/src/task/render.ts +51 -44
  125. package/src/tools/bash-skill-urls.ts +7 -4
  126. package/src/tools/bash.ts +30 -33
  127. package/src/tools/browser/cmux/cmux-tab.ts +49 -50
  128. package/src/tools/browser/cmux/rpc.ts +50 -0
  129. package/src/tools/browser/launch.ts +8 -4
  130. package/src/tools/browser/run-cancellation.ts +44 -5
  131. package/src/tools/browser/run-output.ts +76 -0
  132. package/src/tools/browser/tab-worker.ts +280 -133
  133. package/src/tools/eval-render.ts +16 -14
  134. package/src/tools/glob.ts +20 -6
  135. package/src/tools/grep.ts +1 -0
  136. package/src/tools/image-gen.ts +3 -4
  137. package/src/tools/irc.ts +27 -13
  138. package/src/tools/path-utils.ts +6 -1
  139. package/src/tools/read.ts +14 -7
  140. package/src/tools/write.ts +22 -5
  141. package/src/tools/yield.ts +29 -1
  142. package/src/utils/changelog.ts +106 -9
  143. package/src/utils/git.ts +0 -1
  144. package/src/utils/title-generator.ts +72 -7
  145. package/src/web/search/index.ts +14 -6
  146. package/src/web/search/providers/perplexity.ts +18 -2
  147. package/dist/types/tools/bash-command-fixup.d.ts +0 -11
  148. package/src/tools/bash-command-fixup.ts +0 -37
@@ -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
 
@@ -34,6 +34,7 @@ import {
34
34
  buildIrcMessageCard,
35
35
  normalizeToolArgs,
36
36
  resolveAssistantErrorMessage,
37
+ splitAssistantMessageToolTimeline,
37
38
  } from "../utils/transcript-render-helpers";
38
39
  import { createAdvisorMessageCard } from "./advisor-message";
39
40
  import { AssistantMessageComponent } from "./assistant-message";
@@ -268,13 +269,16 @@ export class ChatTranscriptBuilder {
268
269
  }
269
270
 
270
271
  #appendAssistantMessage(message: Extract<AgentMessage, { role: "assistant" }>): void {
272
+ const hideThinkingBlock = this.deps.hideThinkingBlock?.() ?? false;
273
+ const proseOnlyThinking = this.deps.proseOnlyThinking ? this.deps.proseOnlyThinking() : true;
274
+ const timeline = splitAssistantMessageToolTimeline(message);
271
275
  const assistantComponent = new AssistantMessageComponent(
272
- message,
273
- this.deps.hideThinkingBlock?.() ?? false,
276
+ timeline.beforeTools,
277
+ hideThinkingBlock,
274
278
  () => this.deps.requestRender(),
275
- this.deps.getMessageRenderer ? undefined : [], // placeholder for thinkingRenderers
276
- undefined, // placeholder for imageBudget
277
- this.deps.proseOnlyThinking ? this.deps.proseOnlyThinking() : true,
279
+ this.deps.getMessageRenderer ? undefined : [],
280
+ undefined,
281
+ proseOnlyThinking,
278
282
  );
279
283
  this.container.addChild(assistantComponent);
280
284
 
@@ -286,18 +290,30 @@ export class ChatTranscriptBuilder {
286
290
  this.#lastAssistantUsage = message.usage;
287
291
  }
288
292
 
289
- const hasVisibleAssistantContent = assistantHasVisibleContent(message);
290
- if (hasVisibleAssistantContent) {
291
- // New visible turn content closes the current read run (mirrors rebuild).
293
+ if (assistantHasVisibleContent(message)) {
292
294
  this.#readGroup?.seal();
293
295
  this.#readGroup = null;
294
296
  }
295
297
 
296
298
  const { hasErrorStop, errorMessage } = resolveAssistantErrorMessage(message);
299
+ const appendAssistantSegment = (segment: Extract<AgentMessage, { role: "assistant" }> | undefined) => {
300
+ if (!segment || !assistantHasVisibleContent(segment)) return;
301
+ this.container.addChild(
302
+ new AssistantMessageComponent(
303
+ segment,
304
+ hideThinkingBlock,
305
+ () => this.deps.requestRender(),
306
+ this.deps.getMessageRenderer ? undefined : [],
307
+ undefined,
308
+ proseOnlyThinking,
309
+ ),
310
+ );
311
+ };
297
312
 
298
313
  for (const content of message.content) {
299
314
  if (content.type !== "toolCall") continue;
300
315
  this.#resolveWaitingPoll(content.name);
316
+ const afterToolSegment = timeline.afterToolCalls.get(content.id);
301
317
 
302
318
  if (
303
319
  content.name === "read" &&
@@ -312,10 +328,14 @@ export class ChatTranscriptBuilder {
312
328
  false,
313
329
  content.id,
314
330
  );
331
+ } else if (afterToolSegment) {
332
+ const group = this.#ensureReadGroup();
333
+ group.updateArgs(content.arguments, content.id);
334
+ this.#pendingTools.set(content.id, group);
315
335
  } else {
316
- const normalizedArgs = normalizeToolArgs(content.arguments);
317
- this.#readArgs.set(content.id, normalizedArgs);
336
+ this.#readArgs.set(content.id, normalizeToolArgs(content.arguments));
318
337
  }
338
+ appendAssistantSegment(afterToolSegment);
319
339
  continue;
320
340
  }
321
341
 
@@ -325,7 +345,6 @@ export class ChatTranscriptBuilder {
325
345
  content.name,
326
346
  content.arguments,
327
347
  {
328
- // Images can't be sliced through the scroll viewport; keep them off.
329
348
  showImages: false,
330
349
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
331
350
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
@@ -348,6 +367,7 @@ export class ChatTranscriptBuilder {
348
367
  } else {
349
368
  this.#pendingTools.set(content.id, component);
350
369
  }
370
+ appendAssistantSegment(afterToolSegment);
351
371
  }
352
372
 
353
373
  this.#pendingUsage = settings.get("display.showTokenUsage") ? message.usage : undefined;
@@ -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
  ? ""
@@ -1,4 +1,4 @@
1
- import type { ImageContent } from "jeopi-ai";
1
+ import type { AssistantMessage, ImageContent } from "jeopi-ai";
2
2
  import * as AIError from "jeopi-ai/error";
3
3
  import { getStreamingPartialJson } from "jeopi-ai/utils/block-symbols";
4
4
  import { type Component, Loader, TERMINAL } from "jeopi-tui";
@@ -31,6 +31,7 @@ import { vocalizer } from "../../tts/vocalizer";
31
31
  import { canonicalizeMessage } from "../../utils/thinking-display";
32
32
  import { interruptHint } from "../shared";
33
33
  import { createAssistantMessageComponent } from "../utils/interactive-context-helpers";
34
+ import { assistantHasVisibleContent, splitAssistantMessageToolTimeline } from "../utils/transcript-render-helpers";
34
35
  import { StreamingRevealController } from "./streaming-reveal";
35
36
  import { streamingStringKeysForTool, ToolArgsRevealController } from "./tool-args-reveal";
36
37
 
@@ -74,8 +75,9 @@ export class EventController {
74
75
  #lastVisibleBlockCount = 0;
75
76
  #renderedCustomMessages = new Set<string>();
76
77
  #lastIntent: string | undefined = undefined;
77
- #backgroundToolCallIds = new Set<string>();
78
+ #backgroundTaskCallIds = new Set<string>();
78
79
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
80
+ #toolTimelineComponents = new Map<string, Component>();
79
81
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
80
82
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
81
83
  // Assistant component whose turn-ending error is currently mirrored in the
@@ -235,6 +237,24 @@ export class EventController {
235
237
  assistantComponent.setToolResultImages(toolCallId, images);
236
238
  return true;
237
239
  }
240
+
241
+ #insertAfterTranscriptComponent(anchor: Component | undefined, component: Component): void {
242
+ const children = this.ctx.chatContainer.children;
243
+ const anchorIndex = anchor ? children.indexOf(anchor) : -1;
244
+ this.ctx.chatContainer.addChild(component);
245
+ if (anchorIndex < 0) return;
246
+ children.splice(children.length - 1, 1);
247
+ children.splice(anchorIndex + 1, 0, component);
248
+ }
249
+
250
+ #appendPostToolAssistantSegment(toolCallId: string, segment: AssistantMessage | undefined) {
251
+ if (!segment || !assistantHasVisibleContent(segment)) return undefined;
252
+ const component = createAssistantMessageComponent(this.ctx);
253
+ component.updateContent(segment);
254
+ component.markTranscriptBlockFinalized();
255
+ this.#insertAfterTranscriptComponent(this.#toolTimelineComponents.get(toolCallId), component);
256
+ return component;
257
+ }
238
258
  #updateWorkingMessageFromIntent(intent: unknown): void {
239
259
  if (this.ctx.session.isAborting) return;
240
260
  // Streamed JSON can deliver non-string `i` (object, number, boolean) before
@@ -262,7 +282,8 @@ export class EventController {
262
282
  this.#lastVisibleBlockCount = 0;
263
283
  this.#renderedCustomMessages.clear();
264
284
  this.#lastIntent = undefined;
265
- this.#backgroundToolCallIds.clear();
285
+ this.#toolTimelineComponents.clear();
286
+ this.#backgroundTaskCallIds.clear();
266
287
  this.#readToolCallArgs.clear();
267
288
  this.#readToolCallAssistantComponents.clear();
268
289
  this.#lastAssistantComponent = undefined;
@@ -306,6 +327,7 @@ export class EventController {
306
327
  }
307
328
 
308
329
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
330
+ this.#toolTimelineComponents.clear();
309
331
  this.#lastIntent = undefined;
310
332
  this.#readToolCallArgs.clear();
311
333
  this.#readToolCallAssistantComponents.clear();
@@ -626,6 +648,7 @@ export class EventController {
626
648
  const group = this.#getReadGroup();
627
649
  group.updateArgs(content.arguments, content.id);
628
650
  this.ctx.pendingTools.set(content.id, group);
651
+ this.#toolTimelineComponents.set(content.id, group);
629
652
  }
630
653
  continue;
631
654
  }
@@ -672,6 +695,7 @@ export class EventController {
672
695
  component.setExpanded(this.ctx.toolOutputExpanded);
673
696
  this.ctx.chatContainer.addChild(component);
674
697
  this.ctx.pendingTools.set(content.id, component);
698
+ this.#toolTimelineComponents.set(content.id, component);
675
699
  this.#toolArgsReveal.bind(content.id, component);
676
700
  } else {
677
701
  const component = this.ctx.pendingTools.get(content.id);
@@ -745,15 +769,12 @@ export class EventController {
745
769
  errorMessage = resolveAbortLabel(this.ctx.streamingMessage, this.ctx.viewSession.retryAttempt);
746
770
  this.ctx.streamingMessage.errorMessage = errorMessage;
747
771
  }
748
- if (silentlyAborted || ttsrSilenced) {
749
- // Silence the streaming render by downgrading stopReason to "stop" for
750
- // display only — does NOT mutate the persisted message's stopReason
751
- // (the marker on errorMessage drives replay-side suppression).
752
- const msgWithoutAbort = { ...this.ctx.streamingMessage, stopReason: "stop" as const };
753
- this.ctx.streamingComponent.updateContent(msgWithoutAbort);
754
- } else {
755
- this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
756
- }
772
+ const displayMessage: AssistantMessage =
773
+ silentlyAborted || ttsrSilenced
774
+ ? { ...this.ctx.streamingMessage, stopReason: "stop" as const }
775
+ : this.ctx.streamingMessage;
776
+ const displayTimeline = splitAssistantMessageToolTimeline(displayMessage);
777
+ this.ctx.streamingComponent.updateContent(displayTimeline.beforeTools);
757
778
 
758
779
  if (this.ctx.streamingMessage.stopReason !== "aborted" && this.ctx.streamingMessage.stopReason !== "error") {
759
780
  for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
@@ -763,9 +784,9 @@ export class EventController {
763
784
  // The turn ended without running these calls (abort/error/TTSR rewind),
764
785
  // so they will never produce a result. Seal them so they stop animating
765
786
  // and freeze instead of pinning the transcript live region while a retry
766
- // streams fresh blocks below them. Background tools keep updating.
787
+ // streams fresh blocks below them. Background task calls keep updating.
767
788
  for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
768
- if (!this.#backgroundToolCallIds.has(toolCallId) && component instanceof ToolExecutionComponent) {
789
+ if (!this.#backgroundTaskCallIds.has(toolCallId) && component instanceof ToolExecutionComponent) {
769
790
  component.seal();
770
791
  }
771
792
  }
@@ -783,8 +804,13 @@ export class EventController {
783
804
  }
784
805
  this.ctx.lastAssistantUsage = usage;
785
806
  }
786
- this.#lastAssistantComponent = this.ctx.streamingComponent;
787
- this.#lastAssistantComponent.markTranscriptBlockFinalized();
807
+ this.ctx.streamingComponent.markTranscriptBlockFinalized();
808
+ let lastPostToolAssistantComponent: AssistantMessageComponent | undefined;
809
+ for (const [toolCallId, segment] of displayTimeline.afterToolCalls) {
810
+ const component = this.#appendPostToolAssistantSegment(toolCallId, segment);
811
+ if (component) lastPostToolAssistantComponent = component;
812
+ }
813
+ this.#lastAssistantComponent = lastPostToolAssistantComponent ?? this.ctx.streamingComponent;
788
814
  if (settings.get("display.showTokenUsage")) {
789
815
  this.ctx.chatContainer.addChild(
790
816
  createUsageRowBlock(event.message.usage, event.message.duration, event.message.ttft),
@@ -821,6 +847,7 @@ export class EventController {
821
847
  const group = this.#getReadGroup();
822
848
  group.updateArgs(event.args, event.toolCallId);
823
849
  this.ctx.pendingTools.set(event.toolCallId, group);
850
+ this.#toolTimelineComponents.set(event.toolCallId, group);
824
851
  }
825
852
  this.ctx.ui.requestRender();
826
853
  return;
@@ -846,6 +873,7 @@ export class EventController {
846
873
  component.setExpanded(this.ctx.toolOutputExpanded);
847
874
  this.ctx.chatContainer.addChild(component);
848
875
  this.ctx.pendingTools.set(event.toolCallId, component);
876
+ this.#toolTimelineComponents.set(event.toolCallId, component);
849
877
  this.ctx.ui.requestRender();
850
878
  } else {
851
879
  // The tool is about to run, so its arguments are final and validated.
@@ -885,7 +913,7 @@ export class EventController {
885
913
  );
886
914
  if (isFinalAsyncState) {
887
915
  this.ctx.pendingTools.delete(event.toolCallId);
888
- this.#backgroundToolCallIds.delete(event.toolCallId);
916
+ this.#backgroundTaskCallIds.delete(event.toolCallId);
889
917
  }
890
918
  this.ctx.ui.requestRender();
891
919
  }
@@ -906,13 +934,7 @@ export class EventController {
906
934
  component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId);
907
935
  this.ctx.pendingTools.delete(event.toolCallId);
908
936
  }
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
- }
937
+ this.#clearReadToolCall(event.toolCallId);
916
938
  this.ctx.ui.requestRender();
917
939
  } else {
918
940
  let component = this.ctx.pendingTools.get(event.toolCallId);
@@ -924,30 +946,24 @@ export class EventController {
924
946
  }
925
947
  component = group;
926
948
  this.ctx.pendingTools.set(event.toolCallId, group);
949
+ this.#toolTimelineComponents.set(event.toolCallId, group);
927
950
  }
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
- }
951
+ component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId);
952
+ this.ctx.pendingTools.delete(event.toolCallId);
953
+ this.#clearReadToolCall(event.toolCallId);
938
954
  this.ctx.ui.requestRender();
939
955
  }
940
956
  } else {
941
957
  const component = this.ctx.pendingTools.get(event.toolCallId);
942
958
  if (component) {
943
959
  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);
960
+ const isBackgroundTask = event.toolName === "task" && asyncState === "running";
961
+ component.updateResult({ ...event.result, isError: event.isError }, isBackgroundTask, event.toolCallId);
962
+ if (isBackgroundTask) {
963
+ this.#backgroundTaskCallIds.add(event.toolCallId);
948
964
  } else {
949
965
  this.ctx.pendingTools.delete(event.toolCallId);
950
- this.#backgroundToolCallIds.delete(event.toolCallId);
966
+ this.#backgroundTaskCallIds.delete(event.toolCallId);
951
967
  }
952
968
  if (component instanceof ToolExecutionComponent && component.isDisplaceableBlock()) {
953
969
  if (event.toolName === "job" && component.canBeDisplacedBy("job")) {
@@ -1025,7 +1041,7 @@ export class EventController {
1025
1041
  }
1026
1042
  await this.ctx.flushPendingModelSwitch();
1027
1043
  for (const toolCallId of Array.from(this.ctx.pendingTools.keys())) {
1028
- if (!this.#backgroundToolCallIds.has(toolCallId)) {
1044
+ if (!this.#backgroundTaskCallIds.has(toolCallId)) {
1029
1045
  // A foreground tool still pending at turn end never delivered a result;
1030
1046
  // seal it so it freezes (and stops animating) rather than lingering in
1031
1047
  // the transcript live region as a streaming preview until the next thaw.
@@ -1039,8 +1055,8 @@ export class EventController {
1039
1055
  this.ctx.pendingTools.delete(toolCallId);
1040
1056
  }
1041
1057
  }
1042
- this.#backgroundToolCallIds = new Set(
1043
- Array.from(this.#backgroundToolCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)),
1058
+ this.#backgroundTaskCallIds = new Set(
1059
+ Array.from(this.#backgroundTaskCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)),
1044
1060
  );
1045
1061
  this.#readToolCallArgs.clear();
1046
1062
  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
  }