@oh-my-pi/pi-coding-agent 15.10.8 → 15.10.9
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 +15 -1
- package/dist/types/extensibility/custom-tools/loader.d.ts +22 -3
- package/dist/types/extensibility/extensions/index.d.ts +1 -1
- package/dist/types/extensibility/extensions/loader.d.ts +17 -1
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
- package/dist/types/mcp/transports/stdio.d.ts +12 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -2
- package/dist/types/sdk.d.ts +42 -2
- package/dist/types/task/executor.d.ts +16 -0
- package/dist/types/tools/index.d.ts +17 -0
- package/dist/types/tui/hyperlink.d.ts +8 -0
- package/package.json +9 -9
- package/src/extensibility/custom-tools/loader.ts +43 -19
- package/src/extensibility/extensions/index.ts +1 -0
- package/src/extensibility/extensions/loader.ts +29 -6
- package/src/extensibility/plugins/legacy-pi-compat.ts +30 -6
- package/src/mcp/transports/stdio.ts +139 -3
- package/src/modes/components/custom-editor.ts +69 -9
- package/src/modes/components/transcript-container.ts +77 -25
- package/src/modes/controllers/input-controller.ts +1 -1
- package/src/modes/controllers/mcp-command-controller.ts +2 -2
- package/src/sdk.ts +138 -56
- package/src/ssh/ssh-executor.ts +60 -4
- package/src/task/executor.ts +19 -0
- package/src/task/index.ts +4 -0
- package/src/tools/index.ts +17 -0
- package/src/tui/hyperlink.ts +27 -3
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* Messages are newline-delimited JSON.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import * as fs from "node:fs/promises";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
|
|
8
11
|
import { getProjectDir, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
|
|
9
12
|
import { type Subprocess, spawn } from "bun";
|
|
10
13
|
import type {
|
|
@@ -19,6 +22,134 @@ import type {
|
|
|
19
22
|
import { toJsonRpcError } from "../../mcp/types";
|
|
20
23
|
import { isMCPTimeoutEnabled, resolveMCPTimeoutMs } from "../timeout";
|
|
21
24
|
|
|
25
|
+
/** Subprocess argv for launching an MCP stdio server. */
|
|
26
|
+
export interface StdioSpawnCommand {
|
|
27
|
+
cmd: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Inputs used to resolve platform-specific stdio spawn behavior. */
|
|
31
|
+
export interface ResolveStdioSpawnOptions {
|
|
32
|
+
cwd: string;
|
|
33
|
+
env: Record<string, string | undefined>;
|
|
34
|
+
platform?: NodeJS.Platform;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_WINDOWS_PATHEXT = [".COM", ".EXE", ".BAT", ".CMD"];
|
|
38
|
+
const WINDOWS_BATCH_EXTENSIONS = new Set([".bat", ".cmd"]);
|
|
39
|
+
|
|
40
|
+
function getCaseInsensitiveEnv(env: Record<string, string | undefined>, name: string): string | undefined {
|
|
41
|
+
const direct = env[name];
|
|
42
|
+
if (direct !== undefined) return direct;
|
|
43
|
+
const normalized = name.toLowerCase();
|
|
44
|
+
for (const [key, value] of Object.entries(env)) {
|
|
45
|
+
if (key.toLowerCase() === normalized) return value;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getWindowsPathExt(env: Record<string, string | undefined>): string[] {
|
|
51
|
+
const raw = getCaseInsensitiveEnv(env, "PATHEXT");
|
|
52
|
+
if (!raw) return DEFAULT_WINDOWS_PATHEXT;
|
|
53
|
+
const extensions: string[] = [];
|
|
54
|
+
for (const part of raw.split(";")) {
|
|
55
|
+
const trimmed = part.trim();
|
|
56
|
+
if (!trimmed) continue;
|
|
57
|
+
extensions.push(trimmed.startsWith(".") ? trimmed : `.${trimmed}`);
|
|
58
|
+
}
|
|
59
|
+
return extensions.length > 0 ? extensions : DEFAULT_WINDOWS_PATHEXT;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
63
|
+
try {
|
|
64
|
+
await fs.access(filePath);
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hasPathSegment(command: string): boolean {
|
|
72
|
+
return command.includes("/") || command.includes("\\") || path.isAbsolute(command);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hasExecutableExtension(command: string, extensions: string[]): boolean {
|
|
76
|
+
const ext = path.extname(command).toLowerCase();
|
|
77
|
+
if (!ext) return false;
|
|
78
|
+
return extensions.some(candidate => candidate.toLowerCase() === ext);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function resolveWindowsCommandPath(
|
|
82
|
+
command: string,
|
|
83
|
+
cwd: string,
|
|
84
|
+
env: Record<string, string | undefined>,
|
|
85
|
+
): Promise<string | null> {
|
|
86
|
+
const extensions = getWindowsPathExt(env);
|
|
87
|
+
if (hasExecutableExtension(command, extensions)) return command;
|
|
88
|
+
|
|
89
|
+
const candidates = extensions.map(ext => `${command}${ext}`);
|
|
90
|
+
if (hasPathSegment(command)) {
|
|
91
|
+
for (const candidate of candidates) {
|
|
92
|
+
const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
|
|
93
|
+
if (await fileExists(resolved)) return resolved;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const pathValue = getCaseInsensitiveEnv(env, "PATH");
|
|
99
|
+
if (!pathValue) return null;
|
|
100
|
+
for (const dir of pathValue.split(";")) {
|
|
101
|
+
if (!dir) continue;
|
|
102
|
+
for (const candidate of candidates) {
|
|
103
|
+
const resolved = path.join(dir, candidate);
|
|
104
|
+
if (await fileExists(resolved)) return resolved;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function quoteCmdArg(value: string): string {
|
|
111
|
+
if (value.length === 0) return '""';
|
|
112
|
+
let result = '"';
|
|
113
|
+
for (const char of value) {
|
|
114
|
+
if (char === '"') {
|
|
115
|
+
result += '^"';
|
|
116
|
+
} else if (char === "^") {
|
|
117
|
+
result += "^^";
|
|
118
|
+
} else if (char === "%") {
|
|
119
|
+
result += "^%";
|
|
120
|
+
} else {
|
|
121
|
+
result += char;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return `${result}"`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isWindowsBatchCommand(command: string): boolean {
|
|
128
|
+
return WINDOWS_BATCH_EXTENSIONS.has(path.extname(command).toLowerCase());
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function resolveComSpec(env: Record<string, string | undefined>): string {
|
|
132
|
+
const comspec = getCaseInsensitiveEnv(env, "COMSPEC");
|
|
133
|
+
return comspec && comspec.length > 0 ? comspec : "cmd.exe";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Resolve the subprocess argv used to launch an MCP stdio server. */
|
|
137
|
+
export async function resolveStdioSpawnCommand(
|
|
138
|
+
config: MCPStdioServerConfig,
|
|
139
|
+
options: ResolveStdioSpawnOptions,
|
|
140
|
+
): Promise<StdioSpawnCommand> {
|
|
141
|
+
const args = config.args ?? [];
|
|
142
|
+
if (options.platform !== "win32") return { cmd: [config.command, ...args] };
|
|
143
|
+
|
|
144
|
+
const resolvedCommand =
|
|
145
|
+
(await resolveWindowsCommandPath(config.command, options.cwd, options.env)) ?? config.command;
|
|
146
|
+
if (!isWindowsBatchCommand(resolvedCommand)) return { cmd: [resolvedCommand, ...args] };
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
cmd: [resolveComSpec(options.env), "/d", "/s", "/c", [resolvedCommand, ...args].map(quoteCmdArg).join(" ")],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
22
153
|
/** Minimal write surface of `Subprocess.stdin` we need for framed sends. */
|
|
23
154
|
interface FrameSink {
|
|
24
155
|
write(chunk: string): unknown;
|
|
@@ -100,15 +231,20 @@ export class StdioTransport implements MCPTransport {
|
|
|
100
231
|
async connect(): Promise<void> {
|
|
101
232
|
if (this.#connected) return;
|
|
102
233
|
|
|
103
|
-
const args = this.config.args ?? [];
|
|
104
234
|
const env = {
|
|
105
235
|
...Bun.env,
|
|
106
236
|
...this.config.env,
|
|
107
237
|
};
|
|
238
|
+
const cwd = this.config.cwd ?? getProjectDir();
|
|
239
|
+
const spawnCommand = await resolveStdioSpawnCommand(this.config, {
|
|
240
|
+
cwd,
|
|
241
|
+
env,
|
|
242
|
+
platform: process.platform,
|
|
243
|
+
});
|
|
108
244
|
|
|
109
245
|
this.#process = spawn({
|
|
110
|
-
cmd:
|
|
111
|
-
cwd
|
|
246
|
+
cmd: spawnCommand.cmd,
|
|
247
|
+
cwd,
|
|
112
248
|
env,
|
|
113
249
|
stdin: "pipe",
|
|
114
250
|
stdout: "pipe",
|
|
@@ -58,16 +58,72 @@ function buildMatchKeys(keys: readonly KeyId[]): Set<string> {
|
|
|
58
58
|
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
59
59
|
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
60
60
|
const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
|
|
61
|
+
const BRACKETED_IMAGE_PATH_BOUNDARY_REGEX = /\.(?:png|jpe?g|gif|webp)(?=$|["']?\s)/gi;
|
|
62
|
+
const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g;
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
function isPastedPathSeparator(char: string | undefined): boolean {
|
|
65
|
+
return char === undefined || char === " " || char === "\t" || char === "\r" || char === "\n";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function imagePathBoundaryEnd(payload: string, segmentStart: number, extensionEnd: number): number | undefined {
|
|
69
|
+
const quote = payload[segmentStart];
|
|
70
|
+
const afterExtension = payload[extensionEnd];
|
|
71
|
+
if (quote === '"' || quote === "'") {
|
|
72
|
+
return afterExtension === quote && isPastedPathSeparator(payload[extensionEnd + 1])
|
|
73
|
+
? extensionEnd + 1
|
|
74
|
+
: undefined;
|
|
75
|
+
}
|
|
76
|
+
if (isPastedPathSeparator(afterExtension)) return extensionEnd;
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizePastedImagePath(path: string): string {
|
|
81
|
+
const trimmed = path.trim();
|
|
82
|
+
const first = trimmed[0];
|
|
83
|
+
const last = trimmed[trimmed.length - 1];
|
|
84
|
+
const unquoted =
|
|
85
|
+
trimmed.length > 1 && (first === '"' || first === "'") && last === first ? trimmed.slice(1, -1) : trimmed;
|
|
86
|
+
return unquoted.replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function extractBracketedImagePastePaths(data: string): string[] | undefined {
|
|
63
90
|
if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
|
|
64
91
|
const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
|
|
65
92
|
if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
|
|
66
93
|
|
|
67
94
|
const pasted = data.slice(BRACKETED_PASTE_START.length, endIndex).trim();
|
|
68
|
-
if (!pasted
|
|
69
|
-
|
|
70
|
-
|
|
95
|
+
if (!pasted) return undefined;
|
|
96
|
+
|
|
97
|
+
const paths: string[] = [];
|
|
98
|
+
let segmentStart = 0;
|
|
99
|
+
BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.lastIndex = 0;
|
|
100
|
+
for (
|
|
101
|
+
let match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted);
|
|
102
|
+
match;
|
|
103
|
+
match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted)
|
|
104
|
+
) {
|
|
105
|
+
const extensionEnd = match.index + match[0].length;
|
|
106
|
+
const boundaryEnd = imagePathBoundaryEnd(pasted, segmentStart, extensionEnd);
|
|
107
|
+
if (boundaryEnd === undefined) continue;
|
|
108
|
+
|
|
109
|
+
const path = normalizePastedImagePath(pasted.slice(segmentStart, boundaryEnd));
|
|
110
|
+
if (!path || !BRACKETED_IMAGE_PATH_REGEX.test(path)) return undefined;
|
|
111
|
+
paths.push(path);
|
|
112
|
+
|
|
113
|
+
segmentStart = boundaryEnd;
|
|
114
|
+
while (segmentStart < pasted.length && isPastedPathSeparator(pasted[segmentStart])) {
|
|
115
|
+
segmentStart++;
|
|
116
|
+
}
|
|
117
|
+
BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.lastIndex = segmentStart;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (paths.length === 0 || segmentStart !== pasted.length) return undefined;
|
|
121
|
+
return paths;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function extractBracketedImagePastePath(data: string): string | undefined {
|
|
125
|
+
const paths = extractBracketedImagePastePaths(data);
|
|
126
|
+
return paths?.length === 1 ? paths[0] : undefined;
|
|
71
127
|
}
|
|
72
128
|
|
|
73
129
|
/**
|
|
@@ -111,8 +167,8 @@ export class CustomEditor extends Editor {
|
|
|
111
167
|
onCopyPrompt?: () => void;
|
|
112
168
|
/** Called when the configured image-paste shortcut is pressed. */
|
|
113
169
|
onPasteImage?: () => Promise<boolean>;
|
|
114
|
-
/** Called when a bracketed paste contains
|
|
115
|
-
onPasteImagePath?: (path: string) => void
|
|
170
|
+
/** Called when a bracketed paste contains one or more image-file paths. */
|
|
171
|
+
onPasteImagePath?: (path: string) => void | Promise<void>;
|
|
116
172
|
/** Called when the configured raw text-paste shortcut is pressed. */
|
|
117
173
|
onPasteTextRaw?: () => void;
|
|
118
174
|
/** Called when the configured dequeue shortcut is pressed. */
|
|
@@ -188,9 +244,13 @@ export class CustomEditor extends Editor {
|
|
|
188
244
|
return;
|
|
189
245
|
}
|
|
190
246
|
|
|
191
|
-
const
|
|
192
|
-
if (
|
|
193
|
-
|
|
247
|
+
const pastedImagePaths = extractBracketedImagePastePaths(data);
|
|
248
|
+
if (pastedImagePaths && this.onPasteImagePath) {
|
|
249
|
+
void (async () => {
|
|
250
|
+
for (const path of pastedImagePaths) {
|
|
251
|
+
await this.onPasteImagePath?.(path);
|
|
252
|
+
}
|
|
253
|
+
})();
|
|
194
254
|
return;
|
|
195
255
|
}
|
|
196
256
|
|
|
@@ -7,7 +7,11 @@ interface FrozenRender {
|
|
|
7
7
|
lines: string[];
|
|
8
8
|
generation: number;
|
|
9
9
|
appendOnly: boolean;
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Frames remaining until a block that rewrote an interior row may re-earn
|
|
12
|
+
* append-only status. `0` means the block is not under rewrite suspicion.
|
|
13
|
+
*/
|
|
14
|
+
volatileCooldown: number;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
interface SnapshotCarrier {
|
|
@@ -51,10 +55,41 @@ function stripPlainBlankEdges(lines: string[]): string[] {
|
|
|
51
55
|
|
|
52
56
|
interface LiveCommitState {
|
|
53
57
|
appendOnly: boolean;
|
|
54
|
-
|
|
58
|
+
volatileCooldown: number;
|
|
55
59
|
safeLength: number;
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Render frames a block must stay clean (static or append-shaped) after an
|
|
64
|
+
* interior rewrite before its rows become committable again. A one-off
|
|
65
|
+
* re-layout (a codespan finalizing across a wrap boundary, a paragraph
|
|
66
|
+
* re-parsed as a heading) only suspends commits briefly — the pinned emitter
|
|
67
|
+
* appends from the stalled high-water mark, so the gap backfills contiguously
|
|
68
|
+
* once the block re-earns append-only. Periodic animations (a spinner rewrites
|
|
69
|
+
* its row every few frames) keep resetting the countdown and never re-earn it,
|
|
70
|
+
* so genuinely volatile blocks stay deferred. Frames arrive at most at the
|
|
71
|
+
* TUI's 30 Hz render cadence, so 30 frames ≈ 1s of clean streaming.
|
|
72
|
+
*/
|
|
73
|
+
const VOLATILE_REARM_FRAMES = 30;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Visible-content form of a row: SGR/OSC bytes and trailing pad spaces are
|
|
77
|
+
* write framing, not content. A styled line's closing escape moves when the
|
|
78
|
+
* line stops being the last of its span (a wrapped thinking paragraph growing
|
|
79
|
+
* by one row), and width-padded rows shift their trailing spaces as text
|
|
80
|
+
* grows; both leave the on-screen cells identical and must not count as a
|
|
81
|
+
* rewrite of a committed-candidate row. Committed scrollback rows are written
|
|
82
|
+
* with a full SGR/OSC reset terminator, so escape-placement drift between
|
|
83
|
+
* visually identical renders cannot bleed styles across rows.
|
|
84
|
+
*/
|
|
85
|
+
function normalizeRow(line: string): string {
|
|
86
|
+
return Bun.stripANSI(line).trimEnd();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function rowsVisiblyEqual(prev: string, cur: string): boolean {
|
|
90
|
+
return prev === cur || normalizeRow(prev) === normalizeRow(cur);
|
|
91
|
+
}
|
|
92
|
+
|
|
58
93
|
function hasValidSnapshot(
|
|
59
94
|
snapshot: FrozenRender | undefined,
|
|
60
95
|
width: number,
|
|
@@ -66,14 +101,14 @@ function hasValidSnapshot(
|
|
|
66
101
|
function commonPrefixLength(prev: string[], cur: string[]): number {
|
|
67
102
|
const limit = Math.min(prev.length, cur.length);
|
|
68
103
|
let i = 0;
|
|
69
|
-
while (i < limit && prev[i]
|
|
104
|
+
while (i < limit && rowsVisiblyEqual(prev[i]!, cur[i]!)) i++;
|
|
70
105
|
return i;
|
|
71
106
|
}
|
|
72
107
|
|
|
73
108
|
function commonSuffixLength(prev: string[], cur: string[], prefixLength: number): number {
|
|
74
109
|
const limit = Math.min(prev.length - prefixLength, cur.length - prefixLength);
|
|
75
110
|
let i = 0;
|
|
76
|
-
while (i < limit && prev[prev.length - 1 - i]
|
|
111
|
+
while (i < limit && rowsVisiblyEqual(prev[prev.length - 1 - i]!, cur[cur.length - 1 - i]!)) i++;
|
|
77
112
|
return i;
|
|
78
113
|
}
|
|
79
114
|
|
|
@@ -84,42 +119,56 @@ function deriveLiveCommitState(
|
|
|
84
119
|
generation: number,
|
|
85
120
|
): LiveCommitState {
|
|
86
121
|
let appendOnly = false;
|
|
87
|
-
let
|
|
122
|
+
let volatileCooldown = 0;
|
|
88
123
|
if (hasValidSnapshot(previous, width, generation)) {
|
|
89
124
|
appendOnly = previous.appendOnly;
|
|
90
|
-
|
|
125
|
+
volatileCooldown = previous.volatileCooldown;
|
|
91
126
|
|
|
92
127
|
const prefixLength = commonPrefixLength(previous.lines, current);
|
|
93
128
|
const staticRender = prefixLength === previous.lines.length && prefixLength === current.length;
|
|
129
|
+
let cleanFrame = true;
|
|
94
130
|
if (!staticRender) {
|
|
95
131
|
const suffixLength = commonSuffixLength(previous.lines, current, prefixLength);
|
|
96
132
|
// Append-only growth never rewrites a row that may already have scrolled
|
|
97
|
-
// into native scrollback; it only grows the block at/near its tail.
|
|
133
|
+
// into native scrollback; it only grows the block at/near its tail. Four
|
|
98
134
|
// shapes qualify: a pure bottom append, an insertion above stable trailing
|
|
99
|
-
// chrome (a streaming tool's footer/border),
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
135
|
+
// chrome (a streaming tool's footer/border), an in-place extension of the
|
|
136
|
+
// current line by one streamed token (line count unchanged), and a
|
|
137
|
+
// wrap-shrink of the current line where its last word grew past the wrap
|
|
138
|
+
// column and moved down onto an appended row. The first two preserve every
|
|
139
|
+
// previous row across a matching prefix + suffix; the last two leave a
|
|
140
|
+
// single divergent previous row — the block's in-flight bottom line, which
|
|
141
|
+
// cannot have been committed (commits stop at the viewport top and the
|
|
142
|
+
// bottom line is by definition on screen). Any other divergent interior
|
|
143
|
+
// row means the block re-laid-out committed-candidate content — a rewrite,
|
|
144
|
+
// which suspends commits until the block re-earns append-only.
|
|
105
145
|
const preservedEveryRow = prefixLength + suffixLength >= previous.lines.length;
|
|
106
|
-
|
|
146
|
+
let tailExtendedInPlace = false;
|
|
147
|
+
if (
|
|
148
|
+
!preservedEveryRow &&
|
|
107
149
|
prefixLength + suffixLength === previous.lines.length - 1 &&
|
|
108
|
-
prefixLength < current.length
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
150
|
+
prefixLength < current.length
|
|
151
|
+
) {
|
|
152
|
+
const prevTail = normalizeRow(previous.lines[prefixLength]!);
|
|
153
|
+
const curTail = normalizeRow(current[prefixLength]!);
|
|
154
|
+
tailExtendedInPlace =
|
|
155
|
+
curTail.startsWith(prevTail) || (current.length > previous.lines.length && prevTail.startsWith(curTail));
|
|
156
|
+
}
|
|
157
|
+
if ((preservedEveryRow || tailExtendedInPlace) && current.length >= previous.lines.length) {
|
|
158
|
+
if (volatileCooldown === 0) appendOnly = true;
|
|
159
|
+
} else {
|
|
160
|
+
cleanFrame = false;
|
|
114
161
|
appendOnly = false;
|
|
162
|
+
volatileCooldown = VOLATILE_REARM_FRAMES;
|
|
115
163
|
}
|
|
116
164
|
}
|
|
165
|
+
if (cleanFrame && volatileCooldown > 0) volatileCooldown--;
|
|
117
166
|
}
|
|
118
167
|
|
|
119
168
|
return {
|
|
120
169
|
appendOnly,
|
|
121
|
-
|
|
122
|
-
safeLength:
|
|
170
|
+
volatileCooldown,
|
|
171
|
+
safeLength: appendOnly ? current.length : 0,
|
|
123
172
|
};
|
|
124
173
|
}
|
|
125
174
|
|
|
@@ -163,8 +212,11 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
|
|
|
163
212
|
#nativeScrollbackLiveRegionStart: number | undefined;
|
|
164
213
|
// Local line index up to which the leading run of live blocks is safe to
|
|
165
214
|
// commit. Finalized blocks contribute their full frozen body; still-live
|
|
166
|
-
// blocks contribute only
|
|
167
|
-
//
|
|
215
|
+
// blocks contribute only while their render has been observed growing
|
|
216
|
+
// without visibly rewriting a previously rendered interior row (escape
|
|
217
|
+
// placement and pad drift are ignored). A rewrite suspends the block's
|
|
218
|
+
// contribution until it re-earns append-only via VOLATILE_REARM_FRAMES
|
|
219
|
+
// clean frames; the pinned emitter then backfills the stalled gap.
|
|
168
220
|
#nativeScrollbackCommitSafeEnd: number | undefined;
|
|
169
221
|
|
|
170
222
|
override invalidate(): void {
|
|
@@ -265,7 +317,7 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
|
|
|
265
317
|
lines: contribution,
|
|
266
318
|
generation: this.#generation,
|
|
267
319
|
appendOnly: liveCommitState?.appendOnly ?? false,
|
|
268
|
-
|
|
320
|
+
volatileCooldown: liveCommitState?.volatileCooldown ?? 0,
|
|
269
321
|
};
|
|
270
322
|
}
|
|
271
323
|
}
|
|
@@ -191,7 +191,7 @@ export class InputController {
|
|
|
191
191
|
this.ctx.keybindings.getKeys("app.clipboard.pasteImage"),
|
|
192
192
|
);
|
|
193
193
|
this.ctx.editor.onPasteImage = () => this.handleImagePaste();
|
|
194
|
-
this.ctx.editor.onPasteImagePath = path =>
|
|
194
|
+
this.ctx.editor.onPasteImagePath = path => this.handleImagePathPaste(path);
|
|
195
195
|
this.ctx.editor.setActionKeys(
|
|
196
196
|
"app.clipboard.pasteTextRaw",
|
|
197
197
|
this.ctx.keybindings.getKeys("app.clipboard.pasteTextRaw"),
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types";
|
|
37
37
|
import type { OAuthCredential } from "../../session/auth-storage";
|
|
38
38
|
import { shortenPath } from "../../tools/render-utils";
|
|
39
|
-
import {
|
|
39
|
+
import { urlHyperlinkAlways } from "../../tui";
|
|
40
40
|
import { openPath } from "../../utils/open";
|
|
41
41
|
import { ChatBlock } from "../components/chat-block";
|
|
42
42
|
import { MCPAddWizard } from "../components/mcp-add-wizard";
|
|
@@ -63,7 +63,7 @@ export class MCPAuthorizationLinkPrompt implements Component {
|
|
|
63
63
|
invalidate(): void {}
|
|
64
64
|
|
|
65
65
|
render(_width: number): string[] {
|
|
66
|
-
const link =
|
|
66
|
+
const link = urlHyperlinkAlways(this.#url, "Click here to authorize");
|
|
67
67
|
return [
|
|
68
68
|
` ${theme.fg("success", "Open authorization URL:")}`,
|
|
69
69
|
` ${theme.fg("accent", link)}`,
|