@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.
- package/CHANGELOG.md +64 -50
- package/README.md +1 -1
- package/dist/types/app-keybindings.d.ts +5 -0
- package/dist/types/apps/session-picker.d.ts +5 -3
- package/dist/types/chat/bash-execution.d.ts +4 -1
- package/dist/types/chat/eval-execution.d.ts +2 -0
- package/dist/types/chat/execution-shared.d.ts +14 -8
- package/dist/types/chat/transcript-render-helpers.d.ts +8 -0
- package/dist/types/chrome/dynamic-border.d.ts +6 -0
- package/dist/types/chrome/transcript-status.d.ts +2 -2
- package/dist/types/components/editor.d.ts +15 -6
- package/dist/types/components/loader.d.ts +2 -0
- package/dist/types/keybinding-matchers.d.ts +7 -0
- package/dist/types/overlays/btw-history-panel.d.ts +13 -0
- package/dist/types/overlays/btw-history.d.ts +24 -0
- package/dist/types/overlays/btw-inject.d.ts +22 -0
- package/dist/types/overlays/btw-panel.d.ts +7 -0
- package/dist/types/overlays/hook-editor.d.ts +2 -0
- package/dist/types/overlays/jobs-panel.d.ts +4 -0
- package/dist/types/overlays/session-selector.d.ts +24 -3
- package/dist/types/render/render-utils.d.ts +21 -1
- package/dist/types/status-line/startup.d.ts +0 -15
- package/dist/types/terminal-capabilities.d.ts +1 -1
- package/dist/types/theme/symbols.d.ts +3 -1
- package/dist/types/theme/theme-class.d.ts +1 -0
- package/dist/types/tools/grouped-file-output.d.ts +76 -8
- package/dist/types/tools/hashline-format.d.ts +5 -0
- package/dist/types/tools/native-view.d.ts +2 -0
- package/dist/types/tools/output-meta.d.ts +27 -0
- package/dist/types/tools/read.d.ts +10 -0
- package/dist/types/tools/wait.d.ts +2 -0
- package/dist/types/utils.d.ts +4 -0
- package/package.json +9 -9
- package/src/app-keybindings.ts +5 -0
- package/src/apps/session-picker.ts +7 -3
- package/src/chat/bash-execution.ts +20 -9
- package/src/chat/chat-transcript-builder.ts +5 -2
- package/src/chat/eval-execution.ts +14 -7
- package/src/chat/execution-shared.ts +39 -18
- package/src/chat/read-tool-group.ts +56 -36
- package/src/chat/transcript-render-helpers.ts +52 -6
- package/src/chrome/dynamic-border.ts +28 -2
- package/src/chrome/transcript-status.ts +5 -3
- package/src/components/editor.ts +123 -24
- package/src/components/key-value-list.ts +9 -9
- package/src/components/loader.ts +7 -0
- package/src/components/section.ts +1 -9
- package/src/components/table.ts +1 -9
- package/src/keybinding-matchers.ts +13 -0
- package/src/overlays/btw-history-panel.ts +98 -14
- package/src/overlays/btw-history.ts +65 -0
- package/src/overlays/btw-inject.ts +91 -0
- package/src/overlays/btw-panel.ts +53 -12
- package/src/overlays/hook-editor.ts +6 -0
- package/src/overlays/jobs-panel.ts +45 -19
- package/src/overlays/model-browser.ts +65 -8
- package/src/overlays/session-selector.ts +131 -26
- package/src/overlays/usage-dashboard.ts +2 -2
- package/src/render/code-cell.ts +1 -1
- package/src/render/file-list.ts +4 -7
- package/src/render/render-utils.ts +36 -6
- package/src/setup/scenes/glyph.ts +1 -1
- package/src/status-line/startup.ts +36 -70
- package/src/terminal-capabilities.ts +5 -0
- package/src/theme/symbols.ts +16 -9
- package/src/theme/theme-class.ts +3 -0
- package/src/tmux.ts +1 -0
- package/src/tools/agent-tree.ts +13 -11
- package/src/tools/ask.ts +13 -6
- package/src/tools/ast-edit.ts +48 -93
- package/src/tools/ast-grep.ts +22 -66
- package/src/tools/bash.ts +7 -0
- package/src/tools/default-renderer.ts +1 -1
- package/src/tools/edit.ts +50 -43
- package/src/tools/fetch.ts +2 -1
- package/src/tools/find.ts +6 -3
- package/src/tools/github.ts +14 -46
- package/src/tools/glob.ts +2 -1
- package/src/tools/grep.ts +43 -287
- package/src/tools/grouped-file-output.ts +250 -64
- package/src/tools/hashline-format.ts +5 -0
- package/src/tools/json-tree.ts +1 -5
- package/src/tools/lsp.ts +53 -25
- package/src/tools/native-view.ts +11 -5
- package/src/tools/output-meta.ts +68 -4
- package/src/tools/proc-render.ts +3 -3
- package/src/tools/read.ts +14 -0
- package/src/tools/task.ts +34 -77
- package/src/tools/vibe.ts +4 -4
- package/src/tools/wait.ts +8 -5
- package/src/tools/write.ts +17 -27
- package/src/tools/yield.ts +1 -1
- package/src/utils.ts +10 -0
package/src/components/editor.ts
CHANGED
|
@@ -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
|
-
| {
|
|
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
|
|
949
|
-
*
|
|
950
|
-
* autocomplete is open. A shown prediction replaces the {@link placeholder}; Tab or
|
|
951
|
-
* the rest without submitting.
|
|
952
|
-
*
|
|
953
|
-
|
|
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.#
|
|
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.#
|
|
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
|
|
5156
|
+
/** Insert the rest of the shown prediction, if any. Never submits. */
|
|
5126
5157
|
#acceptPrediction(): boolean {
|
|
5127
|
-
const
|
|
5128
|
-
if (!
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
5430
|
-
if (!
|
|
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
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
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 {
|
|
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[];
|
package/src/components/loader.ts
CHANGED
|
@@ -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,
|
|
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);
|
package/src/components/table.ts
CHANGED
|
@@ -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,
|
|
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) =>
|
|
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
|
-
#
|
|
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
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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`));
|