@oh-my-pi/pi-tui 18.6.1 → 18.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +6 -2
- package/dist/types/app-keybindings.d.ts +6 -1
- package/dist/types/chat/assistant-message.d.ts +6 -1
- package/dist/types/chat/chat-transcript-builder.d.ts +1 -0
- package/dist/types/chat/user-message.d.ts +2 -2
- package/dist/types/chrome/status-notice.d.ts +15 -3
- package/dist/types/components/editor.d.ts +4 -0
- package/dist/types/components/image.d.ts +14 -0
- package/dist/types/components/input.d.ts +1 -0
- package/dist/types/components/loader.d.ts +5 -6
- package/dist/types/key-hint-format.d.ts +8 -0
- package/dist/types/native/backend.d.ts +2 -2
- package/dist/types/native/node.d.ts +6 -0
- package/dist/types/native/overlay.d.ts +3 -3
- package/dist/types/overlays/agent-hub.d.ts +1 -0
- package/dist/types/overlays/agent-transcript-viewer.d.ts +1 -0
- package/dist/types/overlays/btw-history-panel.d.ts +5 -1
- package/dist/types/overlays/copy-selector.d.ts +1 -0
- package/dist/types/overlays/rewind-selector.d.ts +1 -0
- package/dist/types/overlays/usage-dashboard.d.ts +4 -4
- package/dist/types/prompt/custom-editor.d.ts +10 -4
- package/dist/types/prompt/interactive-context-helpers.d.ts +1 -0
- package/dist/types/space-hold.d.ts +40 -25
- package/dist/types/terminal-capabilities.d.ts +13 -0
- package/dist/types/tui.d.ts +5 -0
- package/package.json +9 -9
- package/src/app-keybindings.ts +6 -1
- package/src/apps/git/diff-pane.ts +42 -16
- package/src/apps/git/git-tui.ts +9 -9
- package/src/apps/git/sidebar.ts +6 -1
- package/src/chat/assistant-message.ts +26 -4
- package/src/chat/chat-transcript-builder.ts +4 -0
- package/src/chat/tool-execution.ts +6 -1
- package/src/chat/user-message.ts +16 -5
- package/src/chrome/status-notice.ts +25 -7
- package/src/chrome/transcript-container.ts +6 -3
- package/src/components/editor.ts +14 -0
- package/src/components/image.ts +68 -1
- package/src/components/input.ts +14 -6
- package/src/components/loader.ts +10 -17
- package/src/hotkeys-markdown.ts +9 -2
- package/src/key-hint-format.ts +25 -10
- package/src/native/backend.ts +10 -3
- package/src/native/node.ts +6 -0
- package/src/native/overlay.ts +4 -4
- package/src/overlays/agent-hub.ts +4 -0
- package/src/overlays/agent-transcript-viewer.ts +2 -0
- package/src/overlays/btw-history-panel.ts +9 -2
- package/src/overlays/copy-selector.ts +2 -0
- package/src/overlays/extensions/extension-dashboard.ts +4 -4
- package/src/overlays/pause-screen.ts +2 -2
- package/src/overlays/rewind-selector.ts +2 -0
- package/src/overlays/usage-dashboard.ts +17 -8
- package/src/overlays/usage-row.ts +22 -8
- package/src/prompt/custom-editor.ts +188 -67
- package/src/prompt/interactive-context-helpers.ts +2 -0
- package/src/prompt/queued-messages.ts +6 -4
- package/src/space-hold.ts +156 -52
- package/src/terminal-capabilities.ts +42 -23
- package/src/tui.ts +36 -14
package/src/app-keybindings.ts
CHANGED
|
@@ -60,6 +60,7 @@ interface AppKeybindings {
|
|
|
60
60
|
"app.tree.unfoldOrDown": true;
|
|
61
61
|
"app.plan.toggle": true;
|
|
62
62
|
"app.history.search": true;
|
|
63
|
+
"app.stt.pushToTalk": true;
|
|
63
64
|
"app.stt.toggle": true;
|
|
64
65
|
"app.live.toggle": true;
|
|
65
66
|
}
|
|
@@ -237,9 +238,13 @@ export const KEYBINDINGS = {
|
|
|
237
238
|
defaultKeys: "ctrl+r",
|
|
238
239
|
description: "Search history",
|
|
239
240
|
},
|
|
241
|
+
"app.stt.pushToTalk": {
|
|
242
|
+
defaultKeys: "space",
|
|
243
|
+
description: "Hold to record speech-to-text",
|
|
244
|
+
},
|
|
240
245
|
"app.stt.toggle": {
|
|
241
246
|
defaultKeys: [],
|
|
242
|
-
description: "Toggle speech-to-text
|
|
247
|
+
description: "Toggle speech-to-text recording",
|
|
243
248
|
},
|
|
244
249
|
"app.live.toggle": {
|
|
245
250
|
defaultKeys: "ctrl+l",
|
|
@@ -601,6 +601,12 @@ export class DiffPane {
|
|
|
601
601
|
#layoutCache: { key: string; visuals: Visual[] } | undefined;
|
|
602
602
|
/** Per visible row: clickable hunk-button ranges recorded during render. */
|
|
603
603
|
#hits: ({ hunk: number; primary?: [number, number]; discard?: [number, number] } | undefined)[] = [];
|
|
604
|
+
/**
|
|
605
|
+
* Asset images from the last render, by placement and size. Reusing them
|
|
606
|
+
* lets a SIXEL image show the sequence its off-thread encode delivers on a
|
|
607
|
+
* later render; a fresh `Image` per render would restart the encode forever.
|
|
608
|
+
*/
|
|
609
|
+
#assetImages = new Map<string, Image>();
|
|
604
610
|
constructor(imageBudget?: ImageBudget) {
|
|
605
611
|
this.#imageBudget = imageBudget;
|
|
606
612
|
}
|
|
@@ -1103,8 +1109,16 @@ export class DiffPane {
|
|
|
1103
1109
|
const leftWidth = Math.max(1, Math.floor((width - 1) / 2));
|
|
1104
1110
|
const rightWidth = Math.max(1, width - leftWidth - 1);
|
|
1105
1111
|
const bodyHeight = Math.max(0, height - 1);
|
|
1106
|
-
const
|
|
1107
|
-
|
|
1112
|
+
const previousImages = this.#assetImages;
|
|
1113
|
+
this.#assetImages = new Map();
|
|
1114
|
+
const oldLines = this.#renderAssetSide(asset.old, leftWidth, bodyHeight, `old:${asset.filePath}`, previousImages);
|
|
1115
|
+
const newLines = this.#renderAssetSide(
|
|
1116
|
+
asset.new,
|
|
1117
|
+
rightWidth,
|
|
1118
|
+
bodyHeight,
|
|
1119
|
+
`new:${asset.filePath}`,
|
|
1120
|
+
previousImages,
|
|
1121
|
+
);
|
|
1108
1122
|
const border = theme.fg("borderMuted", "│");
|
|
1109
1123
|
const lines: string[] = [];
|
|
1110
1124
|
for (let index = 0; index < height; index++) {
|
|
@@ -1125,24 +1139,36 @@ export class DiffPane {
|
|
|
1125
1139
|
return lines;
|
|
1126
1140
|
}
|
|
1127
1141
|
|
|
1128
|
-
#renderAssetSide(
|
|
1142
|
+
#renderAssetSide(
|
|
1143
|
+
side: FileAssetSide,
|
|
1144
|
+
width: number,
|
|
1145
|
+
height: number,
|
|
1146
|
+
placementKey: string,
|
|
1147
|
+
previousImages: ReadonlyMap<string, Image>,
|
|
1148
|
+
): readonly string[] {
|
|
1129
1149
|
if (height <= 0) return [];
|
|
1130
1150
|
let content: readonly string[];
|
|
1131
1151
|
if (side.kind === "image") {
|
|
1132
1152
|
const image = side.image;
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1153
|
+
const imageKey = `git-review:${placementKey}:${image.key}`;
|
|
1154
|
+
const cacheKey = `${imageKey}:${width}x${height}`;
|
|
1155
|
+
const component =
|
|
1156
|
+
previousImages.get(cacheKey) ??
|
|
1157
|
+
new Image(
|
|
1158
|
+
image.data,
|
|
1159
|
+
image.mimeType,
|
|
1160
|
+
{ fallbackColor: text => theme.fg("dim", text) },
|
|
1161
|
+
{
|
|
1162
|
+
maxWidthCells: Math.max(1, width - 2),
|
|
1163
|
+
maxHeightCells: height,
|
|
1164
|
+
filename: this.#asset?.filePath,
|
|
1165
|
+
budget: this.#imageBudget,
|
|
1166
|
+
imageKey,
|
|
1167
|
+
},
|
|
1168
|
+
{ widthPx: image.widthPx, heightPx: image.heightPx },
|
|
1169
|
+
);
|
|
1170
|
+
this.#assetImages.set(cacheKey, component);
|
|
1171
|
+
content = component.render(width);
|
|
1146
1172
|
} else {
|
|
1147
1173
|
let details: string[];
|
|
1148
1174
|
switch (side.kind) {
|
package/src/apps/git/git-tui.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import type { TspSpan } from "@oh-my-pi/pi-wire";
|
|
29
|
-
import { formatKeyHint, formatKeyHints } from "../../app-keybindings";
|
|
29
|
+
import { formatKeyHint, formatKeyHints, formatTooltipKey } from "../../app-keybindings";
|
|
30
30
|
import { SplitPane } from "../../components/layout/split-pane";
|
|
31
31
|
import { Stack } from "../../components/layout/stack";
|
|
32
32
|
import { matchesKey } from "../../keys";
|
|
@@ -1030,8 +1030,8 @@ class GitTuiComponent implements Component {
|
|
|
1030
1030
|
const right: NativeNode[] = [
|
|
1031
1031
|
row(
|
|
1032
1032
|
[
|
|
1033
|
-
iconButton("chev-up", "prev-hunk", `Previous change (${
|
|
1034
|
-
iconButton("chev", "next-hunk", `Next change (${
|
|
1033
|
+
iconButton("chev-up", "prev-hunk", `Previous change (${formatTooltipKey("alt+up")})`),
|
|
1034
|
+
iconButton("chev", "next-hunk", `Next change (${formatTooltipKey("alt+down")})`),
|
|
1035
1035
|
],
|
|
1036
1036
|
{ gap: "none", align: "center" },
|
|
1037
1037
|
),
|
|
@@ -1052,13 +1052,13 @@ class GitTuiComponent implements Component {
|
|
|
1052
1052
|
"type",
|
|
1053
1053
|
"whitespace",
|
|
1054
1054
|
this.#whitespace === "off"
|
|
1055
|
-
? `Ignore whitespace (${
|
|
1055
|
+
? `Ignore whitespace (${formatTooltipKey("b")})`
|
|
1056
1056
|
: this.#whitespace === "whitespace"
|
|
1057
|
-
? `Ignoring whitespace; also ignore formatting (${
|
|
1058
|
-
: `Ignoring formatting and imports; show all (${
|
|
1057
|
+
? `Ignoring whitespace; also ignore formatting (${formatTooltipKey("b")})`
|
|
1058
|
+
: `Ignoring formatting and imports; show all (${formatTooltipKey("b")})`,
|
|
1059
1059
|
this.#whitespace !== "off",
|
|
1060
1060
|
),
|
|
1061
|
-
iconButton("corner-down-right", "wrap", `Wrap lines (${
|
|
1061
|
+
iconButton("corner-down-right", "wrap", `Wrap lines (${formatTooltipKey("w")})`, this.#pane.wrap),
|
|
1062
1062
|
);
|
|
1063
1063
|
}
|
|
1064
1064
|
if (file?.area === "unstaged" || file?.area === "staged") {
|
|
@@ -1070,8 +1070,8 @@ class GitTuiComponent implements Component {
|
|
|
1070
1070
|
);
|
|
1071
1071
|
}
|
|
1072
1072
|
right.push(
|
|
1073
|
-
iconButton("keyboard", "help", `Keyboard shortcuts (${
|
|
1074
|
-
iconButton("x", "close", `Close (${
|
|
1073
|
+
iconButton("keyboard", "help", `Keyboard shortcuts (${formatTooltipKey("?")})`),
|
|
1074
|
+
iconButton("x", "close", `Close (${formatTooltipKey("q")})`),
|
|
1075
1075
|
);
|
|
1076
1076
|
return keyed(
|
|
1077
1077
|
row(
|
package/src/apps/git/sidebar.ts
CHANGED
|
@@ -1699,7 +1699,12 @@ export class Sidebar {
|
|
|
1699
1699
|
png,
|
|
1700
1700
|
"image/png",
|
|
1701
1701
|
{ fallbackColor: text => theme.fg("dim", text) },
|
|
1702
|
-
{
|
|
1702
|
+
{
|
|
1703
|
+
maxHeightCells: 3,
|
|
1704
|
+
budget: this.#imageBudget,
|
|
1705
|
+
imageKey: `git-avatar:${email}`,
|
|
1706
|
+
requestRender: this.#requestRender,
|
|
1707
|
+
},
|
|
1703
1708
|
),
|
|
1704
1709
|
};
|
|
1705
1710
|
}
|
|
@@ -456,6 +456,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
456
456
|
readonly #thinkingRenderers: readonly AssistantThinkingRenderer[];
|
|
457
457
|
readonly #imageBudget?: ImageBudget;
|
|
458
458
|
#proseOnlyThinking: boolean;
|
|
459
|
+
#expandThinkingBlocks: boolean;
|
|
459
460
|
|
|
460
461
|
constructor(
|
|
461
462
|
message?: AssistantMessage,
|
|
@@ -465,6 +466,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
465
466
|
imageBudget?: ImageBudget,
|
|
466
467
|
proseOnlyThinking = true,
|
|
467
468
|
linkTargets?: ReadonlyMap<string, string>,
|
|
469
|
+
expandThinkingBlocks = false,
|
|
468
470
|
) {
|
|
469
471
|
super();
|
|
470
472
|
this.#hideThinkingBlock = hideThinkingBlock;
|
|
@@ -472,6 +474,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
472
474
|
this.#thinkingRenderers = thinkingRenderers;
|
|
473
475
|
this.#imageBudget = imageBudget;
|
|
474
476
|
this.#proseOnlyThinking = proseOnlyThinking;
|
|
477
|
+
this.#expandThinkingBlocks = expandThinkingBlocks;
|
|
475
478
|
|
|
476
479
|
ensureThemeSync();
|
|
477
480
|
this.#transcriptBlockFinalized = message !== undefined;
|
|
@@ -560,6 +563,16 @@ export class AssistantMessageComponent extends Container {
|
|
|
560
563
|
this.#proseOnlyThinking = proseOnly;
|
|
561
564
|
}
|
|
562
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Keep finished thinking sections expanded instead of folding them to "Thought for 12s".
|
|
568
|
+
* Sections the user folded or unfolded by hand keep that choice.
|
|
569
|
+
*/
|
|
570
|
+
setExpandThinkingBlocks(expand: boolean): void {
|
|
571
|
+
if (this.#expandThinkingBlocks === expand) return;
|
|
572
|
+
this.#expandThinkingBlocks = expand;
|
|
573
|
+
this.#nativeViewVersion++;
|
|
574
|
+
}
|
|
575
|
+
|
|
563
576
|
override dispose(): void {
|
|
564
577
|
this.#stopThinkingAnimation();
|
|
565
578
|
super.dispose();
|
|
@@ -831,8 +844,10 @@ export class AssistantMessageComponent extends Container {
|
|
|
831
844
|
// `.live` while streaming: the body clamps to its tail under a fade.
|
|
832
845
|
role: thinkingLive ? "omp.thinking.live" : "omp.thinking",
|
|
833
846
|
collapsible: true,
|
|
834
|
-
// Open while it streams; a finished thought folds to its "Thought for 12s" line.
|
|
835
|
-
collapsed:
|
|
847
|
+
// Open while it streams; a finished thought folds to its "Thought for 12s" line unless the user keeps thinking expanded.
|
|
848
|
+
collapsed:
|
|
849
|
+
this.#thinkingCollapsed.get(index) ??
|
|
850
|
+
(this.#hideThinkingBlock || (!thinkingLive && !this.#expandThinkingBlocks)),
|
|
836
851
|
// Tern's fold head sums it into "Worked for 12s".
|
|
837
852
|
took: clock?.end === undefined ? undefined : Math.max(0, Math.round(clock.end - clock.start)),
|
|
838
853
|
},
|
|
@@ -1373,7 +1388,12 @@ export class AssistantMessageComponent extends Container {
|
|
|
1373
1388
|
displayImage.data,
|
|
1374
1389
|
displayImage.mimeType,
|
|
1375
1390
|
{ fallbackColor: (text: string) => theme.fg("toolOutput", text) },
|
|
1376
|
-
{
|
|
1391
|
+
{
|
|
1392
|
+
...resolveImageOptions(),
|
|
1393
|
+
budget: this.#imageBudget,
|
|
1394
|
+
imageKey: key,
|
|
1395
|
+
requestRender: this.#onImageUpdate,
|
|
1396
|
+
},
|
|
1377
1397
|
),
|
|
1378
1398
|
);
|
|
1379
1399
|
continue;
|
|
@@ -1415,7 +1435,9 @@ export class AssistantMessageComponent extends Container {
|
|
|
1415
1435
|
}
|
|
1416
1436
|
|
|
1417
1437
|
#computeShapeKey(message: AssistantMessage): string {
|
|
1418
|
-
const parts: string[] = [
|
|
1438
|
+
const parts: string[] = [
|
|
1439
|
+
`htb:${this.#hideThinkingBlock ? 1 : 0}|pot:${this.#proseOnlyThinking ? 1 : 0}|etb:${this.#expandThinkingBlocks ? 1 : 0}`,
|
|
1440
|
+
];
|
|
1419
1441
|
for (const content of message.content) {
|
|
1420
1442
|
if (content.type === "text") {
|
|
1421
1443
|
parts.push(canonicalizeMessage(content.text) ? "T1" : "T0");
|
|
@@ -80,6 +80,7 @@ export interface ChatTranscriptBuilderDeps {
|
|
|
80
80
|
cwd: string;
|
|
81
81
|
hideThinkingBlock?: () => boolean;
|
|
82
82
|
proseOnlyThinking?: () => boolean;
|
|
83
|
+
expandThinkingBlocks?: () => boolean;
|
|
83
84
|
/** Session-scoped resolved destinations for model-authored Markdown links. */
|
|
84
85
|
linkTargets?: ReadonlyMap<string, string>;
|
|
85
86
|
requestRender: () => void;
|
|
@@ -404,6 +405,7 @@ export class ChatTranscriptBuilder {
|
|
|
404
405
|
#appendAssistantMessage(message: Extract<AgentMessage, { role: "assistant" }>): void {
|
|
405
406
|
const hideThinkingBlock = this.#deps.hideThinkingBlock?.() ?? false;
|
|
406
407
|
const proseOnlyThinking = this.#deps.proseOnlyThinking ? this.#deps.proseOnlyThinking() : true;
|
|
408
|
+
const expandThinkingBlocks = this.#deps.expandThinkingBlocks?.() ?? false;
|
|
407
409
|
const timeline = splitAssistantMessageToolTimeline(message);
|
|
408
410
|
const assistantComponent = new AssistantMessageComponent(
|
|
409
411
|
timeline.beforeTools,
|
|
@@ -413,6 +415,7 @@ export class ChatTranscriptBuilder {
|
|
|
413
415
|
this.#deps.ui.imageBudget,
|
|
414
416
|
proseOnlyThinking,
|
|
415
417
|
this.#deps.linkTargets,
|
|
418
|
+
expandThinkingBlocks,
|
|
416
419
|
);
|
|
417
420
|
assistantComponent.setImagesVisible(displayPreferences.showImages);
|
|
418
421
|
assistantComponent.setToolResultImagesVisible(!displayPreferences.hideToolActivity);
|
|
@@ -450,6 +453,7 @@ export class ChatTranscriptBuilder {
|
|
|
450
453
|
undefined,
|
|
451
454
|
proseOnlyThinking,
|
|
452
455
|
this.#deps.linkTargets,
|
|
456
|
+
expandThinkingBlocks,
|
|
453
457
|
);
|
|
454
458
|
component.setImagesVisible(displayPreferences.showImages);
|
|
455
459
|
component.setToolResultImagesVisible(!displayPreferences.hideToolActivity);
|
|
@@ -1639,7 +1639,12 @@ export class ToolExecutionComponent extends Container {
|
|
|
1639
1639
|
imageData,
|
|
1640
1640
|
imageMimeType,
|
|
1641
1641
|
{ fallbackColor: (s: string) => theme.fg("toolOutput", s) },
|
|
1642
|
-
{
|
|
1642
|
+
{
|
|
1643
|
+
...resolveImageOptions(),
|
|
1644
|
+
budget: this.#ui.imageBudget,
|
|
1645
|
+
imageKey: `te${this.#instanceId}:${i}`,
|
|
1646
|
+
requestRender: () => this.#ui.requestRender(),
|
|
1647
|
+
},
|
|
1643
1648
|
);
|
|
1644
1649
|
this.#imageComponents.push(imageComponent);
|
|
1645
1650
|
this.addChild(imageComponent);
|
package/src/chat/user-message.ts
CHANGED
|
@@ -24,7 +24,13 @@ import type { ReactionTarget } from "./reaction";
|
|
|
24
24
|
import { card, md, node, row, span, text } from "../native/describe";
|
|
25
25
|
import { base64ImageNode } from "../native/blobs";
|
|
26
26
|
import { hasTranscriptActions, runTranscriptAction } from "./transcript-actions";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
type DescribeContext,
|
|
29
|
+
type NativeChild,
|
|
30
|
+
type NativeNode,
|
|
31
|
+
type NativeUiEvent,
|
|
32
|
+
rootToggleExpanded,
|
|
33
|
+
} from "../native/node";
|
|
28
34
|
import { Memo } from "../native/memo";
|
|
29
35
|
|
|
30
36
|
// OSC 133 shell integration: marks prompt zones for terminal multiplexers.
|
|
@@ -137,6 +143,8 @@ export class UserMessageComponent extends Container implements ReactionTarget {
|
|
|
137
143
|
readonly #tokens: RegExp;
|
|
138
144
|
#reaction: string | undefined;
|
|
139
145
|
#native: NativeNode | undefined;
|
|
146
|
+
/** The terminal's clock {@link #native} was described with. */
|
|
147
|
+
#nativeHour12: boolean | undefined;
|
|
140
148
|
|
|
141
149
|
constructor(text: string, options: UserBubbleOptions = {}) {
|
|
142
150
|
super();
|
|
@@ -184,17 +192,20 @@ export class UserMessageComponent extends Container implements ReactionTarget {
|
|
|
184
192
|
* agent's reaction are chips at the bottom-right, so a reaction landing
|
|
185
193
|
* later updates the frame in place, even deep in scrollback.
|
|
186
194
|
*/
|
|
187
|
-
override describe(): NativeNode {
|
|
188
|
-
|
|
195
|
+
override describe(cx?: DescribeContext): NativeNode {
|
|
196
|
+
// The terminal's clock: Bun's own default locale ignores the user's.
|
|
197
|
+
const hour12 = cx?.hour12;
|
|
198
|
+
if (this.#native && this.#nativeHour12 === hour12) return this.#native;
|
|
199
|
+
this.#nativeHour12 = hour12;
|
|
189
200
|
const children: NativeChild[] = [];
|
|
190
201
|
if (!this.#synthetic && hasTranscriptActions()) {
|
|
191
202
|
const tools: NativeChild[] = [];
|
|
192
203
|
if (this.#timestamp !== undefined) {
|
|
193
204
|
const at = new Date(this.#timestamp);
|
|
194
205
|
tools.push(
|
|
195
|
-
text([span(at.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), "dim mono")], {
|
|
206
|
+
text([span(at.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12 }), "dim mono")], {
|
|
196
207
|
role: "omp.user.time",
|
|
197
|
-
title: at.toLocaleString(),
|
|
208
|
+
title: at.toLocaleString([], { hour12 }),
|
|
198
209
|
}),
|
|
199
210
|
);
|
|
200
211
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Spacer } from "../components/spacer";
|
|
2
2
|
import { Text } from "../components/text";
|
|
3
|
-
import { node } from "../native/describe";
|
|
3
|
+
import { EMPTY_NODE, node } from "../native/describe";
|
|
4
4
|
import type { NativeNode } from "../native/node";
|
|
5
5
|
import { styledSpans } from "../native/spans";
|
|
6
6
|
import { Container } from "../tui";
|
|
@@ -8,33 +8,51 @@ import { Container } from "../tui";
|
|
|
8
8
|
/** How long a native status toast stays up (ms). */
|
|
9
9
|
const TOAST_TTL = 2400;
|
|
10
10
|
|
|
11
|
+
/** How a {@link StatusNotice} shows. */
|
|
12
|
+
export interface StatusNoticeOptions {
|
|
13
|
+
/** Styles the ANSI line; resolved at render so a theme change re-shapes it. */
|
|
14
|
+
styleFn?: (text: string) => string;
|
|
15
|
+
/**
|
|
16
|
+
* Whether a native terminal toasts it (default). False keeps the notice to
|
|
17
|
+
* the ANSI transcript: background chatter such as MCP connection progress,
|
|
18
|
+
* which would otherwise re-toast on every server that connects or fails.
|
|
19
|
+
*/
|
|
20
|
+
toast?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
/**
|
|
12
24
|
* A transient status notice (`showStatus`: "Thinking blocks: hidden", "Copied
|
|
13
25
|
* to clipboard"). The ANSI render appends a blank row and the dim line to the
|
|
14
26
|
* transcript; a native terminal shows it as a toast instead, so notices never
|
|
15
|
-
* pile up in the history. Updating the text re-shows the toast
|
|
27
|
+
* pile up in the history. Updating the text re-shows the toast; a notice
|
|
28
|
+
* with `toast: false` describes nothing natively.
|
|
16
29
|
*/
|
|
17
30
|
export class StatusNotice extends Container {
|
|
18
31
|
readonly #text: Text;
|
|
19
32
|
#message: string;
|
|
33
|
+
#toast: boolean;
|
|
20
34
|
#native: NativeNode | undefined;
|
|
21
35
|
|
|
22
|
-
constructor(message: string,
|
|
36
|
+
constructor(message: string, options: StatusNoticeOptions = {}) {
|
|
23
37
|
super();
|
|
24
38
|
this.#message = message;
|
|
25
|
-
this.#
|
|
39
|
+
this.#toast = options.toast ?? true;
|
|
40
|
+
this.#text = new Text(message, 1, 0).setStyleFn(options.styleFn);
|
|
26
41
|
this.addChild(new Spacer(1));
|
|
27
42
|
this.addChild(this.#text);
|
|
28
43
|
}
|
|
29
44
|
|
|
30
|
-
setMessage(message: string,
|
|
31
|
-
|
|
45
|
+
setMessage(message: string, options: StatusNoticeOptions = {}): void {
|
|
46
|
+
const toast = options.toast ?? true;
|
|
47
|
+
this.#text.setStyleFn(options.styleFn);
|
|
32
48
|
this.#text.setText(message);
|
|
33
|
-
if (message !== this.#message) this.#native = undefined;
|
|
49
|
+
if (message !== this.#message || toast !== this.#toast) this.#native = undefined;
|
|
34
50
|
this.#message = message;
|
|
51
|
+
this.#toast = toast;
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
override describe(): NativeNode {
|
|
55
|
+
if (!this.#toast) return EMPTY_NODE;
|
|
38
56
|
if (this.#native) return this.#native;
|
|
39
57
|
// Callers may pass pre-styled text; a toast takes plain text.
|
|
40
58
|
const plain = styledSpans(this.#message)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Component, Container, type HistoryBatch } from "../tui";
|
|
2
2
|
import * as logger from "@oh-my-pi/pi-utils/logger";
|
|
3
3
|
import { popLoopPhase, pushLoopPhase } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { renderForScrollback } from "../components/image";
|
|
4
5
|
import { col } from "../native/describe";
|
|
5
6
|
import type { NativeNode } from "../native/node";
|
|
6
7
|
import { isNativeSettled, settleNative } from "../native/settle";
|
|
@@ -1000,9 +1001,11 @@ export class TranscriptContainer extends Container {
|
|
|
1000
1001
|
// Only the range head is sliced by its emitted stable prefix; every other
|
|
1001
1002
|
// entry renders whole, so the append-only verification pass (a second
|
|
1002
1003
|
// full render of the block's stable prefix) is skipped for them. This
|
|
1003
|
-
// keeps a complete-ledger replay at one render per block.
|
|
1004
|
-
|
|
1005
|
-
|
|
1004
|
+
// keeps a complete-ledger replay at one render per block. These rows go
|
|
1005
|
+
// to native scrollback, where nothing can repaint them.
|
|
1006
|
+
const rendered = renderForScrollback(() =>
|
|
1007
|
+
index === start ? this.#renderEntry(entry, width) : trimBlankEdges(entry.component.render(width)),
|
|
1008
|
+
);
|
|
1006
1009
|
const emittedRows = index === start ? this.#renderStablePrefix(entry, entry.emitted, width).length : 0;
|
|
1007
1010
|
const block = rendered.slice(emittedRows);
|
|
1008
1011
|
reached = index + 1;
|
package/src/components/editor.ts
CHANGED
|
@@ -3042,6 +3042,20 @@ export class Editor implements Component, Focusable {
|
|
|
3042
3042
|
return true;
|
|
3043
3043
|
}
|
|
3044
3044
|
|
|
3045
|
+
/** Whether a character-jump hotkey is waiting for its target character. */
|
|
3046
|
+
get isJumpPending(): boolean {
|
|
3047
|
+
return this.#jumpMode !== null;
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
/** Type literal text through the character-editing pipeline without keybinding dispatch. */
|
|
3051
|
+
typeCharacter(text: string): void {
|
|
3052
|
+
// Same as #handleInputChunk: typed input wins over a stale in-flight provider lookup.
|
|
3053
|
+
if (this.#autocompleteRequestRunning && this.#autocompleteState === null) {
|
|
3054
|
+
this.#invalidateAutocompleteRequests();
|
|
3055
|
+
}
|
|
3056
|
+
this.#insertCharacter(text);
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3045
3059
|
#insertCharacter(char: string): void {
|
|
3046
3060
|
this.#exitHistoryForEditing();
|
|
3047
3061
|
// Undo coalescing: consecutive word typing collapses into one undo unit
|
package/src/components/image.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { encodeSixelAsync } from "@oh-my-pi/pi-natives";
|
|
1
2
|
import { getKittyGraphics } from "../kitty-graphics";
|
|
2
3
|
import {
|
|
4
|
+
encodeSixelNow,
|
|
3
5
|
getCellDimensions,
|
|
4
6
|
getImageDimensions,
|
|
5
7
|
type ImageDimensions,
|
|
@@ -29,6 +31,29 @@ export interface ImageOptions {
|
|
|
29
31
|
* repaint replaces the placement instead of stacking a duplicate.
|
|
30
32
|
*/
|
|
31
33
|
imageKey?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Schedules a repaint once this image's off-thread SIXEL encode lands.
|
|
36
|
+
* Defaults to the budget's repaint; a host without a budget must pass one,
|
|
37
|
+
* or the image's reserved rows stay blank until an unrelated repaint.
|
|
38
|
+
*/
|
|
39
|
+
requestRender?: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Renders in progress whose rows are bound for native scrollback. */
|
|
43
|
+
let scrollbackRenderDepth = 0;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run `render` for rows bound for native scrollback. Those rows are never
|
|
47
|
+
* repainted, so an image whose off-thread SIXEL encode has not landed encodes
|
|
48
|
+
* synchronously instead of committing its reserved blank rows.
|
|
49
|
+
*/
|
|
50
|
+
export function renderForScrollback<T>(render: () => T): T {
|
|
51
|
+
scrollbackRenderDepth++;
|
|
52
|
+
try {
|
|
53
|
+
return render();
|
|
54
|
+
} finally {
|
|
55
|
+
scrollbackRenderDepth--;
|
|
56
|
+
}
|
|
32
57
|
}
|
|
33
58
|
|
|
34
59
|
const EMPTY_IDS: readonly number[] = [];
|
|
@@ -227,6 +252,11 @@ export class ImageBudget {
|
|
|
227
252
|
this.#requestRender = requestRender;
|
|
228
253
|
}
|
|
229
254
|
|
|
255
|
+
/** Ask for a repaint, e.g. once an image's off-thread encode settles. */
|
|
256
|
+
requestRender(): void {
|
|
257
|
+
this.#requestRender();
|
|
258
|
+
}
|
|
259
|
+
|
|
230
260
|
setCap(cap: number): void {
|
|
231
261
|
const next = normalizeCap(cap);
|
|
232
262
|
if (next === this.#cap) return;
|
|
@@ -735,6 +765,8 @@ export class Image implements Component {
|
|
|
735
765
|
// (its rows may already be committed to native scrollback).
|
|
736
766
|
#renderedGraphicRows = 0;
|
|
737
767
|
#native?: NativeNode;
|
|
768
|
+
/** Newest SIXEL encode: its target size and, once settled, the sequence (`null` on failure). */
|
|
769
|
+
#sixel?: { widthPx: number; heightPx: number; sequence?: string | null };
|
|
738
770
|
|
|
739
771
|
constructor(
|
|
740
772
|
base64Data: string,
|
|
@@ -768,6 +800,38 @@ export class Image implements Component {
|
|
|
768
800
|
this.#cachedWidth = undefined;
|
|
769
801
|
}
|
|
770
802
|
|
|
803
|
+
/**
|
|
804
|
+
* SIXEL sequence for a target size. A new size starts the encode off the JS
|
|
805
|
+
* thread and answers `undefined` until it settles; the settled encode
|
|
806
|
+
* invalidates the cached lines and requests a repaint. A render bound for
|
|
807
|
+
* scrollback cannot wait, so it encodes synchronously.
|
|
808
|
+
*/
|
|
809
|
+
#sixelSequence(widthPx: number, heightPx: number): string | null | undefined {
|
|
810
|
+
const current = this.#sixel;
|
|
811
|
+
const sameSize = current !== undefined && current.widthPx === widthPx && current.heightPx === heightPx;
|
|
812
|
+
if (sameSize && current.sequence !== undefined) return current.sequence;
|
|
813
|
+
if (scrollbackRenderDepth > 0) {
|
|
814
|
+
// Replacing the entry makes a still-pending async encode settle as stale.
|
|
815
|
+
const sequence = encodeSixelNow(this.#base64Data, widthPx, heightPx);
|
|
816
|
+
this.#sixel = { widthPx, heightPx, sequence };
|
|
817
|
+
return sequence;
|
|
818
|
+
}
|
|
819
|
+
if (sameSize) return undefined;
|
|
820
|
+
const request: { widthPx: number; heightPx: number; sequence?: string | null } = { widthPx, heightPx };
|
|
821
|
+
this.#sixel = request;
|
|
822
|
+
const settle = (sequence: string | null): void => {
|
|
823
|
+
request.sequence = sequence;
|
|
824
|
+
if (this.#sixel !== request) return;
|
|
825
|
+
this.invalidate();
|
|
826
|
+
if (this.#options.requestRender) this.#options.requestRender();
|
|
827
|
+
else this.#budget?.requestRender();
|
|
828
|
+
};
|
|
829
|
+
encodeSixelAsync(new Uint8Array(Buffer.from(this.#base64Data, "base64")), widthPx, heightPx).then(settle, () =>
|
|
830
|
+
settle(null),
|
|
831
|
+
);
|
|
832
|
+
return undefined;
|
|
833
|
+
}
|
|
834
|
+
|
|
771
835
|
/**
|
|
772
836
|
* A native `image` backed by a content-addressed blob; the terminal fits
|
|
773
837
|
* it. Cell caps become `ch`/`lines` bounds. The inline-image budget and
|
|
@@ -820,7 +884,9 @@ export class Image implements Component {
|
|
|
820
884
|
this.#cachedCellWidthPx === cellDimensions.widthPx &&
|
|
821
885
|
this.#cachedCellHeightPx === cellDimensions.heightPx &&
|
|
822
886
|
this.#cachedKittyUnicodePlaceholders === kittyUnicodePlaceholders &&
|
|
823
|
-
(!transmitsSeparately || this.#budget?.shouldTransmit(imageId) !== true)
|
|
887
|
+
(!transmitsSeparately || this.#budget?.shouldTransmit(imageId) !== true) &&
|
|
888
|
+
// Cached rows reserved for a pending SIXEL encode must not reach scrollback.
|
|
889
|
+
!(scrollbackRenderDepth > 0 && this.#sixel !== undefined && this.#sixel.sequence === undefined)
|
|
824
890
|
) {
|
|
825
891
|
return this.#cachedLines;
|
|
826
892
|
}
|
|
@@ -839,6 +905,7 @@ export class Image implements Component {
|
|
|
839
905
|
maxHeightCells: this.#options.maxHeightCells,
|
|
840
906
|
imageId: this.#imageId,
|
|
841
907
|
includeTransmit: needsTransmit,
|
|
908
|
+
sixel: (widthPx, heightPx) => this.#sixelSequence(widthPx, heightPx),
|
|
842
909
|
});
|
|
843
910
|
|
|
844
911
|
if (result?.transmit && this.#imageId != null && this.#budget !== undefined) {
|
package/src/components/input.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
|
|
2
|
-
import { getKeybindings } from "../keybindings";
|
|
3
|
-
import { extractPrintableText,
|
|
2
|
+
import { canonicalKeyId, getKeybindings } from "../keybindings";
|
|
3
|
+
import { extractPrintableText, parseKey } from "../keys";
|
|
4
4
|
import { KillRing } from "../kill-ring";
|
|
5
5
|
import type { TspInputProps } from "@oh-my-pi/pi-wire";
|
|
6
6
|
import { node } from "../native/describe";
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
type NativeUiEvent,
|
|
15
15
|
resolveTextEdit,
|
|
16
16
|
} from "../native/node";
|
|
17
|
-
import { SpaceHoldGesture } from "../space-hold";
|
|
17
|
+
import { getSpaceHoldText, SpaceHoldGesture } from "../space-hold";
|
|
18
18
|
import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
|
|
19
19
|
import { cursorColumnWindow } from "./scroll-viewport";
|
|
20
20
|
import {
|
|
@@ -134,6 +134,10 @@ export class Input implements Component, Focusable {
|
|
|
134
134
|
return this.#useTerminalCursor;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
capturesInput(data: string): boolean {
|
|
138
|
+
return this.spaceHold.shouldRoute(data);
|
|
139
|
+
}
|
|
140
|
+
|
|
137
141
|
/**
|
|
138
142
|
* Apply one key: the editor's text bindings (motion, deletion, kill ring,
|
|
139
143
|
* undo), pastes and printable text. Returns whether the key was the
|
|
@@ -154,10 +158,14 @@ export class Input implements Component, Focusable {
|
|
|
154
158
|
return true;
|
|
155
159
|
}
|
|
156
160
|
|
|
157
|
-
//
|
|
158
|
-
|
|
161
|
+
// Reserve configured keys before text bindings or submit/delete handlers can consume them.
|
|
162
|
+
const parsedKey = parseKey(data);
|
|
163
|
+
const canonical = parsedKey !== undefined ? canonicalKeyId(parsedKey) : undefined;
|
|
164
|
+
const spaceHoldText = getSpaceHoldText(data, canonical);
|
|
165
|
+
switch (this.spaceHold.process(canonical, spaceHoldText?.length ?? 0)) {
|
|
159
166
|
case "type":
|
|
160
|
-
this.#insertCharacter(
|
|
167
|
+
this.#insertCharacter(spaceHoldText!);
|
|
168
|
+
this.spaceHold.recordTyped(spaceHoldText!.length);
|
|
161
169
|
return true;
|
|
162
170
|
case "swallow":
|
|
163
171
|
return true;
|