@oh-my-pi/pi-tui 18.2.11 → 18.3.1
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 +44 -0
- package/THIRD-PARTY-NOTICES.txt +3 -3
- package/dist/types/apps/ps-data.d.ts +1 -1
- package/dist/types/apps/ps-top.d.ts +1 -1
- package/dist/types/chat/chat-transcript-builder.d.ts +1 -1
- package/dist/types/chat/read-tool-group.d.ts +3 -3
- package/dist/types/chat/user-message.d.ts +4 -1
- package/dist/types/components/editor.d.ts +0 -2
- package/dist/types/components/input.d.ts +16 -0
- package/dist/types/overlays/annotation-overlay.d.ts +30 -0
- package/dist/types/overlays/annotation-types.d.ts +84 -0
- package/dist/types/overlays/btw-history-panel.d.ts +4 -1
- package/dist/types/overlays/copy-selector.d.ts +30 -2
- package/dist/types/overlays/copy-targets.d.ts +2 -0
- package/dist/types/overlays/model-hub.d.ts +2 -2
- package/dist/types/overlays/oauth-selector.d.ts +3 -6
- package/dist/types/overlays/settings-defs.d.ts +5 -0
- package/dist/types/overlays/usage-dashboard.d.ts +2 -0
- package/dist/types/prompt/composer.d.ts +12 -1
- package/dist/types/prompt/custom-editor.d.ts +5 -26
- package/dist/types/prompt/image-source.d.ts +9 -8
- package/dist/types/space-hold.d.ts +45 -0
- package/dist/types/status-line/context-usage.d.ts +3 -1
- package/dist/types/status-line/host.d.ts +2 -0
- package/dist/types/theme/mermaid-cache.d.ts +4 -6
- package/dist/types/tools/bash.d.ts +7 -0
- package/dist/types/tools/cfg-render.d.ts +37 -0
- package/dist/types/tools/cfg-url.d.ts +16 -0
- package/dist/types/tools/daemon.d.ts +46 -0
- package/dist/types/tools/find.d.ts +2 -2
- package/dist/types/tools/irc.d.ts +30 -0
- package/dist/types/tools/line-ranges.d.ts +1 -1
- package/dist/types/tools/output-meta.d.ts +2 -0
- package/dist/types/tools/proc-render.d.ts +27 -0
- package/dist/types/tools/read.d.ts +18 -4
- package/dist/types/tools/result-card.d.ts +20 -0
- package/dist/types/tools/url-scheme-host.d.ts +29 -0
- package/dist/types/tools/wait.d.ts +131 -0
- package/dist/types/tools/write.d.ts +6 -0
- package/dist/types/tools/xd-url.d.ts +8 -0
- package/dist/types/tools/xdev.d.ts +9 -1
- package/dist/types/windows-altgr.d.ts +54 -0
- package/package.json +13 -10
- package/src/apps/debug/protocol-probe.ts +1 -1
- package/src/apps/ps-data.ts +1 -1
- package/src/apps/ps-top.ts +1 -1
- package/src/chat/chat-transcript-builder.ts +9 -5
- package/src/chat/read-tool-group.ts +8 -9
- package/src/chat/tool-execution.ts +15 -13
- package/src/chat/transcript-render-helpers.ts +3 -3
- package/src/chat/user-message.ts +16 -6
- package/src/components/editor.ts +2 -4
- package/src/components/input.ts +103 -30
- package/src/components/markdown.ts +19 -7
- package/src/desktop-notify.ts +1 -1
- package/src/overlays/annotation-overlay.ts +1144 -0
- package/src/overlays/annotation-types.ts +78 -0
- package/src/overlays/btw-history-panel.ts +4 -0
- package/src/overlays/copy-selector.ts +45 -19
- package/src/overlays/copy-targets.ts +3 -3
- package/src/overlays/model-hub.ts +5 -4
- package/src/overlays/oauth-selector.ts +8 -12
- package/src/overlays/plan-review-overlay.ts +213 -40
- package/src/overlays/settings-defs.ts +6 -0
- package/src/overlays/settings-selector.ts +4 -3
- package/src/overlays/usage-dashboard.ts +22 -1
- package/src/prompt/composer.ts +83 -56
- package/src/prompt/custom-editor.ts +15 -137
- package/src/prompt/image-source.ts +9 -8
- package/src/prompt/tips.txt +1 -0
- package/src/setup/scenes/sign-in.ts +1 -1
- package/src/space-hold.ts +143 -0
- package/src/status-line/component.ts +6 -0
- package/src/status-line/context-usage.ts +15 -3
- package/src/status-line/host.ts +2 -0
- package/src/status-line/segments.ts +4 -0
- package/src/terminal-capabilities.ts +2 -2
- package/src/terminal.ts +8 -1
- package/src/theme/mermaid-cache.ts +83 -25
- package/src/tools/bash.ts +14 -1
- package/src/tools/cfg-render.ts +117 -0
- package/src/tools/cfg-url.ts +30 -0
- package/src/tools/daemon.ts +51 -0
- package/src/tools/find.ts +7 -6
- package/src/tools/index.ts +2 -2
- package/src/tools/irc.ts +33 -0
- package/src/tools/line-ranges.ts +13 -3
- package/src/tools/output-meta.ts +2 -0
- package/src/tools/proc-render.ts +270 -0
- package/src/tools/read.ts +118 -56
- package/src/tools/result-card.ts +36 -0
- package/src/tools/url-scheme-host.ts +42 -0
- package/src/tools/wait.ts +595 -0
- package/src/tools/write.ts +120 -1
- package/src/tools/xd-url.ts +9 -0
- package/src/tools/xdev.ts +9 -1
- package/src/windows-altgr.ts +227 -0
- package/dist/types/chat/read-target.d.ts +0 -4
- package/dist/types/tools/hub.d.ts +0 -342
- package/src/chat/read-target.ts +0 -7
- package/src/tools/hub.ts +0 -1492
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* viewer ({@link AgentTranscriptViewer}) to render a parked subagent / advisor /
|
|
6
6
|
* collab-guest transcript that has no live session.
|
|
7
7
|
*
|
|
8
|
-
* Unlike
|
|
8
|
+
* Unlike incremental transcript sync, {@link ChatTranscriptBuilder.rebuild}
|
|
9
9
|
* always discards prior components and rebuilds the whole transcript from the
|
|
10
10
|
* supplied entries. Re-rendering a growing transcript is therefore O(n) in the
|
|
11
11
|
* entry count, but it cannot duplicate or misorder rows the way incremental
|
|
@@ -184,12 +184,12 @@ export class ChatTranscriptBuilder {
|
|
|
184
184
|
this.#expandables.push(component);
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
/** A `
|
|
187
|
+
/** A `wait` showing all-running is displaced by the next `wait` call. */
|
|
188
188
|
#resolveWaitingPoll(nextToolName?: string): void {
|
|
189
189
|
const previous = this.#waitingPoll;
|
|
190
190
|
if (!previous) return;
|
|
191
191
|
this.#waitingPoll = null;
|
|
192
|
-
if (nextToolName === "
|
|
192
|
+
if (nextToolName === "wait" && previous.isDisplaceableBlock() && this.container.canRemoveBlock(previous)) {
|
|
193
193
|
this.container.removeChild(previous);
|
|
194
194
|
}
|
|
195
195
|
previous.seal();
|
|
@@ -310,7 +310,11 @@ export class ChatTranscriptBuilder {
|
|
|
310
310
|
this.#trackExpandable(collapsed);
|
|
311
311
|
this.container.addChild(collapsed);
|
|
312
312
|
} else {
|
|
313
|
-
this.container.addChild(
|
|
313
|
+
this.container.addChild(
|
|
314
|
+
new UserMessageComponent(userText, {
|
|
315
|
+
liveSteered: message.role === "user" && message.liveSteered === true,
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
314
318
|
}
|
|
315
319
|
}
|
|
316
320
|
break;
|
|
@@ -517,7 +521,7 @@ export class ChatTranscriptBuilder {
|
|
|
517
521
|
if (!pending) return;
|
|
518
522
|
pending.updateResult(message, false, message.toolCallId);
|
|
519
523
|
this.#pendingTools.delete(message.toolCallId);
|
|
520
|
-
if (message.toolName === "
|
|
524
|
+
if (message.toolName === "wait" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) {
|
|
521
525
|
this.#waitingPoll = pending;
|
|
522
526
|
} else if (
|
|
523
527
|
message.toolName === "todo" &&
|
|
@@ -3,14 +3,13 @@ import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai";
|
|
|
3
3
|
import { type Component } from "../tui";
|
|
4
4
|
import { Container } from "../tui";
|
|
5
5
|
import { Text } from "../components/text";
|
|
6
|
-
import { XD_URL_PREFIX } from "../tools/xd-url";
|
|
7
6
|
import { getLanguageFromPath, theme } from "../theme";
|
|
8
7
|
import { parseLineRanges, selectorLineRanges } from "../tools/line-ranges";
|
|
9
8
|
import { type ReadRenderArgs, type ReadToolDetails, readSourceFsPath, splitPathAndSel } from "../tools/read";
|
|
10
9
|
import { PREVIEW_LIMITS, shortenPath } from "../render/render-utils";
|
|
11
10
|
import { fileHyperlink, renderCodeCell } from "../render";
|
|
12
11
|
import { canonicalizeMessage } from "./thinking-display";
|
|
13
|
-
import {
|
|
12
|
+
import { internalUrlSchemeSpec, splitUrlScheme } from "../tools/url-scheme-host";
|
|
14
13
|
import type { ToolExecutionHandle } from "./tool-execution";
|
|
15
14
|
import { formatUsageRow } from "../overlays/usage-row";
|
|
16
15
|
|
|
@@ -35,14 +34,16 @@ export function readArgsHaveTarget(args: unknown): boolean {
|
|
|
35
34
|
/**
|
|
36
35
|
* Whether a read collapses into the compact {@link ReadToolGroupComponent}
|
|
37
36
|
* rather than a full tool execution. Filesystem/external targets always
|
|
38
|
-
* collapse;
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* collapse; registered internal-URL schemes render full so their resolved
|
|
38
|
+
* content is visible, unless their spec declares `compactTranscript`
|
|
39
|
+
* (device listings/docs read better in the compact grouped view).
|
|
41
40
|
*/
|
|
42
41
|
export function readArgsCollapseIntoGroup(args: unknown): boolean {
|
|
43
42
|
const target = readArgsTarget(args);
|
|
44
43
|
if (target === undefined) return false;
|
|
45
|
-
|
|
44
|
+
const url = splitUrlScheme(target);
|
|
45
|
+
const spec = url && internalUrlSchemeSpec(url.scheme);
|
|
46
|
+
return spec === undefined || spec.compactTranscript === true;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
/**
|
|
@@ -137,8 +138,6 @@ const READ_STATUS_RANK: Record<ReadEntry["status"], number> = {
|
|
|
137
138
|
error: 3,
|
|
138
139
|
};
|
|
139
140
|
|
|
140
|
-
const URL_LIKE_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
141
|
-
|
|
142
141
|
function getDisplayReadTargets(details: ReadToolDetails | undefined): ReadDisplayPathSpec[] | undefined {
|
|
143
142
|
if (!Array.isArray(details?.displayReadTargets)) return undefined;
|
|
144
143
|
const links = Array.isArray(details.displayReadTargetLinks) ? details.displayReadTargetLinks : undefined;
|
|
@@ -237,7 +236,7 @@ function commaContinuesLineRangeSelector(input: string, partStart: number, comma
|
|
|
237
236
|
|
|
238
237
|
function splitReadDisplayPathSpecs(rawPath: string): string[] {
|
|
239
238
|
const normalized = rawPath.trim();
|
|
240
|
-
if (!normalized ||
|
|
239
|
+
if (!normalized || splitUrlScheme(normalized)) return [rawPath];
|
|
241
240
|
|
|
242
241
|
const parts: string[] = [];
|
|
243
242
|
let braceDepth = 0;
|
|
@@ -22,7 +22,7 @@ import { type EditMode, type PerFileDiffPreview, renderStreamingFallback } from
|
|
|
22
22
|
import { EVAL_DEFAULT_PREVIEW_LINES } from "../tools/eval";
|
|
23
23
|
import { taskCardAgentIds } from "../tools/task";
|
|
24
24
|
import { TODO_STRIKE_TOTAL_FRAMES, type TodoToolDetails } from "../tools/todo";
|
|
25
|
-
import { isWaitingPollDetails } from "../tools/
|
|
25
|
+
import { isWaitingPollDetails } from "../tools/wait";
|
|
26
26
|
import { formatStatusIcon, replaceTabs, resolveImageOptions } from "../render/render-utils";
|
|
27
27
|
import type { XdevMountedState } from "../tools/xdev";
|
|
28
28
|
import { isFramedBlockComponent, markFramedBlockComponent, renderStatusLine, WidthAwareText } from "../render/index";
|
|
@@ -35,7 +35,7 @@ import { type AnimationFrame, trimBlankEdges } from "../chrome/transcript-contai
|
|
|
35
35
|
export function toolRenderName(wireName: string, tool: AgentTool | undefined): string {
|
|
36
36
|
return tool?.name ?? wireName;
|
|
37
37
|
}
|
|
38
|
-
type DisplaceableToolName = "
|
|
38
|
+
type DisplaceableToolName = "wait" | "todo";
|
|
39
39
|
|
|
40
40
|
function isTodoToolDetails(details: unknown): details is TodoToolDetails {
|
|
41
41
|
return (
|
|
@@ -67,7 +67,7 @@ function displaceableToolName(
|
|
|
67
67
|
isPartial: boolean,
|
|
68
68
|
): DisplaceableToolName | undefined {
|
|
69
69
|
if (result.isError === true) return undefined;
|
|
70
|
-
if (toolName === "
|
|
70
|
+
if (toolName === "wait" && isWaitingPollDetails(result.details)) return "wait";
|
|
71
71
|
if (toolName === "todo" && !isPartial && isTodoToolDetails(result.details)) return "todo";
|
|
72
72
|
return undefined;
|
|
73
73
|
}
|
|
@@ -311,7 +311,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
311
311
|
// late result can update its streaming preview.
|
|
312
312
|
#sealed = false;
|
|
313
313
|
// Tool result snapshots that may be superseded by a later same-tool call
|
|
314
|
-
// while still in the mutable viewport. `
|
|
314
|
+
// while still in the mutable viewport. `wait` uses this for repeated all-running polls; `todo` uses
|
|
315
315
|
// it for per-turn state snapshots so only the latest list remains visible.
|
|
316
316
|
#displaceableByToolName: DisplaceableToolName | undefined;
|
|
317
317
|
// Execution start on the presentation clock (performance.now domain, the
|
|
@@ -605,7 +605,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
605
605
|
this.#toolName !== "todo" &&
|
|
606
606
|
!isBackgroundAsyncRunning &&
|
|
607
607
|
(pendingCallConsumesSpinner || partialResultConsumesSpinner);
|
|
608
|
-
const needsSpinner = isStreamingArgs || isLivePartialTool || this.#displaceableByToolName === "
|
|
608
|
+
const needsSpinner = isStreamingArgs || isLivePartialTool || this.#displaceableByToolName === "wait";
|
|
609
609
|
if (needsSpinner && !this.#spinnerActive) {
|
|
610
610
|
const frameCount = theme.spinnerFrames.length;
|
|
611
611
|
const frame = sharedSpinnerFrame(frameCount);
|
|
@@ -845,8 +845,8 @@ export class ToolExecutionComponent extends Container {
|
|
|
845
845
|
if (this.#allocation < 3) {
|
|
846
846
|
// A squeezed allocation degrades only blocks that genuinely overflow it.
|
|
847
847
|
// The allocator measures blocks by trimmed height and never squeezes one
|
|
848
|
-
// below that, so inline tools whose real content is 1-2 rows (
|
|
849
|
-
//
|
|
848
|
+
// below that, so inline tools whose real content is 1-2 rows (wait
|
|
849
|
+
// results, one-line receipts) keep that content instead of an equally
|
|
850
850
|
// tall but contentless frame.
|
|
851
851
|
const trimmed = trimBlankEdges(lines);
|
|
852
852
|
if (trimmed.length > this.#allocation) return this.#renderCompact(width);
|
|
@@ -891,7 +891,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
891
891
|
});
|
|
892
892
|
if (summary !== undefined) {
|
|
893
893
|
if (summary.detail) return summary;
|
|
894
|
-
// A detail-less custom summary (e.g.
|
|
894
|
+
// A detail-less custom summary (e.g. wait before its result
|
|
895
895
|
// parse) must not fold to a bare `╭─ Label` frame under viewport
|
|
896
896
|
// pressure — keep the generic liveness hint for in-flight calls.
|
|
897
897
|
return this.#isRunning() ? { ...summary, detail: "running" } : summary;
|
|
@@ -1297,12 +1297,14 @@ export class ToolExecutionComponent extends Container {
|
|
|
1297
1297
|
}
|
|
1298
1298
|
context.renderDiff = renderDiff;
|
|
1299
1299
|
} else if (this.#toolName === "write") {
|
|
1300
|
-
// Device-dispatch previews
|
|
1300
|
+
// Device-dispatch previews render through the host's canonical resolver,
|
|
1301
|
+
// which covers mounted devices and active top-level tools (the `write`
|
|
1302
|
+
// transport accepts both). Deciding the predicate here instead would
|
|
1303
|
+
// leave a `write xd://<top-level tool>` card on the generic fallback.
|
|
1301
1304
|
const writeTool = this.#tool as { session?: { xdev?: XdevMountedState } } | undefined;
|
|
1302
|
-
const
|
|
1303
|
-
if (
|
|
1304
|
-
context.resolveXdevMounted =
|
|
1305
|
-
xdev.mountedNames.has(name) ? xdev.tools.get(name) : undefined;
|
|
1305
|
+
const resolveXdevMounted = writeTool?.session?.xdev?.resolve;
|
|
1306
|
+
if (resolveXdevMounted) {
|
|
1307
|
+
context.resolveXdevMounted = resolveXdevMounted;
|
|
1306
1308
|
}
|
|
1307
1309
|
}
|
|
1308
1310
|
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
8
8
|
import { type Component } from "../tui";
|
|
9
9
|
import { formatBytes, formatDuration } from "@oh-my-pi/pi-utils";
|
|
10
|
-
import type { JobSnapshot } from "../tools/
|
|
11
|
-
import type { DaemonSnapshot } from "../tools/
|
|
10
|
+
import type { JobSnapshot } from "../tools/wait";
|
|
11
|
+
import type { DaemonSnapshot } from "../tools/daemon";
|
|
12
12
|
import { type CustomMessage, type FileMentionMessage, resolveAbortLabel, shouldRenderAbortReason } from "./messages";
|
|
13
|
-
import { createIrcMessageCard } from "../tools/
|
|
13
|
+
import { createIrcMessageCard } from "../tools/wait";
|
|
14
14
|
import { formatArtifactErrorNotice, type OutputMeta } from "../tools/output-meta";
|
|
15
15
|
import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../render/render-utils";
|
|
16
16
|
import { canonicalizeMessage } from "./thinking-display";
|
package/src/chat/user-message.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface UserBubbleOptions {
|
|
|
48
48
|
imageLinks?: readonly (string | undefined)[];
|
|
49
49
|
/** Agent-attributed input: dim, flat prose. */
|
|
50
50
|
synthetic?: boolean;
|
|
51
|
+
/** Delivered into the response that was streaming; marked `*` at the bubble's top-left. */
|
|
52
|
+
liveSteered?: boolean;
|
|
51
53
|
/** SKILL.md path for a skill chip by name; `undefined` leaves the chip unlinked. */
|
|
52
54
|
skillPath?: (name: string) => string | undefined;
|
|
53
55
|
}
|
|
@@ -102,7 +104,8 @@ export function userBubbleColor(
|
|
|
102
104
|
|
|
103
105
|
/**
|
|
104
106
|
* Component that renders a user message. Accepts an agent reaction badge
|
|
105
|
-
* (see {@link ReactionTarget}) drawn right-aligned in the bubble's top padding row
|
|
107
|
+
* (see {@link ReactionTarget}) drawn right-aligned in the bubble's top padding row;
|
|
108
|
+
* a live-steered message carries a `*` marker left-aligned in the same row.
|
|
106
109
|
*/
|
|
107
110
|
export class UserMessageComponent extends Container implements ReactionTarget {
|
|
108
111
|
// Memoized OSC 133 zone wrapping keyed on the underlying container render
|
|
@@ -112,6 +115,7 @@ export class UserMessageComponent extends Container implements ReactionTarget {
|
|
|
112
115
|
#zoneSource: readonly string[] | undefined;
|
|
113
116
|
#zoneLines: string[] | undefined;
|
|
114
117
|
readonly #bgColor: (value: string) => string;
|
|
118
|
+
readonly #liveSteered: boolean;
|
|
115
119
|
#reaction: string | undefined;
|
|
116
120
|
|
|
117
121
|
constructor(text: string, options: UserBubbleOptions = {}) {
|
|
@@ -130,6 +134,7 @@ export class UserMessageComponent extends Container implements ReactionTarget {
|
|
|
130
134
|
});
|
|
131
135
|
const bgColor = (value: string) => theme.bg("userMessageBg", value);
|
|
132
136
|
this.#bgColor = bgColor;
|
|
137
|
+
this.#liveSteered = options.liveSteered === true;
|
|
133
138
|
const md = new Markdown(text, 1, 1, getMarkdownTheme(), {
|
|
134
139
|
bgColor,
|
|
135
140
|
color: userBubbleColor(options, composerTokenRegex(mentionLabels)),
|
|
@@ -144,10 +149,15 @@ export class UserMessageComponent extends Container implements ReactionTarget {
|
|
|
144
149
|
this.#zoneLines = undefined;
|
|
145
150
|
}
|
|
146
151
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
152
|
+
/**
|
|
153
|
+
* The top padding row: the live-steering marker left-aligned and the reaction
|
|
154
|
+
* badge right-aligned, both inside the horizontal padding.
|
|
155
|
+
*/
|
|
156
|
+
#badgeRow(width: number): string {
|
|
157
|
+
const marker = this.#liveSteered ? theme.fg("accent", "*") : "";
|
|
158
|
+
const emoji = this.#reaction ?? "";
|
|
159
|
+
const gap = Math.max(0, width - 2 - visibleWidth(marker) - visibleWidth(emoji));
|
|
160
|
+
return applyBackgroundToLine(` ${marker}${padding(gap)}${emoji}`, width, this.#bgColor);
|
|
151
161
|
}
|
|
152
162
|
|
|
153
163
|
override render(width: number): readonly string[] {
|
|
@@ -159,7 +169,7 @@ export class UserMessageComponent extends Container implements ReactionTarget {
|
|
|
159
169
|
return this.#zoneLines;
|
|
160
170
|
}
|
|
161
171
|
const wrapped = lines.slice();
|
|
162
|
-
if (this.#reaction !== undefined) wrapped[0] = this.#
|
|
172
|
+
if (this.#reaction !== undefined || this.#liveSteered) wrapped[0] = this.#badgeRow(width);
|
|
163
173
|
wrapped[0] = OSC133_ZONE_START + wrapped[0];
|
|
164
174
|
wrapped[wrapped.length - 1] = wrapped[wrapped.length - 1] + OSC133_ZONE_CLOSE;
|
|
165
175
|
this.#zoneSource = lines;
|
package/src/components/editor.ts
CHANGED
|
@@ -528,8 +528,6 @@ export class Editor implements Component, Focusable {
|
|
|
528
528
|
|
|
529
529
|
/** When set, replaces the normal cursor glyph at end-of-text with this ANSI-styled string. */
|
|
530
530
|
cursorOverride: string | undefined;
|
|
531
|
-
/** Display width of the cursorOverride glyph (needed because override may contain ANSI escapes). */
|
|
532
|
-
cursorOverrideWidth: number | undefined;
|
|
533
531
|
/** Optional hook that decorates displayed user text after source-text layout.
|
|
534
532
|
* Width-changing output is allowed on lines without the cursor; it is truncated
|
|
535
533
|
* to the content width rather than reflowed. Cursor glyphs and inline hints are excluded. */
|
|
@@ -1304,7 +1302,7 @@ export class Editor implements Component, Focusable {
|
|
|
1304
1302
|
if (hasCursor && !this.#useTerminalCursor) {
|
|
1305
1303
|
const zeroWidthCursorBudget = visibleWidth(gutterText);
|
|
1306
1304
|
const zeroWidthCursorReplacement = this.cursorOverride
|
|
1307
|
-
? { text: this.cursorOverride, width: this.
|
|
1305
|
+
? { text: this.cursorOverride, width: visibleWidth(this.cursorOverride) }
|
|
1308
1306
|
: this.#getStyledInputCursor();
|
|
1309
1307
|
if (showPromptGutter && zeroWidthCursorBudget > 0) {
|
|
1310
1308
|
// Keep the leading prompt glyph visible when the gutter consumes the whole row.
|
|
@@ -1405,7 +1403,7 @@ export class Editor implements Component, Focusable {
|
|
|
1405
1403
|
// displayWidth stays the same - we're replacing, not adding
|
|
1406
1404
|
} else if (this.cursorOverride) {
|
|
1407
1405
|
// Cursor override replaces the normal end-of-text cursor glyph
|
|
1408
|
-
const overrideWidth = this.
|
|
1406
|
+
const overrideWidth = visibleWidth(this.cursorOverride);
|
|
1409
1407
|
if (!isSideBordered && displayWidth + overrideWidth > lineContentWidth) {
|
|
1410
1408
|
// Borderless editors have no spare padding cell for an end-of-line cursor glyph.
|
|
1411
1409
|
// Preserve cursorOverride by replacing the tail of the line with it.
|
package/src/components/input.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
|
|
2
2
|
import { getKeybindings } from "../keybindings";
|
|
3
|
-
import { extractPrintableText } from "../keys";
|
|
3
|
+
import { extractPrintableText, matchesKey } from "../keys";
|
|
4
4
|
import { KillRing } from "../kill-ring";
|
|
5
|
+
import { SpaceHoldGesture } from "../space-hold";
|
|
5
6
|
import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
|
|
6
7
|
import { cursorColumnWindow } from "./scroll-viewport";
|
|
7
8
|
import {
|
|
@@ -17,6 +18,30 @@ import {
|
|
|
17
18
|
|
|
18
19
|
const segmenter = getSegmenter();
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Clean text entering the single-line value from outside the keyboard (pastes, dictation) —
|
|
23
|
+
* decode tmux's re-encoded control bytes (both extended-keys formats, e.g. Ctrl+J → "\n") back to
|
|
24
|
+
* literal bytes so the escape tail does not leak in, remove newlines/carriage returns, expand tabs,
|
|
25
|
+
* NFC-normalize, then strip any remaining control bytes. The decoder can synthesize Ctrl+A..Ctrl+Z
|
|
26
|
+
* (0x01..0x1A) from a paste, and a single-line value must hold none of them — newlines are already
|
|
27
|
+
* gone and tabs are already spaces by the time the C0/DEL strip runs.
|
|
28
|
+
*
|
|
29
|
+
* NFC normalization rationale: macOS Finder drag-drops file paths in NFD
|
|
30
|
+
* (Conjoining Jamo, U+1100..U+11FF). `Bun.stringWidth` counts each
|
|
31
|
+
* conjoining jamo as a separate cell — a Korean syllable like `화` is
|
|
32
|
+
* 1 char and 2 cells in NFC, but 2 chars and 3 cells in NFD (ᄒ=2 cells
|
|
33
|
+
* + ᅪ=1 cell). The terminal renders the NFD sequence as a single
|
|
34
|
+
* combined syllable (2 cells visible), so the width mismatch shows up
|
|
35
|
+
* as cursor drift past the visible filename — N×~1.5 cells for a path
|
|
36
|
+
* with N Korean syllables. NFC normalization at insert time stores the
|
|
37
|
+
* value in the same form everything else in the codebase assumes.
|
|
38
|
+
*/
|
|
39
|
+
function toSingleLine(text: string): string {
|
|
40
|
+
return replaceTabs(decodeReencodedPasteControls(text).replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, ""))
|
|
41
|
+
.normalize("NFC")
|
|
42
|
+
.replace(/[\x00-\x1F\x7F]/g, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
20
45
|
interface InputState {
|
|
21
46
|
value: string;
|
|
22
47
|
cursor: number;
|
|
@@ -35,6 +60,10 @@ export class Input implements Component, Focusable {
|
|
|
35
60
|
mask = false;
|
|
36
61
|
onSubmit?: (value: string) => void;
|
|
37
62
|
onEscape?: () => void;
|
|
63
|
+
/** Space-bar push-to-talk; set its `handler` to enable it. */
|
|
64
|
+
readonly spaceHold = new SpaceHoldGesture(count => this.deleteBeforeCursor(count));
|
|
65
|
+
/** When set, replaces the cursor glyph at end-of-text with this ANSI-styled string. */
|
|
66
|
+
cursorOverride: string | undefined;
|
|
38
67
|
|
|
39
68
|
/** Focusable interface - set by TUI when focus changes */
|
|
40
69
|
focused: boolean = false;
|
|
@@ -49,6 +78,9 @@ export class Input implements Component, Focusable {
|
|
|
49
78
|
// Undo support
|
|
50
79
|
#undoStack: InputState[] = [];
|
|
51
80
|
|
|
81
|
+
/** Code units of the current volatile speech-to-text preview (see {@link setVolatileText}). */
|
|
82
|
+
#volatileTextLen = 0;
|
|
83
|
+
|
|
52
84
|
getValue(): string {
|
|
53
85
|
return this.#value;
|
|
54
86
|
}
|
|
@@ -93,6 +125,15 @@ export class Input implements Component, Focusable {
|
|
|
93
125
|
return;
|
|
94
126
|
}
|
|
95
127
|
|
|
128
|
+
// Space-hold push-to-talk: a sustained space bar starts/stops STT instead of typing spaces.
|
|
129
|
+
switch (this.spaceHold.process(matchesKey(data, "space"))) {
|
|
130
|
+
case "type":
|
|
131
|
+
this.#insertCharacter(" ");
|
|
132
|
+
return;
|
|
133
|
+
case "swallow":
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
96
137
|
const kb = getKeybindings();
|
|
97
138
|
|
|
98
139
|
// Escape/Cancel
|
|
@@ -212,6 +253,52 @@ export class Input implements Component, Focusable {
|
|
|
212
253
|
this.#handlePaste(text);
|
|
213
254
|
}
|
|
214
255
|
|
|
256
|
+
/** Programmatically trigger submission (e.g. for voice submit). */
|
|
257
|
+
submit(): void {
|
|
258
|
+
this.onSubmit?.(this.#value);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Delete up to `count` characters immediately before the cursor. */
|
|
262
|
+
deleteBeforeCursor(count: number): void {
|
|
263
|
+
const removable = Math.min(count, this.#cursor);
|
|
264
|
+
if (removable <= 0) return;
|
|
265
|
+
this.#lastAction = null;
|
|
266
|
+
this.#pushUndo();
|
|
267
|
+
this.#replaceBeforeCursor(removable, "");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Show or replace a volatile speech-to-text preview at the cursor, outside undo history.
|
|
271
|
+
* Finalize it with {@link commitVolatileText} or drop it with {@link clearVolatileText}. */
|
|
272
|
+
setVolatileText(text: string): void {
|
|
273
|
+
const clean = toSingleLine(text);
|
|
274
|
+
this.#replaceBeforeCursor(this.#volatileTextLen, clean);
|
|
275
|
+
this.#volatileTextLen = clean.length;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Remove the current volatile preview without committing it. */
|
|
279
|
+
clearVolatileText(): void {
|
|
280
|
+
this.setVolatileText("");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Drop any volatile preview, then insert `text` as a single undoable edit. */
|
|
284
|
+
commitVolatileText(text: string): void {
|
|
285
|
+
this.clearVolatileText();
|
|
286
|
+
const clean = toSingleLine(text);
|
|
287
|
+
if (!clean) return;
|
|
288
|
+
this.#lastAction = null;
|
|
289
|
+
this.#pushUndo();
|
|
290
|
+
this.#replaceBeforeCursor(0, clean);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Replace up to `count` code units before the cursor with `text`, leaving the cursor after it. The
|
|
294
|
+
* range stops at the start of the value: a volatile preview's length can outrun the cursor once
|
|
295
|
+
* the caret moves while dictation is still streaming. */
|
|
296
|
+
#replaceBeforeCursor(count: number, text: string): void {
|
|
297
|
+
const start = Math.max(0, this.#cursor - count);
|
|
298
|
+
this.#value = this.#value.slice(0, start) + text + this.#value.slice(this.#cursor);
|
|
299
|
+
this.#cursor = start + text.length;
|
|
300
|
+
}
|
|
301
|
+
|
|
215
302
|
#insertCharacter(text: string): void {
|
|
216
303
|
const isWordChunk = [...segmenter.segment(text)].every(seg => getWordNavKind(seg.segment) !== "whitespace");
|
|
217
304
|
// Undo coalescing: consecutive word typing coalesces into one undo unit.
|
|
@@ -390,32 +477,7 @@ export class Input implements Component, Focusable {
|
|
|
390
477
|
#handlePaste(pastedText: string): void {
|
|
391
478
|
this.#lastAction = null;
|
|
392
479
|
this.#pushUndo();
|
|
393
|
-
|
|
394
|
-
// Clean the pasted text — decode tmux's re-encoded control bytes (both
|
|
395
|
-
// extended-keys formats, e.g. Ctrl+J → "\n") back to literal bytes so the escape
|
|
396
|
-
// tail does not leak in, remove newlines/carriage returns, expand tabs, NFC-normalize,
|
|
397
|
-
// then strip any remaining control bytes. The decoder can synthesize Ctrl+A..Ctrl+Z
|
|
398
|
-
// (0x01..0x1A) from a paste, and a single-line value must hold none of them — newlines
|
|
399
|
-
// are already gone and tabs are already spaces by the time the C0/DEL strip runs.
|
|
400
|
-
//
|
|
401
|
-
// NFC normalization rationale: macOS Finder drag-drops file paths in NFD
|
|
402
|
-
// (Conjoining Jamo, U+1100..U+11FF). `Bun.stringWidth` counts each
|
|
403
|
-
// conjoining jamo as a separate cell — a Korean syllable like `화` is
|
|
404
|
-
// 1 char and 2 cells in NFC, but 2 chars and 3 cells in NFD (ᄒ=2 cells
|
|
405
|
-
// + ᅪ=1 cell). The terminal renders the NFD sequence as a single
|
|
406
|
-
// combined syllable (2 cells visible), so the width mismatch shows up
|
|
407
|
-
// as cursor drift past the visible filename — N×~1.5 cells for a path
|
|
408
|
-
// with N Korean syllables. NFC normalization at paste time stores the
|
|
409
|
-
// value in the same form everything else in the codebase assumes.
|
|
410
|
-
const cleanText = replaceTabs(
|
|
411
|
-
decodeReencodedPasteControls(pastedText).replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, ""),
|
|
412
|
-
)
|
|
413
|
-
.normalize("NFC")
|
|
414
|
-
.replace(/[\x00-\x1F\x7F]/g, "");
|
|
415
|
-
|
|
416
|
-
// Insert at cursor position
|
|
417
|
-
this.#value = this.#value.slice(0, this.#cursor) + cleanText + this.#value.slice(this.#cursor);
|
|
418
|
-
this.#cursor += cleanText.length;
|
|
480
|
+
this.#replaceBeforeCursor(0, toSingleLine(pastedText));
|
|
419
481
|
}
|
|
420
482
|
|
|
421
483
|
invalidate(): void {
|
|
@@ -440,7 +502,13 @@ export class Input implements Component, Focusable {
|
|
|
440
502
|
visibleValue = "•".repeat(graphemes.length);
|
|
441
503
|
cursorIndex = graphemes.filter(grapheme => grapheme.index < this.#cursor).length;
|
|
442
504
|
}
|
|
443
|
-
|
|
505
|
+
// At end of text the cursor sits on trailing padding, sized to fit a (possibly wide) cursor override.
|
|
506
|
+
const atEnd = this.#cursor >= this.#value.length;
|
|
507
|
+
const override =
|
|
508
|
+
atEnd && this.cursorOverride !== undefined
|
|
509
|
+
? { text: this.cursorOverride, width: visibleWidth(this.cursorOverride) }
|
|
510
|
+
: undefined;
|
|
511
|
+
const displayValue = atEnd ? visibleValue + " ".repeat(override?.width ?? 1) : visibleValue;
|
|
444
512
|
|
|
445
513
|
const window = cursorColumnWindow(displayValue, cursorIndex, availableWidth);
|
|
446
514
|
const visibleText = window.text;
|
|
@@ -456,11 +524,10 @@ export class Input implements Component, Focusable {
|
|
|
456
524
|
|
|
457
525
|
// Hardware cursor marker (zero-width, emitted before the cursor cell for IME positioning)
|
|
458
526
|
const marker = this.focused ? CURSOR_MARKER : "";
|
|
459
|
-
const
|
|
527
|
+
const { text: cursorChar, width: cursorWidth } = override ?? this.#cursorCell(atCursor);
|
|
460
528
|
|
|
461
529
|
// Clamp only the trailing text (measured in terminal cells), keeping the cursor marker intact.
|
|
462
530
|
const beforeWidth = visibleWidth(beforeCursor);
|
|
463
|
-
const cursorWidth = this.#useTerminalCursor ? visibleWidth(atCursor) : visibleWidth(atCursor || " ");
|
|
464
531
|
const remainingAfterWidth = Math.max(0, availableWidth - beforeWidth - cursorWidth);
|
|
465
532
|
const clampedAfterCursor = sliceWithWidth(afterCursor, 0, remainingAfterWidth, true).text;
|
|
466
533
|
const renderedNoMarker = beforeCursor + cursorChar + clampedAfterCursor;
|
|
@@ -471,4 +538,10 @@ export class Input implements Component, Focusable {
|
|
|
471
538
|
const line = prompt + textWithCursor + pad;
|
|
472
539
|
return [line];
|
|
473
540
|
}
|
|
541
|
+
|
|
542
|
+
/** The rendered cursor cell: the grapheme under the cursor, inverted unless the terminal draws its own cursor. */
|
|
543
|
+
#cursorCell(atCursor: string): { text: string; width: number } {
|
|
544
|
+
if (this.#useTerminalCursor) return { text: atCursor, width: visibleWidth(atCursor) };
|
|
545
|
+
return { text: `\x1b[7m${atCursor || " "}\x1b[27m`, width: visibleWidth(atCursor || " ") };
|
|
546
|
+
}
|
|
474
547
|
}
|
|
@@ -2882,6 +2882,14 @@ export class Markdown implements Component {
|
|
|
2882
2882
|
};
|
|
2883
2883
|
}
|
|
2884
2884
|
|
|
2885
|
+
#quoteStyle(text: string): string {
|
|
2886
|
+
return this.#theme.quote(this.#theme.italic(text));
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
#getQuoteStylePrefix(): string {
|
|
2890
|
+
return this.#getStylePrefix(text => this.#quoteStyle(text));
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2885
2893
|
#renderToken(
|
|
2886
2894
|
token: Token,
|
|
2887
2895
|
width: number,
|
|
@@ -2955,7 +2963,7 @@ export class Markdown implements Component {
|
|
|
2955
2963
|
// resolver. The art is preformatted, so clip each row to the content
|
|
2956
2964
|
// width: the later wrap pass would otherwise fragment the box-drawing
|
|
2957
2965
|
// canvas. truncateToWidth is ANSI- and wide-char-aware, and the
|
|
2958
|
-
// resolver
|
|
2966
|
+
// resolver picks the shortest orientation that fits this width.
|
|
2959
2967
|
if (token.lang === "mermaid" && this.#theme.resolveMermaidAscii) {
|
|
2960
2968
|
const ascii = this.#theme.resolveMermaidAscii(token.text, width);
|
|
2961
2969
|
if (ascii) {
|
|
@@ -3004,7 +3012,7 @@ export class Markdown implements Component {
|
|
|
3004
3012
|
case "blockquote": {
|
|
3005
3013
|
const quoteInlineStyleContext: InlineStyleContext = {
|
|
3006
3014
|
applyText: (text: string) => text,
|
|
3007
|
-
stylePrefix:
|
|
3015
|
+
stylePrefix: this.#getQuoteStylePrefix(),
|
|
3008
3016
|
};
|
|
3009
3017
|
const quoteContentWidth = Math.max(1, width - 2);
|
|
3010
3018
|
const quoteTokens = token.tokens || [];
|
|
@@ -3074,14 +3082,13 @@ export class Markdown implements Component {
|
|
|
3074
3082
|
* `width` is the full content width; the border reserves two cells.
|
|
3075
3083
|
*/
|
|
3076
3084
|
#applyQuoteBorder(renderedLines: RenderedLine[], width: number): RenderedLine[] {
|
|
3077
|
-
const
|
|
3078
|
-
const quoteStylePrefix = this.#getStylePrefix(quoteStyle);
|
|
3085
|
+
const quoteStylePrefix = this.#getQuoteStylePrefix();
|
|
3079
3086
|
const applyQuoteStyle = (line: string): string => {
|
|
3080
3087
|
if (!quoteStylePrefix) {
|
|
3081
|
-
return quoteStyle(line);
|
|
3088
|
+
return this.#quoteStyle(line);
|
|
3082
3089
|
}
|
|
3083
3090
|
const lineWithReappliedStyle = line.replace(/\x1b\[0m/g, `\x1b[0m${quoteStylePrefix}`);
|
|
3084
|
-
return quoteStyle(lineWithReappliedStyle);
|
|
3091
|
+
return this.#quoteStyle(lineWithReappliedStyle);
|
|
3085
3092
|
};
|
|
3086
3093
|
const quoteContentWidth = Math.max(1, width - 2);
|
|
3087
3094
|
const lines: RenderedLine[] = [];
|
|
@@ -3140,7 +3147,12 @@ export class Markdown implements Component {
|
|
|
3140
3147
|
|
|
3141
3148
|
/** Render the inner content of an HTML `<blockquote>` with quote styling. */
|
|
3142
3149
|
#renderHtmlBlockquote(inner: string, width: number): RenderedLine[] {
|
|
3143
|
-
const
|
|
3150
|
+
const quoteStylePrefix = this.#getQuoteStylePrefix();
|
|
3151
|
+
const cleaned = normalizeHtmlForTerminal(
|
|
3152
|
+
inner,
|
|
3153
|
+
createHtmlNormalizationState(),
|
|
3154
|
+
text => this.#theme.code(text) + quoteStylePrefix,
|
|
3155
|
+
);
|
|
3144
3156
|
const innerLines = splitTerminalLines(cleaned).map(line => renderedLine(line.trimEnd()));
|
|
3145
3157
|
while (innerLines.length > 0 && innerLines[innerLines.length - 1].text === "") innerLines.pop();
|
|
3146
3158
|
return this.#applyQuoteBorder(innerLines, width);
|
package/src/desktop-notify.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { $which } from "@oh-my-pi/pi-utils";
|
|
|
26
26
|
import type { TerminalId, TerminalNotification } from "./terminal-capabilities";
|
|
27
27
|
|
|
28
28
|
/** Application name surfaced as the notification source. */
|
|
29
|
-
const APP_NAME = "
|
|
29
|
+
const APP_NAME = "omp";
|
|
30
30
|
|
|
31
31
|
/** Resolved notifier binary used to fan a notification out to D-Bus. */
|
|
32
32
|
export type DesktopNotifierKind = "notify-send" | "gdbus";
|