@oh-my-pi/pi-tui 18.14.2 → 18.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +64 -50
  2. package/README.md +1 -1
  3. package/dist/types/app-keybindings.d.ts +5 -0
  4. package/dist/types/apps/session-picker.d.ts +5 -3
  5. package/dist/types/chat/bash-execution.d.ts +4 -1
  6. package/dist/types/chat/eval-execution.d.ts +2 -0
  7. package/dist/types/chat/execution-shared.d.ts +14 -8
  8. package/dist/types/chat/transcript-render-helpers.d.ts +8 -0
  9. package/dist/types/chrome/dynamic-border.d.ts +6 -0
  10. package/dist/types/chrome/transcript-status.d.ts +2 -2
  11. package/dist/types/components/editor.d.ts +15 -6
  12. package/dist/types/components/loader.d.ts +2 -0
  13. package/dist/types/keybinding-matchers.d.ts +7 -0
  14. package/dist/types/overlays/btw-history-panel.d.ts +13 -0
  15. package/dist/types/overlays/btw-history.d.ts +24 -0
  16. package/dist/types/overlays/btw-inject.d.ts +22 -0
  17. package/dist/types/overlays/btw-panel.d.ts +7 -0
  18. package/dist/types/overlays/hook-editor.d.ts +2 -0
  19. package/dist/types/overlays/jobs-panel.d.ts +4 -0
  20. package/dist/types/overlays/session-selector.d.ts +24 -3
  21. package/dist/types/render/render-utils.d.ts +21 -1
  22. package/dist/types/status-line/startup.d.ts +0 -15
  23. package/dist/types/terminal-capabilities.d.ts +1 -1
  24. package/dist/types/theme/symbols.d.ts +3 -1
  25. package/dist/types/theme/theme-class.d.ts +1 -0
  26. package/dist/types/tools/grouped-file-output.d.ts +76 -8
  27. package/dist/types/tools/hashline-format.d.ts +5 -0
  28. package/dist/types/tools/native-view.d.ts +2 -0
  29. package/dist/types/tools/output-meta.d.ts +27 -0
  30. package/dist/types/tools/read.d.ts +10 -0
  31. package/dist/types/tools/wait.d.ts +2 -0
  32. package/dist/types/utils.d.ts +4 -0
  33. package/package.json +9 -9
  34. package/src/app-keybindings.ts +5 -0
  35. package/src/apps/session-picker.ts +7 -3
  36. package/src/chat/bash-execution.ts +20 -9
  37. package/src/chat/chat-transcript-builder.ts +5 -2
  38. package/src/chat/eval-execution.ts +14 -7
  39. package/src/chat/execution-shared.ts +39 -18
  40. package/src/chat/read-tool-group.ts +56 -36
  41. package/src/chat/transcript-render-helpers.ts +52 -6
  42. package/src/chrome/dynamic-border.ts +28 -2
  43. package/src/chrome/transcript-status.ts +5 -3
  44. package/src/components/editor.ts +123 -24
  45. package/src/components/key-value-list.ts +9 -9
  46. package/src/components/loader.ts +7 -0
  47. package/src/components/section.ts +1 -9
  48. package/src/components/table.ts +1 -9
  49. package/src/keybinding-matchers.ts +13 -0
  50. package/src/overlays/btw-history-panel.ts +98 -14
  51. package/src/overlays/btw-history.ts +65 -0
  52. package/src/overlays/btw-inject.ts +91 -0
  53. package/src/overlays/btw-panel.ts +53 -12
  54. package/src/overlays/hook-editor.ts +6 -0
  55. package/src/overlays/jobs-panel.ts +45 -19
  56. package/src/overlays/model-browser.ts +65 -8
  57. package/src/overlays/session-selector.ts +131 -26
  58. package/src/overlays/usage-dashboard.ts +2 -2
  59. package/src/render/code-cell.ts +1 -1
  60. package/src/render/file-list.ts +4 -7
  61. package/src/render/render-utils.ts +36 -6
  62. package/src/setup/scenes/glyph.ts +1 -1
  63. package/src/status-line/startup.ts +36 -70
  64. package/src/terminal-capabilities.ts +5 -0
  65. package/src/theme/symbols.ts +16 -9
  66. package/src/theme/theme-class.ts +3 -0
  67. package/src/tmux.ts +1 -0
  68. package/src/tools/agent-tree.ts +13 -11
  69. package/src/tools/ask.ts +13 -6
  70. package/src/tools/ast-edit.ts +48 -93
  71. package/src/tools/ast-grep.ts +22 -66
  72. package/src/tools/bash.ts +7 -0
  73. package/src/tools/default-renderer.ts +1 -1
  74. package/src/tools/edit.ts +50 -43
  75. package/src/tools/fetch.ts +2 -1
  76. package/src/tools/find.ts +6 -3
  77. package/src/tools/github.ts +14 -46
  78. package/src/tools/glob.ts +2 -1
  79. package/src/tools/grep.ts +43 -287
  80. package/src/tools/grouped-file-output.ts +250 -64
  81. package/src/tools/hashline-format.ts +5 -0
  82. package/src/tools/json-tree.ts +1 -5
  83. package/src/tools/lsp.ts +53 -25
  84. package/src/tools/native-view.ts +11 -5
  85. package/src/tools/output-meta.ts +68 -4
  86. package/src/tools/proc-render.ts +3 -3
  87. package/src/tools/read.ts +14 -0
  88. package/src/tools/task.ts +34 -77
  89. package/src/tools/vibe.ts +4 -4
  90. package/src/tools/wait.ts +8 -5
  91. package/src/tools/write.ts +17 -27
  92. package/src/tools/yield.ts +1 -1
  93. package/src/utils.ts +10 -0
