@oh-my-pi/pi-coding-agent 16.4.6 → 16.4.8

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.
@@ -78,6 +78,8 @@ export declare class ModelBrowser implements Component {
78
78
  setPerfStats(perf: ReadonlyMap<string, ModelPerfStats>): void;
79
79
  setMaxVisible(rows: number): void;
80
80
  setShowProvider(show: boolean): void;
81
+ /** Focused: accent cursor + selected-row background band. Unfocused: dim cursor, no band. */
82
+ setFocused(focused: boolean): void;
81
83
  /** Total rendered height for the current `maxVisible` (host layout budgeting). */
82
84
  get renderedRows(): number;
83
85
  get query(): string;
@@ -86,7 +88,13 @@ export declare class ModelBrowser implements Component {
86
88
  get visibleCount(): number;
87
89
  /** Move selection to `selector`; false when it is not in the current view. */
88
90
  selectSelector(selector: string): boolean;
89
- moveSelection(delta: number): void;
91
+ /**
92
+ * Move the selection by `delta` rows, skipping disabled rows. Single steps
93
+ * wrap at the ends; `wrap: false` (page/home/end jumps) clamps instead.
94
+ */
95
+ moveSelection(delta: number, options?: {
96
+ wrap?: boolean;
97
+ }): void;
90
98
  handleInput(data: string): void;
91
99
  /** Cancel-key ladder: clear a non-empty query first, then bubble to the host. */
92
100
  handleCancel(): void;
@@ -95,6 +103,8 @@ export declare class ModelBrowser implements Component {
95
103
  * row (the search row).
96
104
  */
97
105
  routeMouse(event: SgrMouseEvent, line: number): void;
106
+ /** Drop the hover band. Hosts call this when the pointer leaves the browser pane. */
107
+ clearHover(): void;
98
108
  render(width: number): string[];
99
109
  invalidate(): void;
100
110
  }
@@ -23,6 +23,8 @@ export interface PlanReviewOverlayCallbacks {
23
23
  onPick: (label: string) => void;
24
24
  /** Invoked on Esc / cancel. */
25
25
  onCancel: () => void;
26
+ /** Invoked with the current full plan text when the copy hotkey is pressed. */
27
+ onCopyPlan?: (content: string) => void | Promise<void>;
26
28
  /** Invoked when the external-editor key is pressed (overlay stays open). */
27
29
  onExternalEditor?: () => void;
28
30
  /** Invoked when the external-editor key edits the active annotation draft. */
@@ -9,6 +9,10 @@ export declare const WELCOME_SESSION_SLOTS = 4;
9
9
  * the box height is constant regardless of how many servers a project has.
10
10
  */
11
11
  export declare const WELCOME_LSP_SLOTS = 4;
12
+ /** Pick a tip from `tips`, biased toward "[NEW]" tips by {@link NEW_TIP_WEIGHT};
13
+ * `r` is a uniform sample in [0, 1). Returns "" when `tips` is empty.
14
+ * Exported for tests. */
15
+ export declare function pickWeightedTip(tips: readonly string[], r: number): string;
12
16
  export declare function renderWelcomeTip(tip: string, boxWidth: number, phase?: number): string[];
13
17
  export interface RecentSession {
14
18
  name: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "16.4.6",
4
+ "version": "16.4.8",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -52,17 +52,17 @@
52
52
  "@agentclientprotocol/sdk": "0.25.0",
53
53
  "@babel/parser": "^7.29.7",
54
54
  "@mozilla/readability": "^0.6.0",
55
- "@oh-my-pi/hashline": "16.4.6",
56
- "@oh-my-pi/omp-stats": "16.4.6",
57
- "@oh-my-pi/pi-agent-core": "16.4.6",
58
- "@oh-my-pi/pi-ai": "16.4.6",
59
- "@oh-my-pi/pi-catalog": "16.4.6",
60
- "@oh-my-pi/pi-mnemopi": "16.4.6",
61
- "@oh-my-pi/pi-natives": "16.4.6",
62
- "@oh-my-pi/pi-tui": "16.4.6",
63
- "@oh-my-pi/pi-utils": "16.4.6",
64
- "@oh-my-pi/pi-wire": "16.4.6",
65
- "@oh-my-pi/snapcompact": "16.4.6",
55
+ "@oh-my-pi/hashline": "16.4.8",
56
+ "@oh-my-pi/omp-stats": "16.4.8",
57
+ "@oh-my-pi/pi-agent-core": "16.4.8",
58
+ "@oh-my-pi/pi-ai": "16.4.8",
59
+ "@oh-my-pi/pi-catalog": "16.4.8",
60
+ "@oh-my-pi/pi-mnemopi": "16.4.8",
61
+ "@oh-my-pi/pi-natives": "16.4.8",
62
+ "@oh-my-pi/pi-tui": "16.4.8",
63
+ "@oh-my-pi/pi-utils": "16.4.8",
64
+ "@oh-my-pi/pi-wire": "16.4.8",
65
+ "@oh-my-pi/snapcompact": "16.4.8",
66
66
  "@opentelemetry/api": "^1.9.1",
67
67
  "@opentelemetry/context-async-hooks": "^2.7.1",
68
68
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -48,6 +48,15 @@ type BabelClassDeclaration = {
48
48
  };
49
49
 
50
50
  type BabelLexicalDecl = BabelVariableDeclaration | BabelClassDeclaration;
51
+ type BabelFunctionDeclaration = {
52
+ type: "FunctionDeclaration";
53
+ start: number;
54
+ end: number;
55
+ id: { start: number; end: number; name: string } | null;
56
+ };
57
+
58
+ /** Top-level declarations whose bindings must survive the cell (demoted and/or published). */
59
+ type BabelPublishableDecl = BabelLexicalDecl | BabelFunctionDeclaration;
51
60
 
52
61
  type BabelExpressionStatement = {
53
62
  type: "ExpressionStatement";
@@ -322,7 +331,7 @@ function collectBindingNames(pattern: unknown, names: string[]): void {
322
331
  }
323
332
  }
324
333
 
325
- function getLexicalBindingNames(node: BabelLexicalDecl): string[] {
334
+ function getLexicalBindingNames(node: BabelPublishableDecl): string[] {
326
335
  const names: string[] = [];
327
336
  if (node.type === "VariableDeclaration") {
328
337
  for (const declaration of node.declarations ?? []) collectBindingNames(declaration.id, names);
@@ -348,40 +357,49 @@ function appendGlobalBindingPublish(source: string, names: readonly string[]): s
348
357
  * let { a, b } = obj; -> var { a, b } = obj;
349
358
  * class Foo extends Bar {} -> var Foo = class extends Bar {};
350
359
  *
351
- * When the source must run inside the async wrapper, demoted `var`s would normally become
352
- * function-scoped. In that mode we publish each top-level binding back to the wrapper's
353
- * lexical `this`, which is the worker global object.
360
+ * When the source must run inside the async wrapper (top-level `await`), demoted `var`s
361
+ * and the user's own top-level `var` and `function` declarations would be scoped to the
362
+ * wrapper function and die with the cell. In that mode we publish every top-level binding
363
+ * back to the wrapper's lexical `this`, which is the worker global object.
354
364
  *
355
- * Nested declarations (inside functions, blocks, classes) are left alone \u2014 they're
365
+ * Nested declarations (inside functions, blocks, classes) are left alone they're
356
366
  * scoped to their enclosing function/block regardless of `var` vs `let`/`const`.
357
367
  */
358
368
  async function demoteTopLevelLexicals(code: string, options: { publishGlobals?: boolean } = {}): Promise<string> {
359
- if (!/\b(?:const|let|class)\b/.test(code)) return code;
369
+ const publishGlobals = options.publishGlobals === true;
370
+ const fastPath = publishGlobals ? /\b(?:const|let|class|var|function)\b/ : /\b(?:const|let|class)\b/;
371
+ if (!fastPath.test(code)) return code;
360
372
 
361
373
  const ast = await parseProgram(code);
362
374
  if (!ast) {
363
375
  return code;
364
376
  }
365
377
 
366
- const targets: BabelLexicalDecl[] = [];
378
+ const targets: Array<{ node: BabelPublishableDecl; demote: boolean }> = [];
367
379
  for (const node of ast.program.body) {
368
380
  if (node.type === "VariableDeclaration") {
369
381
  const decl = node as unknown as BabelVariableDeclaration;
370
- if (decl.kind === "const" || decl.kind === "let") targets.push(decl);
382
+ if (decl.kind === "const" || decl.kind === "let") targets.push({ node: decl, demote: true });
383
+ else if (publishGlobals) targets.push({ node: decl, demote: false });
371
384
  } else if (node.type === "ClassDeclaration") {
372
385
  const decl = node as unknown as BabelClassDeclaration;
373
- if (decl.id) targets.push(decl);
386
+ if (decl.id) targets.push({ node: decl, demote: true });
387
+ } else if (publishGlobals && node.type === "FunctionDeclaration") {
388
+ const decl = node as unknown as BabelFunctionDeclaration;
389
+ if (decl.id) targets.push({ node: decl, demote: false });
374
390
  }
375
391
  }
376
392
  if (targets.length === 0) return code;
377
393
 
378
- targets.sort((a, b) => b.start - a.start);
394
+ targets.sort((a, b) => b.node.start - a.node.start);
379
395
  let result = code;
380
- for (const node of targets) {
396
+ for (const { node, demote } of targets) {
381
397
  const segment = result.slice(node.start, node.end);
382
- const bindingNames = options.publishGlobals ? getLexicalBindingNames(node) : [];
398
+ const bindingNames = publishGlobals ? getLexicalBindingNames(node) : [];
383
399
  let replacement: string;
384
- if (node.type === "VariableDeclaration") {
400
+ if (!demote) {
401
+ replacement = segment;
402
+ } else if (node.type === "VariableDeclaration") {
385
403
  replacement = `var${segment.slice(node.kind.length)}`;
386
404
  } else {
387
405
  const id = node.id;
@@ -293,6 +293,8 @@ export class ModelBrowser implements Component {
293
293
  /** First visible list row; panned by the wheel, snapped to the selection on keyboard navigation. */
294
294
  #windowStart = 0;
295
295
  #windowCount = 0;
296
+ /** Whether the host pane owns arrow keys; drives cursor strength and the selected-row band. */
297
+ #focused = true;
296
298
 
297
299
  /** Enter or click-on-selected. */
298
300
  onActivate?: (item: ModelBrowserItem) => void;
@@ -345,6 +347,10 @@ export class ModelBrowser implements Component {
345
347
  setShowProvider(show: boolean): void {
346
348
  this.#showProvider = show;
347
349
  }
350
+ /** Focused: accent cursor + selected-row background band. Unfocused: dim cursor, no band. */
351
+ setFocused(focused: boolean): void {
352
+ this.#focused = focused;
353
+ }
348
354
 
349
355
  /** Total rendered height for the current `maxVisible` (host layout budgeting). */
350
356
  get renderedRows(): number {
@@ -416,18 +422,27 @@ export class ModelBrowser implements Component {
416
422
  this.#windowStart = this.#clampWindowStart(this.#windowStart);
417
423
  }
418
424
 
419
- moveSelection(delta: number): void {
425
+ /**
426
+ * Move the selection by `delta` rows, skipping disabled rows. Single steps
427
+ * wrap at the ends; `wrap: false` (page/home/end jumps) clamps instead.
428
+ */
429
+ moveSelection(delta: number, options: { wrap?: boolean } = {}): void {
420
430
  const count = this.#visibleItems.length;
421
431
  if (count === 0) return;
422
- let index = this.#selectedIndex;
423
- for (let step = 0; step < count; step++) {
424
- index = (index + delta + count) % count;
425
- const item = this.#visibleItems[index];
426
- if (item && !this.#isDisabled(item)) {
427
- this.#setSelectedIndex(index);
428
- return;
432
+ if (options.wrap ?? true) {
433
+ let index = this.#selectedIndex;
434
+ for (let step = 0; step < count; step++) {
435
+ index = (index + delta + count) % count;
436
+ const item = this.#visibleItems[index];
437
+ if (item && !this.#isDisabled(item)) {
438
+ this.#setSelectedIndex(index);
439
+ return;
440
+ }
429
441
  }
442
+ return;
430
443
  }
444
+ const target = Math.max(0, Math.min(this.#selectedIndex + delta, count - 1));
445
+ this.#setSelectedIndex(this.#coerceSelectedIndex(target));
431
446
  }
432
447
 
433
448
  #setSelectedIndex(index: number): void {
@@ -514,11 +529,19 @@ export class ModelBrowser implements Component {
514
529
  return;
515
530
  }
516
531
  if (matchesSelectPageUp(data)) {
517
- this.moveSelection(-this.#maxVisible);
532
+ this.moveSelection(-this.#maxVisible, { wrap: false });
518
533
  return;
519
534
  }
520
535
  if (matchesSelectPageDown(data)) {
521
- this.moveSelection(this.#maxVisible);
536
+ this.moveSelection(this.#maxVisible, { wrap: false });
537
+ return;
538
+ }
539
+ if (matchesKey(data, "home")) {
540
+ this.moveSelection(-this.#visibleItems.length, { wrap: false });
541
+ return;
542
+ }
543
+ if (matchesKey(data, "end")) {
544
+ this.moveSelection(this.#visibleItems.length, { wrap: false });
522
545
  return;
523
546
  }
524
547
  if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
@@ -574,6 +597,10 @@ export class ModelBrowser implements Component {
574
597
  this.#setSelectedIndex(index);
575
598
  }
576
599
  }
600
+ /** Drop the hover band. Hosts call this when the pointer leaves the browser pane. */
601
+ clearHover(): void {
602
+ this.#hoveredIndex = null;
603
+ }
577
604
 
578
605
  /** List index under a frame-local row, or null when off-list or on a disabled row. */
579
606
  #hoverIndexAt(line: number): number | null {
@@ -585,22 +612,6 @@ export class ModelBrowser implements Component {
585
612
  return index;
586
613
  }
587
614
 
588
- #chipsFor(model: Model): string {
589
- const parts: string[] = [];
590
- const seen = new Set<string>();
591
- const pushChip = (role: string) => {
592
- if (seen.has(role)) return;
593
- seen.add(role);
594
- const assignment = this.#roles[role];
595
- if (!assignment || !modelsAreEqual(assignment.model, model)) return;
596
- if (getRoleInfo(role, this.#settings).hidden) return;
597
- parts.push(formatRoleChip(role, assignment, this.#settings));
598
- };
599
- for (const role of MODEL_ROLE_IDS) pushChip(role);
600
- for (const role in this.#roles) pushChip(role);
601
- return parts.length > 0 ? ` ${parts.join(" ")}` : "";
602
- }
603
-
604
615
  /** `0.9s 118t/s` measured-perf cell for the row's meta block; empty when unmeasured or the column is off. */
605
616
  #perfCell(item: ModelBrowserItem, mode: PerfMode): string {
606
617
  if (mode === "off") return "";
@@ -627,13 +638,13 @@ export class ModelBrowser implements Component {
627
638
  return ` ${line} `;
628
639
  }
629
640
  const disabled = this.#isDisabled(item);
630
- const prefix = selected ? `${theme.fg("accent", theme.nav.cursor)} ` : " ";
641
+ const prefix = selected && this.#focused ? `${theme.fg("accent", theme.nav.cursor)} ` : " ";
631
642
  const providerPrefix = this.#showProvider ? theme.fg("dim", `${item.provider}/`) : "";
632
643
  const name = selected ? theme.fg("accent", item.id) : item.id;
633
644
  const overLimit = disabled
634
645
  ? ` ${theme.status.disabled} context>${formatNumber(item.model.contextWindow ?? 0).toLowerCase()}`
635
646
  : "";
636
- let left = `${prefix}${providerPrefix}${name}${this.#chipsFor(item.model)}${overLimit}`;
647
+ let left = `${prefix}${providerPrefix}${name}${overLimit}`;
637
648
 
638
649
  // Perf column collapses entirely when no visible row has measurements.
639
650
  const perfCol =
@@ -648,7 +659,9 @@ export class ModelBrowser implements Component {
648
659
  if (disabled) {
649
660
  line = theme.fg("dim", Bun.stripANSI(line));
650
661
  }
651
- if (hovered && !selected && !disabled) {
662
+ // The bg band is reserved for the mouse: it marks hover, nothing else.
663
+ // Keyboard selection is the cursor glyph + accent name.
664
+ if (hovered && !disabled) {
652
665
  line = theme.bg("selectedBg", line);
653
666
  }
654
667
  return line;
@@ -461,7 +461,7 @@ export class ModelHubComponent implements Component {
461
461
  label: providerId,
462
462
  providerId,
463
463
  locked: isLocked,
464
- annotation: isLocked ? "login" : String(availableCounts.get(providerId) ?? 0),
464
+ annotation: isLocked ? undefined : String(availableCounts.get(providerId) ?? 0),
465
465
  oauth: oauthIds.has(providerId),
466
466
  catalogCount: catalogCounts.get(providerId) ?? 0,
467
467
  });
@@ -1468,6 +1468,10 @@ export class ModelHubComponent implements Component {
1468
1468
  this.#roleHover = null;
1469
1469
  if (overBody && this.#isBrowserView(entry)) {
1470
1470
  this.#browser.routeMouse(event, bodyLine);
1471
+ } else {
1472
+ // Pointer left the browser pane: without this, the last
1473
+ // hovered row keeps its band while the sidebar hovers too.
1474
+ this.#browser.clearHover();
1471
1475
  }
1472
1476
  }
1473
1477
  return true;
@@ -1578,11 +1582,10 @@ export class ModelHubComponent implements Component {
1578
1582
  // While searching, entries the hop skips gray out: locked and
1579
1583
  // zero-match providers, an empty Recent, and the Roles view.
1580
1584
  const muted = entry.locked || matchCount === 0 || (searching && entry.kind === "roles");
1581
- const cursor = active
1582
- ? this.#focus === "scope"
1583
- ? theme.fg("accent", theme.nav.cursor)
1584
- : theme.fg("dim", theme.nav.cursor)
1585
- : " ";
1585
+ // The sidebar's active entry is state, not a cursor: accent label
1586
+ // plus a cursor glyph while the sidebar owns the arrows. The band
1587
+ // stays in the body pane so the two never look alike.
1588
+ const cursor = active && this.#focus === "scope" ? theme.fg("accent", theme.nav.cursor) : " ";
1586
1589
 
1587
1590
  let icon: string;
1588
1591
  if (entry.kind === "recent") {
@@ -1597,7 +1600,7 @@ export class ModelHubComponent implements Component {
1597
1600
  const labelStyled = muted
1598
1601
  ? theme.fg("dim", entry.label)
1599
1602
  : active
1600
- ? theme.fg("accent", entry.label)
1603
+ ? theme.bold(theme.fg("accent", entry.label))
1601
1604
  : entry.label;
1602
1605
 
1603
1606
  const refreshing = entry.providerId ? this.#refreshingProviders.has(entry.providerId) : false;
@@ -1614,8 +1617,10 @@ export class ModelHubComponent implements Component {
1614
1617
  line = `${left}${" ".repeat(width - leftWidth - annWidth)}${annotationStyled}`;
1615
1618
  } else {
1616
1619
  line = truncateToWidth(left, width);
1620
+ const lineWidth = visibleWidth(line);
1621
+ if (lineWidth < width) line += " ".repeat(width - lineWidth);
1617
1622
  }
1618
- if (hovered && !active) {
1623
+ if (hovered) {
1619
1624
  line = theme.bg("selectedBg", line);
1620
1625
  }
1621
1626
  lines.push(line);
@@ -1675,6 +1680,17 @@ export class ModelHubComponent implements Component {
1675
1680
  return truncateToWidth(theme.fg("muted", ` ${text}`), width);
1676
1681
  }
1677
1682
 
1683
+ /** Clamp a roles row to `width`; the bg band is reserved for mouse hover. */
1684
+ #finishRolesRow(line: string, width: number, hovered: boolean): string {
1685
+ let out = truncateToWidth(line, width);
1686
+ if (hovered) {
1687
+ const w = visibleWidth(out);
1688
+ if (w < width) out += " ".repeat(width - w);
1689
+ return theme.bg("selectedBg", out);
1690
+ }
1691
+ return out;
1692
+ }
1693
+
1678
1694
  #renderRolesView(width: number, rows: number): string[] {
1679
1695
  const lines: string[] = [];
1680
1696
  lines.push("");
@@ -1691,12 +1707,14 @@ export class ModelHubComponent implements Component {
1691
1707
  }
1692
1708
 
1693
1709
  const cycleOrder = this.#cycleOrder();
1710
+ const listFocused = this.#focus === "list";
1694
1711
  for (let i = 0; i < this.#rolesRows.length && lines.length < rows - 2; i++) {
1695
1712
  const rowDef = this.#rolesRows[i];
1696
1713
  if (!rowDef) continue;
1697
1714
  const selected = i === this.#roleIndex;
1698
1715
  const hovered = i === this.#roleHover;
1699
- const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
1716
+ // The unfocused pane draws no cursor; accent text still marks the row.
1717
+ const cursor = selected && listFocused ? theme.fg("accent", theme.nav.cursor) : " ";
1700
1718
 
1701
1719
  if (rowDef.kind === "separator") {
1702
1720
  lines.push(` ${theme.fg("border", "─".repeat(Math.max(1, width - 6)))}`);
@@ -1706,10 +1724,7 @@ export class ModelHubComponent implements Component {
1706
1724
  if (rowDef.kind === "newRole" || rowDef.kind === "newFallback") {
1707
1725
  const label = rowDef.kind === "newRole" ? "+ New role…" : "+ New fallback…";
1708
1726
  let line = ` ${cursor} ${theme.fg(selected ? "accent" : "dim", label)}`;
1709
- line = truncateToWidth(line, width);
1710
- if (hovered && !selected) {
1711
- line = theme.bg("selectedBg", line);
1712
- }
1727
+ line = this.#finishRolesRow(line, width, hovered);
1713
1728
  lines.push(line);
1714
1729
  continue;
1715
1730
  }
@@ -1720,10 +1735,7 @@ export class ModelHubComponent implements Component {
1720
1735
  const tail = key.slice(slash + 1);
1721
1736
  const keyStyled = theme.fg("dim", key.slice(0, slash + 1)) + (selected ? theme.fg("accent", tail) : tail);
1722
1737
  let line = ` ${cursor} ${theme.fg("dim", theme.status.shadowed)} ${keyStyled}`;
1723
- line = truncateToWidth(line, width);
1724
- if (hovered && !selected) {
1725
- line = theme.bg("selectedBg", line);
1726
- }
1738
+ line = this.#finishRolesRow(line, width, hovered);
1727
1739
  lines.push(line);
1728
1740
  continue;
1729
1741
  }
@@ -1732,10 +1744,7 @@ export class ModelHubComponent implements Component {
1732
1744
  const branch = theme.fg("dim", `${"".padEnd(tagWidth + 3)}↳`);
1733
1745
  const selector = selected ? theme.fg("accent", rowDef.selector) : theme.fg("muted", rowDef.selector);
1734
1746
  let line = ` ${cursor} ${branch} ${selector}`;
1735
- line = truncateToWidth(line, width);
1736
- if (hovered && !selected) {
1737
- line = theme.bg("selectedBg", line);
1738
- }
1747
+ line = this.#finishRolesRow(line, width, hovered);
1739
1748
  lines.push(line);
1740
1749
  continue;
1741
1750
  }
@@ -1778,12 +1787,8 @@ export class ModelHubComponent implements Component {
1778
1787
  const lineWidth = visibleWidth(line);
1779
1788
  if (rightWidth > 0 && lineWidth + rightWidth + 2 <= width) {
1780
1789
  line = `${line}${" ".repeat(width - lineWidth - rightWidth - 1)}${right}`;
1781
- } else {
1782
- line = truncateToWidth(line, width);
1783
- }
1784
- if (hovered && !selected) {
1785
- line = theme.bg("selectedBg", line);
1786
1790
  }
1791
+ line = this.#finishRolesRow(line, width, hovered);
1787
1792
  lines.push(line);
1788
1793
  }
1789
1794
 
@@ -1981,6 +1986,7 @@ export class ModelHubComponent implements Component {
1981
1986
  bodyLines.push(...this.#renderLockedView(entry, bodyWidth, contentRows - 1));
1982
1987
  } else {
1983
1988
  this.#browser.setMaxVisible(contentRows - 1 - 5);
1989
+ this.#browser.setFocused(this.#focus === "list");
1984
1990
  bodyLines.push(...this.#browser.render(bodyWidth));
1985
1991
  }
1986
1992
 
@@ -82,6 +82,8 @@ export interface PlanReviewOverlayCallbacks {
82
82
  onPick: (label: string) => void;
83
83
  /** Invoked on Esc / cancel. */
84
84
  onCancel: () => void;
85
+ /** Invoked with the current full plan text when the copy hotkey is pressed. */
86
+ onCopyPlan?: (content: string) => void | Promise<void>;
85
87
  /** Invoked when the external-editor key is pressed (overlay stays open). */
86
88
  onExternalEditor?: () => void;
87
89
  /** Invoked when the external-editor key edits the active annotation draft. */
@@ -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,5 @@ 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
25
+ Try starting your prompt with a ->, and writing a list (1. Do X, 2. Do Y)
@@ -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;
@@ -120,6 +120,7 @@ import { formatPhaseDisplayName, todoMatchesAnyDescription } from "../tools/todo
120
120
  import { ToolError } from "../tools/tool-errors";
121
121
  import { vocalizer } from "../tts/vocalizer";
122
122
  import { renderTreeList } from "../tui/tree-list";
123
+ import { copyToClipboard } from "../utils/clipboard";
123
124
  import type { EventBus } from "../utils/event-bus";
124
125
  import { getEditorCommand, openInEditor } from "../utils/external-editor";
125
126
  import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
@@ -2517,6 +2518,7 @@ export class InteractiveMode implements InteractiveModeContext {
2517
2518
  {
2518
2519
  onPick: choice => finish(choice),
2519
2520
  onCancel: () => finish(undefined),
2521
+ onCopyPlan: content => void this.#copyPlanToClipboard(content),
2520
2522
  onExternalEditor: dialogOptions?.onExternalEditor,
2521
2523
  onAnnotationExternalEditor: (draft, commit) => void this.#openPlanAnnotationInExternalEditor(draft, commit),
2522
2524
  onPlanEdited: dialogOptions?.onPlanEdited,
@@ -2583,6 +2585,17 @@ export class InteractiveMode implements InteractiveModeContext {
2583
2585
  return contextUsage !== undefined && contextUsage.percent > PLAN_KEEP_CONTEXT_DISABLE_THRESHOLD_PERCENT;
2584
2586
  }
2585
2587
 
2588
+ async #copyPlanToClipboard(content: string): Promise<void> {
2589
+ try {
2590
+ await copyToClipboard(content);
2591
+ this.showStatus("Copied plan to clipboard");
2592
+ } catch (error) {
2593
+ this.showWarning(
2594
+ `Failed to copy plan to clipboard: ${error instanceof Error ? error.message : String(error)}`,
2595
+ );
2596
+ }
2597
+ }
2598
+
2586
2599
  async #openPlanInExternalEditor(planFilePath: string): Promise<void> {
2587
2600
  const editorCmd = getEditorCommand();
2588
2601
  if (!editorCmd) {
@@ -581,11 +581,27 @@ async function callPerplexityAsk(
581
581
  search_recency_filter: params.search_recency_filter ?? null,
582
582
  is_incognito: true,
583
583
  use_schematized_api: true,
584
- skip_search_enabled: true,
584
+ // `true` (the native app's default) lets the backend classifier skip
585
+ // retrieval for queries it deems answerable from memory — the model then
586
+ // runs ungrounded and refuses with "I don't currently have live access".
587
+ // We are a search tool; always retrieve.
588
+ skip_search_enabled: false,
589
+ // Belt and braces with `skip_search_enabled: false`: the web client sets
590
+ // this to force retrieval even when the skip classifier fires.
591
+ always_search_override: true,
592
+ prompt_source: "user",
593
+ source: "default",
594
+ local_search_enabled: false,
595
+ // Declare no tool-approval UI and no local (Comet) browser agent, so the
596
+ // stream never stalls waiting for a confirmation we cannot render.
597
+ should_ask_for_mcp_tool_confirmation: false,
598
+ supports_tool_approval_modal: false,
599
+ force_enable_browser_agent: false,
600
+ is_local_browser_available: false,
601
+ is_local_browser_allowed: false,
585
602
  };
586
603
  if (auth.type === "anonymous") {
587
604
  requestParams.send_back_text_in_streaming_api = true;
588
- requestParams.source = "default";
589
605
  }
590
606
 
591
607
  const response = await (params.fetch ?? fetch)(PERPLEXITY_OAUTH_ASK_URL, {