@oh-my-pi/pi-tui 18.4.9 → 18.4.10
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 +23 -0
- package/dist/types/chat/assistant-message.d.ts +4 -3
- package/dist/types/chat/messages.d.ts +4 -0
- package/dist/types/chrome/transcript-container.d.ts +7 -0
- package/dist/types/components/markdown.d.ts +0 -4
- package/dist/types/overlays/advisor-config.d.ts +8 -0
- package/dist/types/overlays/ask-dialog.d.ts +1 -0
- package/dist/types/prompt/composer.d.ts +3 -1
- package/dist/types/status-line/context-usage.d.ts +2 -0
- package/dist/types/terminal-capabilities.d.ts +5 -0
- package/dist/types/tui.d.ts +3 -0
- package/package.json +9 -9
- package/src/chat/advisor-message.ts +4 -2
- package/src/chat/assistant-message.ts +7 -9
- package/src/chat/chat-transcript-builder.ts +1 -1
- package/src/chat/messages.ts +4 -0
- package/src/chrome/transcript-container.ts +13 -0
- package/src/components/markdown.ts +90 -130
- package/src/keys.ts +2 -4
- package/src/native/encode.ts +2 -0
- package/src/overlays/advisor-config.ts +228 -2
- package/src/overlays/ask-dialog.ts +1 -0
- package/src/overlays/model-hub.ts +25 -4
- package/src/overlays/session-info-overlay.ts +55 -11
- package/src/prompt/composer.ts +32 -7
- package/src/status-line/context-usage.ts +18 -2
- package/src/terminal-capabilities.ts +8 -0
- package/src/terminal.ts +8 -8
- package/src/tools/wait.ts +7 -19
- package/src/tui.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.4.10] - 2026-10-02
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added ContextUsageView.setBreakdown to refresh usage card without recreating it
|
|
10
|
+
- Added support for change events with flexible values in native TUI
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Keep settled responses reachable in scrollback while an ask panel is open, and keep the editor at the bottom after answering ([#12398](https://github.com/can1357/oh-my-pi/issues/12398), [#13993](https://github.com/can1357/oh-my-pi/pull/13993) by [@Dante-dan](https://github.com/Dante-dan)).
|
|
15
|
+
- Fixed a finished `wait` whose jobs were all still running going blank, and the next `wait` removing it while its turn's usage row stayed; the card now keeps its job snapshot ([#12248](https://github.com/can1357/oh-my-pi/issues/12248), [#13978](https://github.com/can1357/oh-my-pi/pull/13978) by [@Dante-dan](https://github.com/Dante-dan)).
|
|
16
|
+
- Hidden thinking blocks no longer leave a faint "Thought for Ns" row in Tern's native transcript; only the live "Thinking…" indicator shows while the model reasons.
|
|
17
|
+
- Fixed long Markdown paragraphs, such as a read preview of a file with no blank line, stalling rendering: a 44 KB paragraph now renders in about 8 ms instead of 95 ms ([#13961](https://github.com/can1357/oh-my-pi/pull/13961) by [@sjawhar](https://github.com/sjawhar)).
|
|
18
|
+
- Fixed Markdown paragraphs with many unclosed `[`, `*` or `_`, or with a long address-like word, stalling rendering for seconds ([#13961](https://github.com/can1357/oh-my-pi/pull/13961) by [@sjawhar](https://github.com/sjawhar)).
|
|
19
|
+
- Fixed Markdown paragraphs with many unclosed `~~`, `$`, `\(` or `\[`, nested brackets or emphasis, or unclosed HTML, stalling rendering for seconds: 40 KB of unclosed `~~` took 85 s. Emphasis or links nested a thousand levels deep still render slowly, for seconds per few KB: each level restyles the text inside it ([#13961](https://github.com/can1357/oh-my-pi/pull/13961) by [@sjawhar](https://github.com/sjawhar)).
|
|
20
|
+
- Fixed arrow keys acting as Escape and terminal query replies appearing as typed text when running omp on Windows over SSH ([#14034](https://github.com/can1357/oh-my-pi/issues/14034)).
|
|
21
|
+
- Fixed `/model` under an `enabledModels`/`--models` scope hiding every judge, search, image, and speech model and dropping their configured role assignments (JUDGE, WEB, IMAGE, …) ([#14016](https://github.com/can1357/oh-my-pi/issues/14016))
|
|
22
|
+
|
|
23
|
+
### Removed
|
|
24
|
+
|
|
25
|
+
- Removed the internal `urlTokenPossible` export ([#13961](https://github.com/can1357/oh-my-pi/pull/13961) by [@sjawhar](https://github.com/sjawhar)).
|
|
26
|
+
- Removed the internal `autolinkSchemeScanIndex` export ([#13961](https://github.com/can1357/oh-my-pi/pull/13961) by [@sjawhar](https://github.com/sjawhar)).
|
|
27
|
+
|
|
5
28
|
## [18.4.9] - 2026-10-01
|
|
6
29
|
|
|
7
30
|
### Added
|
|
@@ -86,9 +86,10 @@ export declare class AssistantMessageComponent extends Container {
|
|
|
86
86
|
* A `col` (role `omp.assistant`) of `md` nodes keyed by content index, so
|
|
87
87
|
* streamed deltas reach the terminal as `text append` on the same node;
|
|
88
88
|
* the tail block carries `stream: true` until the message finalizes.
|
|
89
|
-
* Thinking blocks are quiet collapsible `section`s
|
|
90
|
-
*
|
|
91
|
-
*
|
|
89
|
+
* Thinking blocks are quiet collapsible `section`s: a muted "Thinking…"
|
|
90
|
+
* shimmer while live, then "Thought for 12s"; tokens and rate ride in the
|
|
91
|
+
* head's `title`. With `hideThinkingBlock` only the live head shows (collapsed)
|
|
92
|
+
* and a finished thought emits nothing.
|
|
92
93
|
*/
|
|
93
94
|
describe(): NativeNode;
|
|
94
95
|
/**
|
|
@@ -180,6 +180,10 @@ export interface AdvisorNote {
|
|
|
180
180
|
severity?: AdvisorSeverity;
|
|
181
181
|
/** Which configured advisor produced this note (omitted for the default advisor). */
|
|
182
182
|
advisor?: string;
|
|
183
|
+
/** Primary turns completed when note was emitted. */
|
|
184
|
+
turn?: number;
|
|
185
|
+
/** Primary turns elapsed before merged-batch delivery. */
|
|
186
|
+
turnsAgo?: number;
|
|
183
187
|
}
|
|
184
188
|
/** Details payload on the batched `advisor` custom message rendered in the transcript. */
|
|
185
189
|
export interface AdvisorMessageDetails {
|
|
@@ -81,6 +81,13 @@ export declare class TranscriptContainer extends Container {
|
|
|
81
81
|
resetStableEmission(): void;
|
|
82
82
|
/** Whether a transient block may be discarded without leaving tape history. */
|
|
83
83
|
canRemoveBlock(component: Component): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Whether a superseded snapshot block (a repeated `wait` poll) may be
|
|
86
|
+
* replaced by its successor. Beyond {@link canRemoveBlock}, a block directly
|
|
87
|
+
* above a usage row stays: that row describes the turn the block belongs to,
|
|
88
|
+
* and removing the block would leave a usage-only turn (#12248).
|
|
89
|
+
*/
|
|
90
|
+
canDisplaceBlock(component: Component): boolean;
|
|
84
91
|
/** Lifecycle state per block in transcript order (diagnostics and tests). */
|
|
85
92
|
blockStates(): readonly BlockState[];
|
|
86
93
|
/** Permanently captured presentation mode per block (diagnostics and tests). */
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import type { DescribeContext, NativeNode } from "../native/node.js";
|
|
2
2
|
import type { SymbolTheme } from "../symbols.js";
|
|
3
3
|
import type { Component } from "../tui.js";
|
|
4
|
-
/** @internal exported for tests — must stay index-identical to the old regex scan. */
|
|
5
|
-
export declare function autolinkSchemeScanIndex(src: string): number | undefined;
|
|
6
|
-
/** @internal exported for tests — must never return false for a src the built-in url regex matches. */
|
|
7
|
-
export declare function urlTokenPossible(src: string): boolean;
|
|
8
4
|
/** @internal exported for tests — the grown-line-start block-kind gate. */
|
|
9
5
|
export declare function fastLineStartHazard(grownLine: string): boolean;
|
|
10
6
|
/** A hyperlink as the renderer sees it: inline `[text](href)`, `<autolink>`, bare GFM URL, or reference link. */
|
|
@@ -20,6 +20,10 @@ import { type Model, type UsageLimit, type UsageReport } from "@oh-my-pi/pi-ai";
|
|
|
20
20
|
import { type Component, type TUI } from "../index.js";
|
|
21
21
|
import { type ModelBrowserSource } from "./model-browser.js";
|
|
22
22
|
import type { DescribeContext, NativeNode, NativeUiEvent } from "../native/node.js";
|
|
23
|
+
export declare const ADVISOR_REVIEW_MODES: readonly ["turn", "agent-end"];
|
|
24
|
+
export declare const ADVISOR_SYNC_BACKLOG_MODES: readonly ["off", "1", "3", "5", "strict"];
|
|
25
|
+
export type AdvisorReviewMode = (typeof ADVISOR_REVIEW_MODES)[number];
|
|
26
|
+
export type AdvisorSyncBacklog = (typeof ADVISOR_SYNC_BACKLOG_MODES)[number];
|
|
23
27
|
/** One advisor declared in `WATCHDOG.yml`; its instructions specialize the shared baseline. */
|
|
24
28
|
export interface AdvisorConfig {
|
|
25
29
|
name: string;
|
|
@@ -32,6 +36,9 @@ export interface AdvisorConfig {
|
|
|
32
36
|
enabled?: boolean;
|
|
33
37
|
/** Maximum non-blocker notes per advisor prompt update (default 4); blockers are exempt. */
|
|
34
38
|
maxNotesPerUpdate?: number;
|
|
39
|
+
reviewMode?: AdvisorReviewMode;
|
|
40
|
+
reviewInterval?: number;
|
|
41
|
+
syncBacklog?: AdvisorSyncBacklog;
|
|
35
42
|
}
|
|
36
43
|
/** Which level a `WATCHDOG.yml` lives at: the project root or the user agent dir. */
|
|
37
44
|
export type AdvisorConfigScope = "project" | "user";
|
|
@@ -92,6 +99,7 @@ export interface AdvisorConfigDeps {
|
|
|
92
99
|
thinkingLevel?: ThinkingLevel;
|
|
93
100
|
}>;
|
|
94
101
|
availableToolNames: string[];
|
|
102
|
+
syncBacklog?: AdvisorSyncBacklog;
|
|
95
103
|
/** Formatted advisor-role model shown on the seeded default row (e.g. "anthropic/claude-..."). */
|
|
96
104
|
defaultModelLabel?: string;
|
|
97
105
|
}
|
|
@@ -88,6 +88,7 @@ export declare class AskDialogComponent implements Component {
|
|
|
88
88
|
#private;
|
|
89
89
|
private readonly callbacks;
|
|
90
90
|
private readonly options;
|
|
91
|
+
readonly retireDisplacedTranscript = true;
|
|
91
92
|
constructor(questions: ExtensionAskDialogQuestion[], callbacks: AskDialogCallbacks, options?: AskDialogOptions);
|
|
92
93
|
invalidate(): void;
|
|
93
94
|
dispose(): void;
|
|
@@ -62,7 +62,9 @@ export interface RuntimeChildrenOptions {
|
|
|
62
62
|
* multi-line editor). They are billed at their smallest height so expansion
|
|
63
63
|
* clips the live tail instead of retiring rows a later shrink could not
|
|
64
64
|
* reclaim (#11007). Every other root is billed at its current height, so
|
|
65
|
-
* settled rows it displaces retire to native scrollback.
|
|
65
|
+
* settled rows it displaces retire to native scrollback. Decision panels
|
|
66
|
+
* declaring `retireDisplacedTranscript` opt out of this floor even when
|
|
67
|
+
* mounted as a child of a transient editor container.
|
|
66
68
|
*/
|
|
67
69
|
readonly transient?: readonly Component[];
|
|
68
70
|
/**
|
|
@@ -168,6 +168,8 @@ export declare function describeContextUsage(breakdown: ContextBreakdown): Nativ
|
|
|
168
168
|
export declare class ContextUsageView extends Container {
|
|
169
169
|
#private;
|
|
170
170
|
constructor(breakdown: ContextBreakdown, theme: Theme);
|
|
171
|
+
/** Replace the shown breakdown in place, refreshing both the ANSI and native views. */
|
|
172
|
+
setBreakdown(breakdown: ContextBreakdown): void;
|
|
171
173
|
describe(cx: DescribeContext): NativeNode;
|
|
172
174
|
}
|
|
173
175
|
/**
|
|
@@ -61,6 +61,11 @@ export declare class TerminalInfo {
|
|
|
61
61
|
* is observed and tests can toggle `Bun.env.ZELLIJ` per case.
|
|
62
62
|
*/
|
|
63
63
|
export declare function isInsideZellij(env?: NodeJS.ProcessEnv): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Whether the agent process runs in an SSH session, so the terminal emulator
|
|
66
|
+
* is remote and host-local input/keyboard modes cannot be assumed.
|
|
67
|
+
*/
|
|
68
|
+
export declare function isSshSession(env?: NodeJS.ProcessEnv): boolean;
|
|
64
69
|
export declare function isNotificationSuppressed(): boolean;
|
|
65
70
|
/**
|
|
66
71
|
* Whether `PI_FORCE_IMAGE_PROTOCOL` pins the image protocol, including its
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -111,6 +111,9 @@ export interface Component {
|
|
|
111
111
|
* last time.
|
|
112
112
|
*/
|
|
113
113
|
render(width: number): readonly string[];
|
|
114
|
+
/** Inline decision panels must keep displaced settled transcript rows in
|
|
115
|
+
* native scrollback even when mounted inside a transient editor container. */
|
|
116
|
+
readonly retireDisplacedTranscript?: boolean;
|
|
114
117
|
/**
|
|
115
118
|
* Describe the component semantically for a Tern Surface Protocol
|
|
116
119
|
* terminal (see `native/node.ts`). Called instead of `render()` when the
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "18.4.
|
|
4
|
+
"version": "18.4.10",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": {
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"gen:glyphs": "bun scripts/gen-glyphs.ts"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@oh-my-pi/omptype": "18.4.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "18.4.
|
|
46
|
-
"@oh-my-pi/pi-ai": "18.4.
|
|
47
|
-
"@oh-my-pi/pi-catalog": "18.4.
|
|
48
|
-
"@oh-my-pi/pi-natives": "18.4.
|
|
49
|
-
"@oh-my-pi/pi-utils": "18.4.
|
|
50
|
-
"@oh-my-pi/pi-wire": "18.4.
|
|
51
|
-
"@oh-my-pi/snapcompact": "18.4.
|
|
44
|
+
"@oh-my-pi/omptype": "18.4.10",
|
|
45
|
+
"@oh-my-pi/pi-agent-core": "18.4.10",
|
|
46
|
+
"@oh-my-pi/pi-ai": "18.4.10",
|
|
47
|
+
"@oh-my-pi/pi-catalog": "18.4.10",
|
|
48
|
+
"@oh-my-pi/pi-natives": "18.4.10",
|
|
49
|
+
"@oh-my-pi/pi-utils": "18.4.10",
|
|
50
|
+
"@oh-my-pi/pi-wire": "18.4.10",
|
|
51
|
+
"@oh-my-pi/snapcompact": "18.4.10"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"kitty-vt-wasm": "^0.2.0"
|
|
@@ -110,12 +110,14 @@ function renderAdvisorNote(entry: AdvisorNote, width: number, uiTheme: Theme): s
|
|
|
110
110
|
// single ("default") advisor renders unlabeled, as before.
|
|
111
111
|
const who =
|
|
112
112
|
entry.advisor && entry.advisor !== "default" ? `${uiTheme.fg("dim", `[${replaceTabs(entry.advisor)}]`)} ` : "";
|
|
113
|
+
const age = entry.turnsAgo !== undefined ? `${uiTheme.fg("dim", `T-${entry.turnsAgo}`)} ` : "";
|
|
113
114
|
const railGlyph = uiTheme.symbol("advisor.rail");
|
|
114
115
|
const rail = uiTheme.fg(severityColor(entry.severity), railGlyph);
|
|
115
116
|
const quoteWidth = visibleWidth(` ${railGlyph} `);
|
|
116
117
|
const badgeWidth = visibleWidth(badge);
|
|
118
|
+
const ageWidth = visibleWidth(age);
|
|
117
119
|
const whoWidth = visibleWidth(who);
|
|
118
|
-
const w1 = Math.max(10, Math.min(NOTE_LINE_WIDTH, width) - quoteWidth - badgeWidth - whoWidth);
|
|
120
|
+
const w1 = Math.max(10, Math.min(NOTE_LINE_WIDTH, width) - quoteWidth - badgeWidth - ageWidth - whoWidth);
|
|
119
121
|
const w2 = Math.max(10, Math.min(NOTE_LINE_WIDTH, width) - quoteWidth);
|
|
120
122
|
|
|
121
123
|
const paragraphs = entry.note.split("\n").filter(p => p.trim());
|
|
@@ -131,7 +133,7 @@ function renderAdvisorNote(entry: AdvisorNote, width: number, uiTheme: Theme): s
|
|
|
131
133
|
|
|
132
134
|
return bodyLines.map(
|
|
133
135
|
(line, index) =>
|
|
134
|
-
` ${rail} ${index === 0 ? `${badge}${who}` : ""}${uiTheme.fg("customMessageText", replaceTabs(line))}`,
|
|
136
|
+
` ${rail} ${index === 0 ? `${badge}${age}${who}` : ""}${uiTheme.fg("customMessageText", replaceTabs(line))}`,
|
|
135
137
|
);
|
|
136
138
|
}
|
|
137
139
|
|
|
@@ -710,9 +710,10 @@ export class AssistantMessageComponent extends Container {
|
|
|
710
710
|
* A `col` (role `omp.assistant`) of `md` nodes keyed by content index, so
|
|
711
711
|
* streamed deltas reach the terminal as `text append` on the same node;
|
|
712
712
|
* the tail block carries `stream: true` until the message finalizes.
|
|
713
|
-
* Thinking blocks are quiet collapsible `section`s
|
|
714
|
-
*
|
|
715
|
-
*
|
|
713
|
+
* Thinking blocks are quiet collapsible `section`s: a muted "Thinking…"
|
|
714
|
+
* shimmer while live, then "Thought for 12s"; tokens and rate ride in the
|
|
715
|
+
* head's `title`. With `hideThinkingBlock` only the live head shows (collapsed)
|
|
716
|
+
* and a finished thought emits nothing.
|
|
716
717
|
*/
|
|
717
718
|
override describe(): NativeNode {
|
|
718
719
|
const tail = this.#displayedMessage ? this.#thinkingTailIndex(this.#displayedMessage) : undefined;
|
|
@@ -804,6 +805,8 @@ export class AssistantMessageComponent extends Container {
|
|
|
804
805
|
} else if (clock && clock.end === undefined) {
|
|
805
806
|
clock.end = performance.now();
|
|
806
807
|
}
|
|
808
|
+
// Hidden (Ctrl+T): only the live "Thinking…" head shows; a finished thought leaves nothing.
|
|
809
|
+
if (this.#hideThinkingBlock && !thinkingLive) continue;
|
|
807
810
|
const tokens = thinkingLive ? this.#thinkingTokens : (clock?.tokens ?? 0);
|
|
808
811
|
const title = tokens > 0 ? `${formatNumber(tokens)} tokens` : undefined;
|
|
809
812
|
// Live: starburst · "Thinking…" · ticking timer · tok/s. Done: "Thought for 12s".
|
|
@@ -826,12 +829,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
826
829
|
"section",
|
|
827
830
|
{
|
|
828
831
|
// `.live` while streaming: the body clamps to its tail under a fade.
|
|
829
|
-
|
|
830
|
-
role: thinkingLive
|
|
831
|
-
? "omp.thinking.live"
|
|
832
|
-
: this.#hideThinkingBlock
|
|
833
|
-
? "omp.thinking.ghost"
|
|
834
|
-
: "omp.thinking",
|
|
832
|
+
role: thinkingLive ? "omp.thinking.live" : "omp.thinking",
|
|
835
833
|
collapsible: true,
|
|
836
834
|
// Open while it streams; a finished thought folds to its "Thought for 12s" line.
|
|
837
835
|
collapsed: this.#thinkingCollapsed.get(index) ?? (this.#hideThinkingBlock || !thinkingLive),
|
|
@@ -205,7 +205,7 @@ export class ChatTranscriptBuilder {
|
|
|
205
205
|
const previous = this.#waitingPoll;
|
|
206
206
|
if (!previous) return;
|
|
207
207
|
this.#waitingPoll = null;
|
|
208
|
-
if (nextToolName === "wait" && previous.isDisplaceableBlock() && this.container.
|
|
208
|
+
if (nextToolName === "wait" && previous.isDisplaceableBlock() && this.container.canDisplaceBlock(previous)) {
|
|
209
209
|
this.container.removeChild(previous);
|
|
210
210
|
}
|
|
211
211
|
previous.seal();
|
package/src/chat/messages.ts
CHANGED
|
@@ -294,6 +294,10 @@ export interface AdvisorNote {
|
|
|
294
294
|
severity?: AdvisorSeverity;
|
|
295
295
|
/** Which configured advisor produced this note (omitted for the default advisor). */
|
|
296
296
|
advisor?: string;
|
|
297
|
+
/** Primary turns completed when note was emitted. */
|
|
298
|
+
turn?: number;
|
|
299
|
+
/** Primary turns elapsed before merged-batch delivery. */
|
|
300
|
+
turnsAgo?: number;
|
|
297
301
|
}
|
|
298
302
|
|
|
299
303
|
/** Details payload on the batched `advisor` custom message rendered in the transcript. */
|
|
@@ -4,6 +4,7 @@ import { popLoopPhase, pushLoopPhase } from "@oh-my-pi/pi-utils";
|
|
|
4
4
|
import { col } from "../native/describe";
|
|
5
5
|
import type { NativeNode } from "../native/node";
|
|
6
6
|
import { isNativeSettled, settleNative } from "../native/settle";
|
|
7
|
+
import { isUsageRowBlock } from "../overlays/usage-row";
|
|
7
8
|
import { isToolActivityComponent } from "./tool-activity";
|
|
8
9
|
|
|
9
10
|
/** Shared animation time supplied by the constrained transcript root. */
|
|
@@ -291,6 +292,18 @@ export class TranscriptContainer extends Container {
|
|
|
291
292
|
return true;
|
|
292
293
|
}
|
|
293
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Whether a superseded snapshot block (a repeated `wait` poll) may be
|
|
297
|
+
* replaced by its successor. Beyond {@link canRemoveBlock}, a block directly
|
|
298
|
+
* above a usage row stays: that row describes the turn the block belongs to,
|
|
299
|
+
* and removing the block would leave a usage-only turn (#12248).
|
|
300
|
+
*/
|
|
301
|
+
canDisplaceBlock(component: Component): boolean {
|
|
302
|
+
if (!this.canRemoveBlock(component)) return false;
|
|
303
|
+
const next = this.children[this.children.indexOf(component) + 1];
|
|
304
|
+
return next === undefined || !isUsageRowBlock(next);
|
|
305
|
+
}
|
|
306
|
+
|
|
294
307
|
/** Lifecycle state per block in transcript order (diagnostics and tests). */
|
|
295
308
|
blockStates(): readonly BlockState[] {
|
|
296
309
|
this.#syncEntries();
|
|
@@ -3,11 +3,11 @@ import {
|
|
|
3
3
|
Lexer,
|
|
4
4
|
Marked,
|
|
5
5
|
type Token,
|
|
6
|
-
Tokenizer,
|
|
7
6
|
type TokenizerAndRendererExtension,
|
|
7
|
+
type TokenizerThis,
|
|
8
8
|
type Tokens,
|
|
9
9
|
} from "@oh-my-pi/pi-utils/marked";
|
|
10
|
-
import { mathBlockAt,
|
|
10
|
+
import { mathBlockAt, mathSpanInContext, mathStartIndex } from "@oh-my-pi/pi-utils/math-delimiters";
|
|
11
11
|
import { latexToBlock } from "../latex-block";
|
|
12
12
|
import { isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
|
|
13
13
|
import { plainText } from "../native/spans";
|
|
@@ -30,8 +30,6 @@ import {
|
|
|
30
30
|
wrapTextWithAnsi,
|
|
31
31
|
} from "../utils";
|
|
32
32
|
|
|
33
|
-
const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
|
|
34
|
-
|
|
35
33
|
// Marked treats the backslash in an ST-terminated OSC 8 sequence (`ESC \\`) as
|
|
36
34
|
// Markdown punctuation when it is immediately followed by markup such as a
|
|
37
35
|
// codespan backtick. Normalize well-formed OSC 8 prefixes to the equivalent BEL
|
|
@@ -506,27 +504,85 @@ function hangWrapTreeGuideLines(text: string, width: number): string[] | undefin
|
|
|
506
504
|
return out;
|
|
507
505
|
}
|
|
508
506
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
|
|
512
|
-
if (!match) {
|
|
513
|
-
return undefined;
|
|
514
|
-
}
|
|
507
|
+
const WHITESPACE = /\s/;
|
|
508
|
+
const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
|
|
515
509
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
510
|
+
/**
|
|
511
|
+
* GFM strikethrough as upstream marked's rule reads it, with `~~` only: an opener not followed by whitespace or `~`,
|
|
512
|
+
* text read in units of one character or a backslash and the character after it (not a line break), and the first
|
|
513
|
+
* `~~` that follows a unit other than whitespace, `~` or a lone `\` and is not followed by another `~`. One scanner
|
|
514
|
+
* serves one inline source and the link labels and emphasis inside it. A scan also answers for every opener it
|
|
515
|
+
* passed, which meets the same closer or none, so a line of unclosed openers is read once, not once per opener.
|
|
516
|
+
*/
|
|
517
|
+
class StrikethroughScanner {
|
|
518
|
+
readonly #source: string;
|
|
519
|
+
// The last opener whose scan ran, where that scan stopped, and whether a closer stopped it there.
|
|
520
|
+
#open = -1;
|
|
521
|
+
#stop = -1;
|
|
522
|
+
#closed = false;
|
|
523
|
+
|
|
524
|
+
constructor(source: string) {
|
|
525
|
+
this.#source = source;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** The offset of the `~~` closing the strikethrough opened at `open`, or -1. */
|
|
529
|
+
closeAt(open: number): number {
|
|
530
|
+
const src = this.#source;
|
|
531
|
+
const first = src[open + 2];
|
|
532
|
+
if (!src.startsWith("~~", open) || first === undefined || first === "~" || WHITESPACE.test(first)) return -1;
|
|
533
|
+
// Past the opener's own "~~" its units are the last scan's, so they meet the same closer or stop.
|
|
534
|
+
if (this.#open < open && open < this.#stop) return this.#closed ? this.#stop : -1;
|
|
535
|
+
let at = open + 2;
|
|
536
|
+
let close = -1;
|
|
537
|
+
while (at < src.length) {
|
|
538
|
+
let end: number;
|
|
539
|
+
let final: boolean;
|
|
540
|
+
if (src.charCodeAt(at) === 0x5c /* \ */) {
|
|
541
|
+
if (at + 1 === src.length || LINE_TERMINATOR.test(src[at + 1]!)) break;
|
|
542
|
+
end = at + 2;
|
|
543
|
+
final = true;
|
|
544
|
+
} else {
|
|
545
|
+
end = at + 1;
|
|
546
|
+
final = src[at] !== "~" && !WHITESPACE.test(src[at]!);
|
|
547
|
+
}
|
|
548
|
+
if (final && src.startsWith("~~", end) && src[end + 2] !== "~") {
|
|
549
|
+
close = end;
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
at = end;
|
|
553
|
+
}
|
|
554
|
+
this.#open = open;
|
|
555
|
+
this.#stop = close === -1 ? at : close;
|
|
556
|
+
this.#closed = close !== -1;
|
|
557
|
+
return close;
|
|
523
558
|
}
|
|
524
559
|
}
|
|
525
560
|
|
|
561
|
+
const strikethroughScanners = new WeakMap<TokenizerThis, StrikethroughScanner>();
|
|
562
|
+
|
|
563
|
+
// Registered after every other inline extension: at a `~~` no built-in rule before marked's `del` can match, so this
|
|
564
|
+
// gives the tokens a `del` override would, and the `del` override below keeps marked's own looser rule out.
|
|
565
|
+
const strikethroughExtension: TokenizerAndRendererExtension = {
|
|
566
|
+
name: "strictStrikethrough",
|
|
567
|
+
level: "inline",
|
|
568
|
+
// No start hint: inline text already stops at every `~`.
|
|
569
|
+
tokenizer(src) {
|
|
570
|
+
if (!src.startsWith("~~")) return undefined;
|
|
571
|
+
const source = this.source ?? src;
|
|
572
|
+
// Inside a link label or emphasis the text ends at `end`, before its closer (`]`, `*` or `_`), which is not a
|
|
573
|
+
// `~`, so a closer that ends there closes as it would at the end of the text.
|
|
574
|
+
const end = this.end ?? source.length;
|
|
575
|
+
let scanner = strikethroughScanners.get(this);
|
|
576
|
+
if (!scanner) strikethroughScanners.set(this, (scanner = new StrikethroughScanner(source)));
|
|
577
|
+
const open = end - src.length;
|
|
578
|
+
const close = scanner.closeAt(open);
|
|
579
|
+
if (close === -1 || close + 2 > end) return undefined;
|
|
580
|
+
const text = source.slice(open + 2, close);
|
|
581
|
+
return { type: "del", raw: source.slice(open, close + 2), text, tokens: this.lexer.inlineTokens(text) };
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
|
|
526
585
|
const markdownParser = new Marked();
|
|
527
|
-
markdownParser.setOptions({
|
|
528
|
-
tokenizer: new StrictStrikethroughTokenizer(),
|
|
529
|
-
});
|
|
530
586
|
|
|
531
587
|
// Math spans (`$$…$$`, `\[…\]`, `$…$`, `\(…\)`) are tokenized as a dedicated
|
|
532
588
|
// `math` inline token before markdown's escape/emphasis/link rules run, so
|
|
@@ -590,9 +646,9 @@ const customHrExtension: TokenizerAndRendererExtension = {
|
|
|
590
646
|
const mathExtension: TokenizerAndRendererExtension = {
|
|
591
647
|
name: "math",
|
|
592
648
|
level: "inline",
|
|
593
|
-
|
|
649
|
+
startFrom: mathStartIndex,
|
|
594
650
|
tokenizer(src) {
|
|
595
|
-
const span =
|
|
651
|
+
const span = mathSpanInContext(this, src);
|
|
596
652
|
if (!span) return undefined;
|
|
597
653
|
return { type: "math", raw: src.slice(0, span.end), text: span.body, display: span.display };
|
|
598
654
|
},
|
|
@@ -676,64 +732,12 @@ const mathEnvBlockExtension: TokenizerAndRendererExtension = {
|
|
|
676
732
|
// tokenizer at a valid start. Candidates at a legal boundary fall through
|
|
677
733
|
// (return undefined) to marked's own autolink handling unchanged.
|
|
678
734
|
const AUTOLINK_SCHEME_REGEX = /^(?:www\.|https?:\/\/|ftp:\/\/)/i;
|
|
679
|
-
// Case-insensitive scheme scan replacing /www\.|https?:\/\/|ftp:\/\//i in
|
|
680
|
-
// boundedAutolinkExtension.start — like `mathStartIndex`, this runs on the
|
|
681
|
-
// remaining source at every inline position (part of a ~4.3% CPU start() scan
|
|
682
|
-
// tail in profiles). charCode-only: no allocation, no toLowerCase copies.
|
|
683
|
-
// `| 32` lower-cases ASCII letters; `.`/`:`/`/` are compared exactly, matching
|
|
684
|
-
// the regex's ASCII-only `i` semantics. charCodeAt past the end returns NaN,
|
|
685
|
-
// which fails every comparison, so no explicit bounds checks are needed.
|
|
686
|
-
function isAutolinkSchemeAt(src: string, i: number): boolean {
|
|
687
|
-
const c = src.charCodeAt(i) | 32;
|
|
688
|
-
if (c === 119 /* w */) {
|
|
689
|
-
// www.
|
|
690
|
-
return (
|
|
691
|
-
(src.charCodeAt(i + 1) | 32) === 119 &&
|
|
692
|
-
(src.charCodeAt(i + 2) | 32) === 119 &&
|
|
693
|
-
src.charCodeAt(i + 3) === 46 /* . */
|
|
694
|
-
);
|
|
695
|
-
}
|
|
696
|
-
if (c === 104 /* h */) {
|
|
697
|
-
// http:// | https://
|
|
698
|
-
if (
|
|
699
|
-
(src.charCodeAt(i + 1) | 32) !== 116 /* t */ ||
|
|
700
|
-
(src.charCodeAt(i + 2) | 32) !== 116 /* t */ ||
|
|
701
|
-
(src.charCodeAt(i + 3) | 32) !== 112 /* p */
|
|
702
|
-
) {
|
|
703
|
-
return false;
|
|
704
|
-
}
|
|
705
|
-
let j = i + 4;
|
|
706
|
-
if ((src.charCodeAt(j) | 32) === 115 /* s */) j++;
|
|
707
|
-
return src.charCodeAt(j) === 58 /* : */ && src.charCodeAt(j + 1) === 47 /* / */ && src.charCodeAt(j + 2) === 47;
|
|
708
|
-
}
|
|
709
|
-
if (c === 102 /* f */) {
|
|
710
|
-
// ftp://
|
|
711
|
-
return (
|
|
712
|
-
(src.charCodeAt(i + 1) | 32) === 116 /* t */ &&
|
|
713
|
-
(src.charCodeAt(i + 2) | 32) === 112 /* p */ &&
|
|
714
|
-
src.charCodeAt(i + 3) === 58 /* : */ &&
|
|
715
|
-
src.charCodeAt(i + 4) === 47 /* / */ &&
|
|
716
|
-
src.charCodeAt(i + 5) === 47 /* / */
|
|
717
|
-
);
|
|
718
|
-
}
|
|
719
|
-
return false;
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
/** @internal exported for tests — must stay index-identical to the old regex scan. */
|
|
723
|
-
export function autolinkSchemeScanIndex(src: string): number | undefined {
|
|
724
|
-
for (let i = 0; i < src.length; i++) {
|
|
725
|
-
const c = src.charCodeAt(i) | 32;
|
|
726
|
-
if ((c === 119 || c === 104 || c === 102) && isAutolinkSchemeAt(src, i)) return i;
|
|
727
|
-
}
|
|
728
|
-
return undefined;
|
|
729
|
-
}
|
|
730
735
|
const VALID_AUTOLINK_LEFT_BOUNDARY = /[\s*_~(]/;
|
|
731
736
|
const boundedAutolinkExtension: TokenizerAndRendererExtension = {
|
|
732
737
|
name: "boundedAutolink",
|
|
733
738
|
level: "inline",
|
|
734
|
-
start
|
|
735
|
-
|
|
736
|
-
},
|
|
739
|
+
// No start hint: inline text already stops at every `www.`, `http://`, `https://` and `ftp://`, the only places
|
|
740
|
+
// this tokenizer matches.
|
|
737
741
|
tokenizer(src, tokens) {
|
|
738
742
|
const match = AUTOLINK_SCHEME_REGEX.exec(src);
|
|
739
743
|
if (!match) return undefined;
|
|
@@ -747,59 +751,16 @@ const boundedAutolinkExtension: TokenizerAndRendererExtension = {
|
|
|
747
751
|
},
|
|
748
752
|
};
|
|
749
753
|
markdownParser.use({
|
|
750
|
-
extensions: [
|
|
754
|
+
extensions: [
|
|
755
|
+
customHrExtension,
|
|
756
|
+
mathBlockExtension,
|
|
757
|
+
mathEnvBlockExtension,
|
|
758
|
+
mathExtension,
|
|
759
|
+
boundedAutolinkExtension,
|
|
760
|
+
strikethroughExtension,
|
|
761
|
+
],
|
|
751
762
|
});
|
|
752
763
|
|
|
753
|
-
// ---------------------------------------------------------------------------
|
|
754
|
-
// GFM `url` tokenizer gate
|
|
755
|
-
// ---------------------------------------------------------------------------
|
|
756
|
-
// marked tries the bundled GFM `url` tokenizer at every inline tokenization
|
|
757
|
-
// step, and its regex is expensive to FAIL: the email alternative
|
|
758
|
-
// `^[A-Za-z0-9._+-]+(@)…` linearly consumes an identifier run, then backtracks
|
|
759
|
-
// it one character at a time when no `@` follows. A 71414-sample / 1ms CPU
|
|
760
|
-
// profile of the TUI put 73.3% of total CPU (74.9s of a 102s capture) inside
|
|
761
|
-
// this single regex. The override below runs an O(bounded) charCode gate first
|
|
762
|
-
// and only falls through to the built-in tokenizer — by returning `false`,
|
|
763
|
-
// marked's tokenizer-override fallback contract — when a match is possible.
|
|
764
|
-
//
|
|
765
|
-
// Conservativeness argument. The built-in rule (no flags) is
|
|
766
|
-
// /^((?:[hH][tT][tT][pP][sS]?|[fF][tT][pP]):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*
|
|
767
|
-
// |^[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/
|
|
768
|
-
// Both alternatives are anchored, so any match constrains the head of src:
|
|
769
|
-
// • Branch 1 requires src to start with `http://`, `https://`, `ftp://`
|
|
770
|
-
// (scheme letters in any case) or lowercase `www.`. The gate accepts all of
|
|
771
|
-
// these via isAutolinkSchemeAt(src, 0); it also over-accepts `WWW.`, a
|
|
772
|
-
// harmless false positive (the built-in regex simply fails to match).
|
|
773
|
-
// • Branch 2 requires src to start with one-or-more chars from
|
|
774
|
-
// `[A-Za-z0-9._+-]` immediately followed by `@`. The gate scans that exact
|
|
775
|
-
// class: if the run ends within URL_GATE_EMAIL_SCAN_LIMIT chars it accepts
|
|
776
|
-
// iff the terminator is `@`; a run reaching the limit is accepted
|
|
777
|
-
// unconditionally. Every src branch 2 can match is therefore accepted —
|
|
778
|
-
// the gate never rejects a src the built-in regex would match.
|
|
779
|
-
const URL_GATE_EMAIL_SCAN_LIMIT = 320;
|
|
780
|
-
|
|
781
|
-
/** @internal exported for tests — must never return false for a src the built-in url regex matches. */
|
|
782
|
-
export function urlTokenPossible(src: string): boolean {
|
|
783
|
-
if (isAutolinkSchemeAt(src, 0)) return true;
|
|
784
|
-
let i = 0;
|
|
785
|
-
while (i < URL_GATE_EMAIL_SCAN_LIMIT) {
|
|
786
|
-
const c = src.charCodeAt(i);
|
|
787
|
-
const isLocalChar =
|
|
788
|
-
(c >= 97 && c <= 122) /* a-z */ ||
|
|
789
|
-
(c >= 65 && c <= 90) /* A-Z */ ||
|
|
790
|
-
(c >= 48 && c <= 57) /* 0-9 */ ||
|
|
791
|
-
c === 46 /* . */ ||
|
|
792
|
-
c === 95 /* _ */ ||
|
|
793
|
-
c === 43 /* + */ ||
|
|
794
|
-
c === 45; /* - */
|
|
795
|
-
if (!isLocalChar) break;
|
|
796
|
-
i++;
|
|
797
|
-
}
|
|
798
|
-
if (i === 0) return false;
|
|
799
|
-
if (i >= URL_GATE_EMAIL_SCAN_LIMIT) return true; // over-long run: give up conservatively
|
|
800
|
-
return src.charCodeAt(i) === 64; /* @ */
|
|
801
|
-
}
|
|
802
|
-
|
|
803
764
|
// Setext-underline pre-gate for marked's `lheading` rule. The rule's lazy body
|
|
804
765
|
// `((?:.|\n(?!<block-start>))+?)` re-runs its block-start lookahead while
|
|
805
766
|
// expanding character by character, so even a FAILING attempt at offset 0
|
|
@@ -826,12 +787,11 @@ markdownParser.use({
|
|
|
826
787
|
tokenizer: {
|
|
827
788
|
// `false` → marked falls back to the built-in tokenizer;
|
|
828
789
|
// `undefined` → no token here, built-in never runs.
|
|
829
|
-
url(src: string): Tokens.Link | undefined | false {
|
|
830
|
-
return urlTokenPossible(src) ? false : undefined;
|
|
831
|
-
},
|
|
832
790
|
lheading(src: string): Tokens.Heading | undefined | false {
|
|
833
791
|
return lheadingPossible(src) ? false : undefined;
|
|
834
792
|
},
|
|
793
|
+
// Strikethrough is `strikethroughExtension`'s: marked's own looser rule never runs.
|
|
794
|
+
del: () => undefined,
|
|
835
795
|
},
|
|
836
796
|
});
|
|
837
797
|
|
package/src/keys.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
parseKey as parseKeyNative,
|
|
25
25
|
parseKittySequence as parseKittySequenceNative,
|
|
26
26
|
} from "@oh-my-pi/pi-natives";
|
|
27
|
-
import { isInsideTerminalMultiplexer } from "./terminal-capabilities";
|
|
27
|
+
import { isInsideTerminalMultiplexer, isSshSession } from "./terminal-capabilities";
|
|
28
28
|
|
|
29
29
|
// =============================================================================
|
|
30
30
|
// Platform Detection
|
|
@@ -32,9 +32,7 @@ import { isInsideTerminalMultiplexer } from "./terminal-capabilities";
|
|
|
32
32
|
|
|
33
33
|
/** Whether the local process is running directly under Windows Terminal. */
|
|
34
34
|
export function isWindowsTerminalSession(): boolean {
|
|
35
|
-
return (
|
|
36
|
-
Boolean(process.env.WT_SESSION) && !process.env.SSH_CONNECTION && !process.env.SSH_CLIENT && !process.env.SSH_TTY
|
|
37
|
-
);
|
|
35
|
+
return Boolean(process.env.WT_SESSION) && !isSshSession();
|
|
38
36
|
}
|
|
39
37
|
|
|
40
38
|
/**
|
package/src/native/encode.ts
CHANGED
|
@@ -169,6 +169,8 @@ const EVENT_REQUIRED: Readonly<Record<string, Readonly<Record<string, "string" |
|
|
|
169
169
|
select: { id: "string", item: "string" },
|
|
170
170
|
activate: { id: "string", item: "string" },
|
|
171
171
|
action: { id: "string", act: "string" },
|
|
172
|
+
// `value` varies by control (boolean, number, string, string[], null): the handler checks it.
|
|
173
|
+
change: { id: "string", item: "string" },
|
|
172
174
|
edit: { id: "string", from: "number", to: "number", text: "string", cursor: "number", len: "number" },
|
|
173
175
|
focus: { id: "string" },
|
|
174
176
|
error: { msg: "string" },
|