@@ -71,6 +71,7 @@ import {
71
71
  export type { EditorBorderStyle, EditorTopBorder };
72
72
 
73
73
  import { type SelectItem, SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
74
+ import { editorKey } from "../chrome/keybinding-hints";
74
75
 
75
76
  /** The TSP root an {@link Editor.describeLayout} hook builds around the `editor` node. */
76
77
  export interface NativeEditorLayout {
@@ -866,9 +867,19 @@ export class Editor implements Component, Focusable {
866
867
  #autocompleteList?: SelectList;
867
868
  #autocompleteState: "regular" | "force" | "assist" | null = null;
868
869
  #textAssistReplacement:
869
- | { line: number; startCol: number; endCol: number; original: string; cursorOffset: number }
870
+ | {
871
+ line: number;
872
+ startCol: number;
873
+ endCol: number;
874
+ original: string;
875
+ cursorOffset: number;
876
+ /** The list offers {@link predictions} for the whole line rather than spelling replacements. */
877
+ prediction?: boolean;
878
+ }
870
879
  | undefined;
871
880
  #autocompletePrefix: string = "";
881
+ /** The line a prediction insert (Tab, Right, or the alternatives list) left in the buffer; any later edit clears it. */
882
+ #insertedPrediction: string | undefined;
872
883
  /** False while the popup's untouched top row is a description-only slash hit ({@link AutocompleteItem.descriptionMatch}): Enter then submits the typed text. */
873
884
  #enterAcceptsSelection = true;
874
885
  #autocompleteRequestId: number = 0;
@@ -945,12 +956,21 @@ export class Editor implements Component, Focusable {
945
956
  * cursor. The host styles it (ANSI allowed). Re-evaluated on every render, so hosts can derive
946
957
  * it from live state. */
947
958
  placeholder?: () => string | undefined;
948
- /** Whole-message prediction for the next prompt, painted as ghost text after the cursor while
949
- * the single-line buffer is a strict prefix of it, the cursor sits at its end, and no
950
- * autocomplete is open. A shown prediction replaces the {@link placeholder}; Tab or Right inserts
951
- * the rest without submitting. Must be a single line. Re-evaluated on every render, so hosts can
952
- * derive it from live state. */
953
- prediction?: () => string | undefined;
959
+ /** Whole-message predictions for the next prompt, most likely first. The first one the single-line
960
+ * buffer is a strict prefix of is painted as ghost text after the cursor while the cursor sits at
961
+ * its end and no autocomplete is open. A shown prediction replaces the {@link placeholder}; Tab or
962
+ * Right inserts the rest without submitting. A buffer the user typed keeps the ghost even when it
963
+ * equals a shorter prediction; once Tab, Right, or the list has inserted one of two or more
964
+ * predictions, no ghost extends it to a longer one, and Tab instead opens a list of all of them
965
+ * that swaps the whole line, also without submitting. Each must be a single line. Re-evaluated on
966
+ * every render, so hosts can derive them from live state. */
967
+ predictions?: () => readonly string[] | undefined;
968
+ /** Called with the prediction's index in {@link predictions} after Tab or Right inserts the rest of
969
+ * a shown prediction, or the alternatives list swaps a different one into the buffer. */
970
+ onPredictionAccepted?: (index: number) => void;
971
+ /** Called for each frame that paints a prediction as ghost text: a terminal frame that draws at
972
+ * least one cell of it, or a native frame that carries it as the editor's ghost. */
973
+ onPredictionShown?: () => void;
954
974
 
955
975
  // Custom top border (for status line integration). Either an eager `content`
956
976
  // (set once, reused every frame) or a `provider` that recomputes lazily just
@@ -1599,6 +1619,10 @@ export class Editor implements Component, Focusable {
1599
1619
  // against the cursor, or the placeholder flush right.
1600
1620
  const placeholder = this.#getPlaceholder();
1601
1621
  const inlineHint = placeholder ? null : this.#getInlineHint();
1622
+ // Whether the hint is the prediction, and how many of its cells this frame draws: a full row,
1623
+ // an unfocused hardware-cursor row, or the IME-safe layout can leave it unpainted.
1624
+ const hintIsPrediction = inlineHint !== null && inlineHint === this.#getPredictionSuffix();
1625
+ let hintCellsPainted = 0;
1602
1626
  const hintStyle = this.#theme.hintStyle ?? ((t: string) => `\x1b[2m${t}\x1b[0m`);
1603
1627
  const hintTail = (avail: number): { text: string; width: number } | undefined => {
1604
1628
  if (placeholder) {
@@ -1713,6 +1737,7 @@ export class Editor implements Component, Focusable {
1713
1737
  } else if (tail) {
1714
1738
  displayText = before + marker + tail.text;
1715
1739
  displayWidth += tail.width;
1740
+ hintCellsPainted += tail.width;
1716
1741
  } else if (after.length === 0 && !isSideBordered && displayWidth >= lineContentWidth) {
1717
1742
  displayText = this.#renderTerminalCursorMarker(before, marker, lineContentWidth);
1718
1743
  } else {
@@ -1759,6 +1784,7 @@ export class Editor implements Component, Focusable {
1759
1784
  const tail = hintTail(Math.max(0, lineContentWidth - displayWidth - overrideWidth));
1760
1785
  displayText = before + marker + this.cursorOverride + (tail?.text ?? "");
1761
1786
  displayWidth += overrideWidth + (tail?.width ?? 0);
1787
+ hintCellsPainted += tail?.width ?? 0;
1762
1788
  }
1763
1789
  } else {
1764
1790
  // Cursor is at the end - add thin cursor glyph
@@ -1773,6 +1799,7 @@ export class Editor implements Component, Focusable {
1773
1799
  const tail = hintTail(Math.max(0, lineContentWidth - displayWidth - cursorWidth));
1774
1800
  displayText = before + marker + cursor + (tail?.text ?? "");
1775
1801
  displayWidth += cursorWidth + (tail?.width ?? 0);
1802
+ hintCellsPainted += tail?.width ?? 0;
1776
1803
  }
1777
1804
  if (displayWidth > lineContentWidth && paddingX > 0) {
1778
1805
  cursorPaddingOverflow = displayWidth - lineContentWidth;
@@ -1815,6 +1842,7 @@ export class Editor implements Component, Focusable {
1815
1842
  }),
1816
1843
  );
1817
1844
  }
1845
+ if (hintIsPrediction && hintCellsPainted > 0) this.onPredictionShown?.();
1818
1846
 
1819
1847
  const bottomRow = style.renderBottom(chromeCtx);
1820
1848
  if (bottomRow !== undefined) result.push(bottomRow);
@@ -1879,6 +1907,7 @@ export class Editor implements Component, Focusable {
1879
1907
  : placeholder && plainText(placeholder).trim();
1880
1908
  const atLineEnd = cursorCol >= (lines[cursorLine]?.length ?? 0);
1881
1909
  const ghost = nativePlaceholder || !atLineEnd ? null : this.#getInlineHint();
1910
+ if (ghost && ghost === this.#getPredictionSuffix()) this.onPredictionShown?.();
1882
1911
  const vim = this.#vim;
1883
1912
  let anchor: number | null = null;
1884
1913
  if (vim?.visual && vim.anchor !== null) {
@@ -2130,7 +2159,7 @@ export class Editor implements Component, Focusable {
2130
2159
  kb.matchesCanonical(canonical, "tui.input.tab") ||
2131
2160
  rightArrowAccepts)
2132
2161
  ) {
2133
- this.#applySpellingSuggestion();
2162
+ this.#applyAssistReplacement();
2134
2163
  return;
2135
2164
  }
2136
2165
  // Let the autocomplete list handle navigation and selection
@@ -3260,6 +3289,7 @@ export class Editor implements Component, Focusable {
3260
3289
 
3261
3290
  #notifyChange(text?: string): void {
3262
3291
  this.#textRevision++;
3292
+ this.#insertedPrediction = undefined;
3263
3293
  this.onChange?.(text ?? this.getText());
3264
3294
  }
3265
3295
 
@@ -5077,7 +5107,7 @@ export class Editor implements Component, Focusable {
5077
5107
  // A pointer pick on the popup (TSP `select`/`activate`) accepts it like Tab.
5078
5108
  list.onSelect = item => {
5079
5109
  if (this.#autocompleteList !== list) return;
5080
- if (this.#autocompleteState === "assist") this.#applySpellingSuggestion();
5110
+ if (this.#autocompleteState === "assist") this.#applyAssistReplacement();
5081
5111
  else this.#acceptAutocompleteSelection(item);
5082
5112
  };
5083
5113
  return list;
@@ -5085,6 +5115,7 @@ export class Editor implements Component, Focusable {
5085
5115
 
5086
5116
  async #handleTabCompletion(): Promise<void> {
5087
5117
  if (this.#acceptPrediction()) return;
5118
+ if (this.#showPredictionAlternatives()) return;
5088
5119
  if (this.#acceptWordCompletion({ space: true })) return;
5089
5120
  if (!this.#autocompleteProvider) return;
5090
5121
 
@@ -5122,11 +5153,37 @@ export class Editor implements Component, Focusable {
5122
5153
  if (space) this.#lastAction = "accept-word";
5123
5154
  return true;
5124
5155
  }
5125
- /** Insert the rest of the shown {@link prediction}, if any. Never submits. */
5156
+ /** Insert the rest of the shown prediction, if any. Never submits. */
5126
5157
  #acceptPrediction(): boolean {
5127
- const suffix = this.#getPredictionSuffix();
5128
- if (!suffix) return false;
5129
- this.#insertTextAtCursor(suffix);
5158
+ const shown = this.#getShownPrediction();
5159
+ if (!shown) return false;
5160
+ this.#insertTextAtCursor(shown.suffix);
5161
+ this.#insertedPrediction = this.#state.lines[0];
5162
+ this.onPredictionAccepted?.(shown.index);
5163
+ return true;
5164
+ }
5165
+
5166
+ /** Open the list of every prediction over a buffer that equals one of them, that one selected. */
5167
+ #showPredictionAlternatives(): boolean {
5168
+ const alternatives = this.#getPredictionAlternatives();
5169
+ if (!alternatives) return false;
5170
+ const original = this.#state.lines[0] ?? "";
5171
+ this.#autocompletePrefix = original;
5172
+ this.#autocompleteList = this.#createAutocompleteList(
5173
+ "",
5174
+ alternatives.choices.map(value => ({ value, label: value })),
5175
+ );
5176
+ this.#autocompleteList.setSelectedIndex(alternatives.index);
5177
+ this.#autocompleteState = "assist";
5178
+ this.#textAssistReplacement = {
5179
+ line: 0,
5180
+ startCol: 0,
5181
+ endCol: original.length,
5182
+ original,
5183
+ cursorOffset: 0,
5184
+ prediction: true,
5185
+ };
5186
+ this.onAutocompleteUpdate?.();
5130
5187
  return true;
5131
5188
  }
5132
5189
 
@@ -5169,7 +5226,7 @@ export class Editor implements Component, Focusable {
5169
5226
  this.onAutocompleteUpdate?.();
5170
5227
  }
5171
5228
 
5172
- #applySpellingSuggestion(): void {
5229
+ #applyAssistReplacement(): void {
5173
5230
  const replacement = this.#textAssistReplacement;
5174
5231
  const selected = this.#autocompleteList?.getSelectedItem();
5175
5232
  if (!replacement || !selected) {
@@ -5177,8 +5234,13 @@ export class Editor implements Component, Focusable {
5177
5234
  return;
5178
5235
  }
5179
5236
  const line = this.#state.lines[replacement.line] ?? "";
5180
- if (line.slice(replacement.startCol, replacement.endCol) !== replacement.original) {
5237
+ // A mismatch means the list went stale; picking the prediction already in the buffer changes nothing.
5238
+ if (
5239
+ line.slice(replacement.startCol, replacement.endCol) !== replacement.original ||
5240
+ (replacement.prediction && selected.value === replacement.original)
5241
+ ) {
5181
5242
  this.#cancelAutocomplete();
5243
+ this.onAutocompleteUpdate?.();
5182
5244
  return;
5183
5245
  }
5184
5246
  this.#recordUndoState();
@@ -5190,6 +5252,11 @@ export class Editor implements Component, Focusable {
5190
5252
  this.#cancelAutocomplete();
5191
5253
  this.onAutocompleteUpdate?.();
5192
5254
  this.#notifyChange();
5255
+ if (replacement.prediction) {
5256
+ this.#insertedPrediction = selected.value;
5257
+ const index = this.predictions?.()?.indexOf(selected.value) ?? -1;
5258
+ if (index >= 0) this.onPredictionAccepted?.(index);
5259
+ }
5193
5260
  }
5194
5261
  async #handleSlashCommandCompletion(): Promise<void> {
5195
5262
  await this.#tryTriggerAutocomplete();
@@ -5395,7 +5462,8 @@ export class Editor implements Component, Focusable {
5395
5462
 
5396
5463
  /**
5397
5464
  * Get inline hint text to show as dim ghost text after the cursor.
5398
- * Checks the selected autocomplete item's hint first, then the prediction, then the provider.
5465
+ * Checks the selected autocomplete item's hint first, then the prediction and its alternatives,
5466
+ * then the provider.
5399
5467
  */
5400
5468
  #getInlineHint(): string | null {
5401
5469
  // Check selected autocomplete item for a hint
@@ -5406,7 +5474,12 @@ export class Editor implements Component, Focusable {
5406
5474
 
5407
5475
  const prediction = this.#getPredictionSuffix();
5408
5476
  if (prediction) return prediction;
5409
-
5477
+ const alternatives = this.#getPredictionAlternatives();
5478
+ if (alternatives) {
5479
+ // Tab opens the alternatives list from here; without a bound key there is nothing to hint.
5480
+ const tab = editorKey("tui.input.tab");
5481
+ if (tab) return ` ${tab} ${alternatives.choices.length - 1} more`;
5482
+ }
5410
5483
  // Fall back to provider's getInlineHint
5411
5484
  if (this.#autocompleteProvider?.getInlineHint) {
5412
5485
  const hint = this.#autocompleteProvider.getInlineHint(
@@ -5423,18 +5496,44 @@ export class Editor implements Component, Focusable {
5423
5496
  if (this.#autocompleteState || !this.#isEditorEmpty() || this.#getPredictionSuffix()) return undefined;
5424
5497
  return this.placeholder?.() || undefined;
5425
5498
  }
5426
- /** The part of {@link prediction} not yet typed, or `null` when no prediction is shown. */
5499
+ /** The part of the shown prediction not yet typed, or `null` when no prediction is shown. */
5427
5500
  #getPredictionSuffix(): string | null {
5501
+ return this.#getShownPrediction()?.suffix ?? null;
5502
+ }
5503
+ /**
5504
+ * The prediction painted as ghost text: the first one the single-line buffer is a strict prefix of,
5505
+ * with the cursor at its end and no autocomplete open. None while the buffer is a prediction insert
5506
+ * that equals one of two or more predictions, so Tab opens their list rather than extending to a
5507
+ * longer one; a buffer the user typed keeps its ghost.
5508
+ */
5509
+ #getShownPrediction(): { index: number; suffix: string } | null {
5428
5510
  if (this.#autocompleteState) return null;
5429
- const prediction = this.prediction?.();
5430
- if (!prediction) return null;
5511
+ const predictions = this.predictions?.();
5512
+ if (!predictions?.length) return null;
5431
5513
  const { lines, cursorCol } = this.#state;
5432
5514
  if (lines.length !== 1) return null;
5433
5515
  const typed = lines[0] ?? "";
5434
- if (cursorCol !== typed.length || typed.length >= prediction.length || !prediction.startsWith(typed)) {
5435
- return null;
5436
- }
5437
- return prediction.slice(typed.length);
5516
+ if (cursorCol !== typed.length) return null;
5517
+ if (predictions.length > 1 && typed === this.#insertedPrediction && predictions.includes(typed)) return null;
5518
+ const index = predictions.findIndex(
5519
+ prediction => typed.length < prediction.length && prediction.startsWith(typed),
5520
+ );
5521
+ return index < 0 ? null : { index, suffix: predictions[index]!.slice(typed.length) };
5522
+ }
5523
+ /**
5524
+ * Two or more predictions, and which one the single-line buffer equals with the cursor at its end:
5525
+ * Tab then opens the list of all of them.
5526
+ */
5527
+ #getPredictionAlternatives(): { choices: readonly string[]; index: number } | null {
5528
+ if (this.#autocompleteState) return null;
5529
+ const choices = this.predictions?.();
5530
+ if (!choices || choices.length < 2) return null;
5531
+ const { lines, cursorCol } = this.#state;
5532
+ if (lines.length !== 1) return null;
5533
+ const typed = lines[0] ?? "";
5534
+ if (cursorCol !== typed.length) return null;
5535
+ const index = choices.indexOf(typed);
5536
+ return index < 0 ? null : { choices, index };
5438
5537
  }
5439
5538
  /** Typing `typed` where it diverges from the shown ghost completion rejects that ghost. */
5440
5539
  #reportTypedPastWordCompletion(typed: string): void {
@@ -2,7 +2,15 @@ import { compactText, styleSpans } from "../native/spans";
2
2
  import { node } from "../native/describe";
3
3
  import type { DescribeContext, NativeNode } from "../native/node";
4
4
  import type { Component } from "../tui";
5
- import { Ellipsis, getWidthConfigEpoch, replaceTabs, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
5
+ import {
6
+ Ellipsis,
7
+ finiteWidth,
8
+ getWidthConfigEpoch,
9
+ singleLine,
10
+ truncateToWidth,
11
+ visibleWidth,
12
+ wrapTextWithAnsi,
13
+ } from "../utils";
6
14
 
7
15
  /** One label/value entry in a {@link KeyValueList}. */
8
16
  export interface KeyValueRow {
@@ -21,14 +29,6 @@ export interface KeyValueListOptions {
21
29
  readonly labelOverflow?: "allow" | "truncate";
22
30
  }
23
31
 
24
- function finiteWidth(width: number): number {
25
- return Number.isFinite(width) ? Math.max(0, Math.trunc(width)) : 0;
26
- }
27
-
28
- function singleLine(text: string): string {
29
- return replaceTabs(text).replace(/[\r\n]+/g, " ");
30
- }
31
-
32
32
  /** A bounded ANSI-aware label/value list with replaceable rows and aligned wrapped continuations. */
33
33
  export class KeyValueList implements Component {
34
34
  #rows: readonly KeyValueRow[];
@@ -325,6 +325,13 @@ export class Loader extends Text {
325
325
  dispose() {
326
326
  this.stop();
327
327
  }
328
+
329
+ /** The current spinner frame and message as one styled line, for hosts that set it into their own chrome. */
330
+ inlineText(): string {
331
+ const spinner = this.spinnerColorFn(this.#frames[this.#currentFrame]);
332
+ return `${spinner} ${this.messageColorFn(this.#resolveMessage())}`;
333
+ }
334
+
328
335
  /** Install a lazy right-docked suffix for the spinner row (e.g. a styled
329
336
  * session title). Re-evaluated every paint; dropped when the row leaves
330
337
  * less than a two-cell gap. */
@@ -2,20 +2,12 @@ import { compactText, rowsText, styledSpans } from "../native/spans";
2
2
  import { node } from "../native/describe";
3
3
  import type { DescribeContext, NativeNode } from "../native/node";
4
4
  import type { Component } from "../tui";
5
- import { Ellipsis, getWidthConfigEpoch, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
5
+ import { Ellipsis, finiteWidth, getWidthConfigEpoch, singleLine, truncateToWidth, visibleWidth } from "../utils";
6
6
 
7
7
  function isComponent(body: Component | readonly string[]): body is Component {
8
8
  return !Array.isArray(body);
9
9
  }
10
10
 
11
- function finiteWidth(width: number): number {
12
- return Number.isFinite(width) ? Math.max(0, Math.trunc(width)) : 0;
13
- }
14
-
15
- function singleLine(text: string): string {
16
- return replaceTabs(text).replace(/[\r\n]+/g, " ");
17
- }
18
-
19
11
  function fillCells(glyph: string, width: number): string {
20
12
  const cleanGlyph = singleLine(glyph);
21
13
  const glyphWidth = visibleWidth(cleanGlyph);
@@ -3,7 +3,7 @@ import { compactText, styleSpans } from "../native/spans";
3
3
  import { node } from "../native/describe";
4
4
  import type { DescribeContext, NativeNode } from "../native/node";
5
5
  import type { Component } from "../tui";
6
- import { Ellipsis, getWidthConfigEpoch, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
6
+ import { Ellipsis, finiteWidth, getWidthConfigEpoch, singleLine, truncateToWidth, visibleWidth } from "../utils";
7
7
 
8
8
  /** One table cell. Styling is applied after alignment so padding shares the cell style. */
9
9
  export interface TableCell {
@@ -29,14 +29,6 @@ export interface TableOptions {
29
29
  readonly fit?: boolean;
30
30
  }
31
31
 
32
- function finiteWidth(width: number, fallback = 0): number {
33
- return Number.isFinite(width) ? Math.max(0, Math.trunc(width)) : fallback;
34
- }
35
-
36
- function singleLine(text: string): string {
37
- return replaceTabs(text).replace(/[\r\n]+/g, " ");
38
- }
39
-
40
32
  function fitWidths(columns: readonly TableColumn[], maxWidth: number, gapWidth: number): number[] {
41
33
  const widths = columns.map(column => finiteWidth(column.width));
42
34
  let overflow = widths.reduce((sum, width) => sum + width, 0) + Math.max(0, columns.length - 1) * gapWidth - maxWidth;
@@ -120,3 +120,16 @@ export function appApproveKeys(): readonly KeyId[] {
120
120
  export function matchesAppApprove(data: string): boolean {
121
121
  return matchesEffectiveKeys(data, appApproveKeys());
122
122
  }
123
+
124
+ /**
125
+ * Effective /btw inject key (Alt+I by default). No fallback: it sends text into the main
126
+ * session, so a user who unbinds it gets no key at all.
127
+ */
128
+ export function appBtwInjectKey(): KeyId | undefined {
129
+ return getKeybindings().getKeys("app.btw.inject")[0];
130
+ }
131
+
132
+ /** Match the key that sends a /btw answer's steering prompt to the main session. */
133
+ export function matchesAppBtwInject(data: string): boolean {
134
+ return getKeybindings().matches(data, "app.btw.inject");
135
+ }
@@ -18,7 +18,10 @@ import {
18
18
  getBtwLatestTurn,
19
19
  getBtwTopicLabel,
20
20
  getBtwTurns,
21
+ type BtwInjectParts,
22
+ splitBtwInject,
21
23
  } from "./btw-history";
24
+ import { BtwInjectAnswer, type BtwInjectState, describeBtwInjectAnswer } from "./btw-inject";
22
25
  import { compactImageMarkers, formatVisionMarker } from "../prompt/composer-attachments";
23
26
  import { extractImagePastePathsFromText } from "../prompt/custom-editor";
24
27
  import { BracketedPasteHandler } from "../bracketed-paste";
@@ -29,6 +32,8 @@ import {
29
32
  matchesSelectPageDown,
30
33
  matchesSelectPageUp,
31
34
  matchesSelectUp,
35
+ appBtwInjectKey,
36
+ matchesAppBtwInject,
32
37
  } from "../keybinding-matchers";
33
38
  import type { SpaceHoldHandler } from "../space-hold";
34
39
  import { sanitizeErrorLine } from "../chrome/error-block";
@@ -77,6 +82,25 @@ interface BtwHistoryPanelOptions {
77
82
  * first. For a host with no inline panel to fall back to (Tern).
78
83
  */
79
84
  escapeHides?: boolean;
85
+ /** `btw.inject` hooks, given only while it is on for this view. */
86
+ inject?: BtwHistoryInject;
87
+ }
88
+
89
+ interface BtwHistoryInject {
90
+ /**
91
+ * How a finished turn's closed `<inject>` block shows: apart from its prose while pending or
92
+ * sent, plain text when `undefined`. A streaming turn always shows its block.
93
+ */
94
+ state: (record: BtwHistoryRecord, turnIndex: number) => BtwInjectState | undefined;
95
+ /** Whether the inject key may send the record's latest block now. */
96
+ canSend: (record: BtwHistoryRecord) => boolean;
97
+ send: (record: BtwHistoryRecord) => void;
98
+ }
99
+
100
+ /** A turn's `<inject>` block as displayed apart from its prose. */
101
+ interface InjectBlock {
102
+ parts: BtwInjectParts;
103
+ state: BtwInjectState;
80
104
  }
81
105
 
82
106
  interface FollowUpComposer {
@@ -95,6 +119,8 @@ interface RenderedTurn {
95
119
  turn: BtwHistoryTurn;
96
120
  question: Markdown;
97
121
  answer: Markdown;
122
+ /** How the answer's `<inject>` block showed; `undefined` when it rendered as plain text. */
123
+ block: BtwInjectState | undefined;
98
124
  lines: readonly string[];
99
125
  width: number;
100
126
  }
@@ -110,6 +136,8 @@ interface BtwHistoryNativeMemo {
110
136
  followUpPending: boolean;
111
137
  canFollowUp: boolean;
112
138
  copied: boolean;
139
+ canInject: boolean;
140
+ latestInject: BtwInjectState | undefined;
113
141
  scroll: NativeScroll | undefined;
114
142
  node: NativeNode;
115
143
  }
@@ -512,6 +540,10 @@ export class BtwHistoryPanel implements Component, Focusable {
512
540
  if (record && getBtwCopyText(record) !== undefined) this.#options.onCopy(record);
513
541
  return;
514
542
  }
543
+ if (this.#options.inject && matchesAppBtwInject(data)) {
544
+ this.#sendInject(record);
545
+ return;
546
+ }
515
547
  // Legacy terminals deliver Ctrl+/ (and Ctrl+_) as US without Kitty protocol.
516
548
  if (
517
549
  matchesKey(data, "tab") ||
@@ -576,6 +608,8 @@ export class BtwHistoryPanel implements Component, Focusable {
576
608
  const composer = this.#composer;
577
609
  const canFollowUp = record !== undefined && this.#canFollowUp(record);
578
610
  const copied = record !== undefined && this.#isCopied(record);
611
+ const canInject = record !== undefined && this.#options.inject?.canSend(record) === true;
612
+ const latestInject = record && this.#options.inject?.state(record, getBtwTurns(record).length - 1);
579
613
  const memo = this.#native;
580
614
  if (
581
615
  memo?.records === this.#records &&
@@ -587,6 +621,8 @@ export class BtwHistoryPanel implements Component, Focusable {
587
621
  memo.followUpPending === this.#followUpPending &&
588
622
  memo.canFollowUp === canFollowUp &&
589
623
  memo.copied === copied &&
624
+ memo.canInject === canInject &&
625
+ memo.latestInject === latestInject &&
590
626
  memo.scroll === this.#scroll
591
627
  ) {
592
628
  return memo.node;
@@ -612,7 +648,7 @@ export class BtwHistoryPanel implements Component, Focusable {
612
648
  } else {
613
649
  children.push(this.#describeEmpty());
614
650
  }
615
- children.push(this.#describeActions(record, canFollowUp, copied));
651
+ children.push(this.#describeActions(record, canFollowUp, copied, canInject));
616
652
  // The sheet is the frame (`nativeOverlay`); its last row is the pinned footer.
617
653
  const described = col(children, { gap: "none" });
618
654
  this.#native = {
@@ -625,6 +661,8 @@ export class BtwHistoryPanel implements Component, Focusable {
625
661
  followUpPending: this.#followUpPending,
626
662
  canFollowUp,
627
663
  copied,
664
+ canInject,
665
+ latestInject,
628
666
  scroll: this.#scroll,
629
667
  node: described,
630
668
  };
@@ -641,6 +679,9 @@ export class BtwHistoryPanel implements Component, Focusable {
641
679
  case "copy-answer":
642
680
  if (record && getBtwCopyText(record) !== undefined) this.#options.onCopy(record);
643
681
  break;
682
+ case "inject":
683
+ this.#sendInject(record);
684
+ break;
644
685
  case "follow-up":
645
686
  if (record) this.openFollowUp(record.id);
646
687
  break;
@@ -698,12 +739,31 @@ export class BtwHistoryPanel implements Component, Focusable {
698
739
  /** The selected side question as a short document: the asked question titles each answer. */
699
740
  #describeThread(record: BtwHistoryRecord): NativeNode {
700
741
  const turns = getDisplayTurns(record);
701
- const children: NativeChild[] = turns.map((turn, index) => this.#describeTurn(turn, index, `turn-${index}`));
742
+ const children: NativeChild[] = turns.map((turn, index) =>
743
+ this.#describeTurn(turn, index, `turn-${index}`, this.#injectBlock(record, turn, index)),
744
+ );
702
745
  if (this.#composer) children.push(this.#describeComposer(this.#composer));
703
746
  return node("col", { role: "omp.btw.thread" }, children, "thread");
704
747
  }
705
748
 
706
- #describeTurn(turn: BtwHistoryTurn, index: number, key: string): NativeNode {
749
+ #sendInject(record: BtwHistoryRecord | undefined): void {
750
+ const inject = this.#options.inject;
751
+ if (!record || !inject?.canSend(record)) return;
752
+ inject.send(record);
753
+ this.#detailDirty = true;
754
+ this.#options.requestRender();
755
+ }
756
+
757
+ /** The turn's `<inject>` block to show apart from its prose, if any (see {@link BtwHistoryInject.state}). */
758
+ #injectBlock(record: BtwHistoryRecord, turn: BtwHistoryTurn, index: number): InjectBlock | undefined {
759
+ const inject = this.#options.inject;
760
+ const parts = inject ? splitBtwInject(turn.answer) : undefined;
761
+ if (!inject || !parts) return undefined;
762
+ const state = turn.status === "running" ? "pending" : parts.closed ? inject.state(record, index) : undefined;
763
+ return state && { parts, state };
764
+ }
765
+
766
+ #describeTurn(turn: BtwHistoryTurn, index: number, key: string, block: InjectBlock | undefined): NativeNode {
707
767
  const meta: TspSpan[] =
708
768
  index === 0
709
769
  ? [span(this.#dateFormat.format(turn.createdAt))]
@@ -717,11 +777,12 @@ export class BtwHistoryPanel implements Component, Focusable {
717
777
  ];
718
778
  // A failed turn with no text says only why it failed.
719
779
  if (turn.answer.trim() || !turn.error) {
720
- const answer = turn.answer.trim()
721
- ? md(sanitizeDisplayText(turn.answer), { stream: turn.status === "running" })
722
- : text([span(turn.status === "running" ? "Thinking…" : "No answer text.", "dim")], {
723
- role: "omp.btw.pending",
724
- });
780
+ const stream = turn.status === "running";
781
+ const answer = block
782
+ ? describeBtwInjectAnswer(block.parts, block.state === "sent", stream)
783
+ : turn.answer.trim()
784
+ ? md(sanitizeDisplayText(turn.answer), { stream })
785
+ : text([span(stream ? "Thinking…" : "No answer text.", "dim")], { role: "omp.btw.pending" });
725
786
  children.push(node("col", { role: "omp.btw.answer" }, [answer], "answer"));
726
787
  }
727
788
  if (turn.error) {
@@ -772,7 +833,12 @@ export class BtwHistoryPanel implements Component, Focusable {
772
833
  * primary action (Follow up, or Send while composing) in the corner. Keys
773
834
  * live in the buttons' tooltips.
774
835
  */
775
- #describeActions(record: BtwHistoryRecord | undefined, canFollowUp: boolean, copied: boolean): NativeNode {
836
+ #describeActions(
837
+ record: BtwHistoryRecord | undefined,
838
+ canFollowUp: boolean,
839
+ copied: boolean,
840
+ canInject: boolean,
841
+ ): NativeNode {
776
842
  if (this.#composer) {
777
843
  return actionBar([
778
844
  null,
@@ -784,6 +850,7 @@ export class BtwHistoryPanel implements Component, Focusable {
784
850
  const hides = this.#options.escapeHides === true;
785
851
  const buttons: (NativeNode | null)[] = [];
786
852
  if (running) buttons.push(actionButton("Stop answer", "stop", { keys: hides ? "x" : "escape" }));
853
+ if (canInject) buttons.push(actionButton("Inject into main", "inject", { keys: appBtwInjectKey() }));
787
854
  if (record && getBtwCopyText(record) !== undefined) {
788
855
  buttons.push(actionButton(copied ? "Copy again" : "Copy answer", "copy-answer", { keys: "c" }));
789
856
  if (copied) buttons.push(text([span("✓ Copied", "success")], { role: "omp.btw.copied" }));
@@ -847,7 +914,12 @@ export class BtwHistoryPanel implements Component, Focusable {
847
914
  });
848
915
  }
849
916
 
850
- #renderTurn(turn: BtwHistoryTurn, cached: RenderedTurn | undefined, width: number): RenderedTurn {
917
+ #renderTurn(
918
+ turn: BtwHistoryTurn,
919
+ cached: RenderedTurn | undefined,
920
+ width: number,
921
+ block: InjectBlock | undefined,
922
+ ): RenderedTurn {
851
923
  const previous = cached?.turn;
852
924
  if (
853
925
  cached &&
@@ -856,14 +928,16 @@ export class BtwHistoryPanel implements Component, Focusable {
856
928
  turn.answer === previous?.answer &&
857
929
  turn.status === previous?.status &&
858
930
  turn.error === previous?.error &&
859
- turn.createdAt === previous?.createdAt
931
+ turn.createdAt === previous?.createdAt &&
932
+ block?.state === cached.block
860
933
  ) {
861
934
  return cached;
862
935
  }
863
936
  const question = cached?.question ?? new Markdown("", 0, 0, getMarkdownTheme());
864
937
  const answer = cached?.answer ?? new Markdown("", 0, 0, getMarkdownTheme());
865
938
  if (turn.question !== previous?.question) question.setText(sanitizeDisplayText(turn.question));
866
- if (turn.answer !== previous?.answer) answer.setText(sanitizeDisplayText(turn.answer));
939
+ // A block's state can change while the answer stays the same, so the plain text is reset on every miss.
940
+ if (!block) answer.setText(sanitizeDisplayText(turn.answer));
867
941
  const status = STATUS[turn.status];
868
942
  const lines = [
869
943
  ...wrapTextWithAnsi(
@@ -876,7 +950,8 @@ export class BtwHistoryPanel implements Component, Focusable {
876
950
  "",
877
951
  theme.bold(theme.fg("accent", "Answer")),
878
952
  ];
879
- if (turn.answer.trim()) lines.push(...answer.render(width));
953
+ if (block) lines.push(...new BtwInjectAnswer(block.parts, block.state === "sent").render(width));
954
+ else if (turn.answer.trim()) lines.push(...answer.render(width));
880
955
  else
881
956
  lines.push(
882
957
  ...wrapTextWithAnsi(
@@ -898,6 +973,7 @@ export class BtwHistoryPanel implements Component, Focusable {
898
973
  },
899
974
  question,
900
975
  answer,
976
+ block: block?.state,
901
977
  lines,
902
978
  width,
903
979
  };
@@ -915,7 +991,12 @@ export class BtwHistoryPanel implements Component, Focusable {
915
991
  if (record) {
916
992
  const turns = getDisplayTurns(record);
917
993
  for (let index = 0; index < turns.length; index++) {
918
- const cached = this.#renderTurn(turns[index]!, this.#turns[index], inner);
994
+ const cached = this.#renderTurn(
995
+ turns[index]!,
996
+ this.#turns[index],
997
+ inner,
998
+ this.#injectBlock(record, turns[index]!, index),
999
+ );
919
1000
  this.#turns[index] = cached;
920
1001
  if (index > 0) lines.push("", theme.fg("dim", theme.boxRound.horizontal.repeat(inner)), "");
921
1002
  lines.push(...cached.lines);
@@ -975,6 +1056,9 @@ export class BtwHistoryPanel implements Component, Focusable {
975
1056
  ];
976
1057
  if (!composer) {
977
1058
  if (record && this.#canFollowUp(record)) actions.push(rawKeyHint(["f", "enter"], "follow up"));
1059
+ const injectKey = appBtwInjectKey();
1060
+ if (injectKey && record && this.#options.inject?.canSend(record))
1061
+ actions.push(rawKeyHint(injectKey, "inject"));
978
1062
  if (record && getBtwCopyText(record) !== undefined) {
979
1063
  if (this.#isCopied(record))
980
1064
  actions.push(theme.fg("success", `✓ copied · ${formatKeyHint("c")} to copy again`));