@oh-my-pi/pi-coding-agent 17.1.3 → 17.1.4
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 +54 -0
- package/dist/cli.js +3759 -3751
- package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +1 -0
- package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
- package/dist/types/cli/update-cli.d.ts +41 -0
- package/dist/types/cli/usage-cli.d.ts +4 -2
- package/dist/types/commands/update.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +19 -0
- package/dist/types/config/settings-schema.d.ts +30 -7
- package/dist/types/cursor.d.ts +47 -1
- package/dist/types/eval/js/process-entry.d.ts +2 -1
- package/dist/types/eval/js/worker-core.d.ts +4 -1
- package/dist/types/extensibility/extensions/runner.d.ts +1 -0
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +4 -0
- package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +15 -7
- package/dist/types/extensibility/shared-events.d.ts +2 -0
- package/dist/types/modes/components/assistant-message.d.ts +9 -0
- package/dist/types/modes/components/custom-editor.d.ts +12 -8
- package/dist/types/plan-mode/approved-plan.d.ts +3 -2
- package/dist/types/secrets/obfuscator.d.ts +7 -31
- package/dist/types/session/agent-session.d.ts +19 -4
- package/dist/types/session/prewalk.d.ts +2 -1
- package/dist/types/session/session-advisors.d.ts +8 -0
- package/dist/types/session/session-metadata.d.ts +29 -0
- package/dist/types/session/turn-recovery.d.ts +5 -5
- package/dist/types/stt/sherpa-runtime.d.ts +38 -0
- package/dist/types/thinking.d.ts +24 -0
- package/dist/types/tools/bash.d.ts +5 -4
- package/dist/types/tools/computer/exposure.d.ts +8 -0
- package/dist/types/tools/computer/supervisor.d.ts +1 -1
- package/dist/types/tools/computer/worker-entry.d.ts +1 -1
- package/dist/types/tools/computer/worker.d.ts +1 -1
- package/dist/types/tools/computer.d.ts +6 -0
- package/dist/types/tools/shell-tokenize.d.ts +14 -0
- package/dist/types/tools/todo.d.ts +7 -1
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +80 -10
- package/src/advisor/runtime.ts +27 -1
- package/src/cli/__tests__/auth-gateway-catalog.test.ts +111 -0
- package/src/cli/auth-gateway-cli.ts +63 -16
- package/src/cli/config-cli.ts +25 -7
- package/src/cli/update-cli.ts +229 -29
- package/src/cli/usage-cli.ts +144 -15
- package/src/cli.ts +21 -15
- package/src/commands/update.ts +6 -0
- package/src/config/__tests__/model-registry.test.ts +42 -7
- package/src/config/model-registry.ts +67 -4
- package/src/config/settings-schema.ts +39 -8
- package/src/config/settings.ts +24 -2
- package/src/cursor.ts +153 -0
- package/src/dap/session.ts +70 -11
- package/src/eval/__tests__/js-context-manager.test.ts +9 -1
- package/src/eval/__tests__/process-entry-import.test.ts +111 -1
- package/src/eval/js/process-entry.ts +9 -5
- package/src/eval/js/worker-core.ts +6 -2
- package/src/exec/bash-executor.ts +4 -2
- package/src/extensibility/extensions/runner.ts +23 -8
- package/src/extensibility/legacy-pi-ai-shim.ts +9 -0
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +14 -2
- package/src/extensibility/legacy-pi-tui-shim.ts +33 -0
- package/src/extensibility/shared-events.ts +2 -0
- package/src/main.ts +22 -1
- package/src/modes/acp/acp-agent.ts +6 -0
- package/src/modes/components/assistant-message.ts +88 -11
- package/src/modes/components/chat-transcript-builder.ts +2 -0
- package/src/modes/components/custom-editor.test.ts +170 -0
- package/src/modes/components/custom-editor.ts +79 -29
- package/src/modes/components/settings-defs.ts +5 -1
- package/src/modes/controllers/event-controller.ts +96 -4
- package/src/modes/controllers/selector-controller.ts +14 -0
- package/src/modes/interactive-mode.ts +34 -8
- package/src/modes/utils/context-usage.ts +19 -2
- package/src/modes/utils/interactive-context-helpers.ts +1 -0
- package/src/plan-mode/approved-plan.ts +18 -10
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/system-prompt.md +10 -1
- package/src/sdk.ts +4 -0
- package/src/secrets/obfuscator.ts +36 -126
- package/src/session/agent-session.ts +69 -60
- package/src/session/prewalk.ts +8 -3
- package/src/session/session-advisors.ts +67 -3
- package/src/session/session-metadata.ts +53 -0
- package/src/session/session-provider-boundary.ts +0 -1
- package/src/session/session-tools.ts +35 -1
- package/src/session/turn-recovery.ts +36 -19
- package/src/slash-commands/builtin-registry.ts +49 -7
- package/src/stt/asr-worker.ts +2 -37
- package/src/stt/sherpa-runtime.ts +71 -0
- package/src/task/executor.ts +6 -5
- package/src/thinking.ts +39 -0
- package/src/tools/bash.ts +43 -15
- package/src/tools/computer/exposure.ts +38 -0
- package/src/tools/computer/supervisor.ts +61 -13
- package/src/tools/computer/worker-entry.ts +28 -19
- package/src/tools/computer/worker.ts +3 -7
- package/src/tools/computer.ts +65 -10
- package/src/tools/gh-cache-invalidation.ts +2 -82
- package/src/tools/shell-tokenize.ts +83 -0
- package/src/tools/todo.ts +44 -17
|
@@ -25,6 +25,8 @@ interface ActiveRun {
|
|
|
25
25
|
|
|
26
26
|
type RunResult = Extract<WorkerOutbound, { type: "result" }>;
|
|
27
27
|
|
|
28
|
+
export type RejectionInterceptor = (handler: (reason: unknown) => boolean) => () => void;
|
|
29
|
+
|
|
28
30
|
export type WorkerCoreOptions =
|
|
29
31
|
| {
|
|
30
32
|
mode: "isolated";
|
|
@@ -36,10 +38,12 @@ export type WorkerCoreOptions =
|
|
|
36
38
|
* mutate the host's own cwd on the inline fallback.
|
|
37
39
|
*/
|
|
38
40
|
chdir?: (cwd: string) => void;
|
|
41
|
+
/** Share the subprocess host's fatal-rejection guard when one is installed. */
|
|
42
|
+
interceptUnhandledRejections?: RejectionInterceptor;
|
|
39
43
|
}
|
|
40
44
|
| {
|
|
41
45
|
mode: "inline";
|
|
42
|
-
interceptUnhandledRejections
|
|
46
|
+
interceptUnhandledRejections: RejectionInterceptor;
|
|
43
47
|
};
|
|
44
48
|
|
|
45
49
|
/** Finished-cell filenames retained for attributing rejections that surface after the run settled. */
|
|
@@ -115,7 +119,7 @@ export class WorkerCore {
|
|
|
115
119
|
* without a usable stack, while anything else keeps its default fatality.
|
|
116
120
|
*/
|
|
117
121
|
#installRejectionGuard(): () => void {
|
|
118
|
-
if (this.#options.
|
|
122
|
+
if (this.#options.interceptUnhandledRejections) {
|
|
119
123
|
return this.#options.interceptUnhandledRejections(reason => this.#consumeRejection(reason));
|
|
120
124
|
}
|
|
121
125
|
const onRejection = (reason: unknown): void => {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { ExponentialYield } from "@oh-my-pi/pi-agent-core/utils/yield";
|
|
7
7
|
import { type MinimizerOptions, Shell, type ShellRunResult } from "@oh-my-pi/pi-natives";
|
|
8
|
-
import { isExecutable, type ShellConfig } from "@oh-my-pi/pi-utils/procmgr";
|
|
8
|
+
import { isCmdShell, isExecutable, type ShellConfig } from "@oh-my-pi/pi-utils/procmgr";
|
|
9
9
|
import { Settings, type ShellMinimizerSettings } from "../config/settings";
|
|
10
10
|
import { OutputSink } from "../session/streaming-output";
|
|
11
11
|
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta";
|
|
@@ -367,8 +367,10 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
367
367
|
});
|
|
368
368
|
const commandEnv = buildNonInteractiveEnv(preflight.env);
|
|
369
369
|
const runCdInPersistentShell = options?.useUserShell === true && !prefix && isPersistentShellCdCommand(command);
|
|
370
|
+
// Never wrap in cmd.exe: it is only the Windows no-bash fallback for spawn
|
|
371
|
+
// paths, and the embedded brush shell runs the POSIX line better directly.
|
|
370
372
|
const finalCommand =
|
|
371
|
-
options?.useUserShell === true && !bashShell && !runCdInPersistentShell
|
|
373
|
+
options?.useUserShell === true && !bashShell && !isCmdShell(shell) && !runCdInPersistentShell
|
|
372
374
|
? buildUserShellCommand(shell, args, preflight.command)
|
|
373
375
|
: preflight.command;
|
|
374
376
|
|
|
@@ -103,10 +103,11 @@ function handlerTimeoutForEvent(eventType: string): number {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout");
|
|
106
|
+
const EXTENSION_HANDLER_ABORTED = Symbol("extensionHandlerAborted");
|
|
106
107
|
|
|
107
108
|
/**
|
|
108
|
-
* Race `work` against a `timeoutMs` budget
|
|
109
|
-
*
|
|
109
|
+
* Race `work` against a `timeoutMs` budget and optional cancellation signal,
|
|
110
|
+
* clearing the timer and abort listener as soon as one branch settles.
|
|
110
111
|
*
|
|
111
112
|
* We deliberately avoid `Bun.sleep(timeoutMs).then(...)` here: that leaves an
|
|
112
113
|
* uncancellable timer registered with the event loop, so every successful
|
|
@@ -119,14 +120,20 @@ const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout");
|
|
|
119
120
|
async function raceHandlerWithTimeout<T>(
|
|
120
121
|
work: Promise<T>,
|
|
121
122
|
timeoutMs: number,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
123
|
+
signal?: AbortSignal,
|
|
124
|
+
): Promise<T | typeof EXTENSION_HANDLER_TIMEOUT | typeof EXTENSION_HANDLER_ABORTED> {
|
|
125
|
+
if (signal?.aborted) return EXTENSION_HANDLER_ABORTED;
|
|
126
|
+
const { promise: interruptPromise, resolve: resolveInterrupt } = Promise.withResolvers<
|
|
127
|
+
typeof EXTENSION_HANDLER_TIMEOUT | typeof EXTENSION_HANDLER_ABORTED
|
|
128
|
+
>();
|
|
129
|
+
const timer = setTimeout(() => resolveInterrupt(EXTENSION_HANDLER_TIMEOUT), timeoutMs);
|
|
130
|
+
const onAbort = () => resolveInterrupt(EXTENSION_HANDLER_ABORTED);
|
|
131
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
126
132
|
try {
|
|
127
|
-
return await Promise.race([work,
|
|
133
|
+
return await Promise.race([work, interruptPromise]);
|
|
128
134
|
} finally {
|
|
129
135
|
clearTimeout(timer);
|
|
136
|
+
signal?.removeEventListener("abort", onAbort);
|
|
130
137
|
}
|
|
131
138
|
}
|
|
132
139
|
|
|
@@ -372,7 +379,9 @@ export class ExtensionRunner {
|
|
|
372
379
|
await this.emit({ type: "credential_disabled", ...event });
|
|
373
380
|
}
|
|
374
381
|
|
|
382
|
+
/** Emits a session stop pass that can be cancelled with the active settle signal. */
|
|
375
383
|
async emitSessionStop(event: Omit<SessionStopEvent, "type">): Promise<SessionStopEventResult | undefined> {
|
|
384
|
+
if (event.signal.aborted) return undefined;
|
|
376
385
|
return await this.emit({ type: "session_stop", ...event });
|
|
377
386
|
}
|
|
378
387
|
|
|
@@ -622,8 +631,14 @@ export class ExtensionRunner {
|
|
|
622
631
|
ext: Extension,
|
|
623
632
|
timeoutMs: number,
|
|
624
633
|
): Promise<TResult | undefined> {
|
|
634
|
+
const signal =
|
|
635
|
+
event.type === "session_stop" && "signal" in event && event.signal instanceof AbortSignal
|
|
636
|
+
? event.signal
|
|
637
|
+
: undefined;
|
|
638
|
+
if (signal?.aborted) return undefined;
|
|
625
639
|
try {
|
|
626
|
-
const handlerResult = await raceHandlerWithTimeout(Promise.resolve(handler(event, ctx)), timeoutMs);
|
|
640
|
+
const handlerResult = await raceHandlerWithTimeout(Promise.resolve(handler(event, ctx)), timeoutMs, signal);
|
|
641
|
+
if (handlerResult === EXTENSION_HANDLER_ABORTED) return undefined;
|
|
627
642
|
if (handlerResult === EXTENSION_HANDLER_TIMEOUT) {
|
|
628
643
|
const error = `handler timed out after ${timeoutMs}ms`;
|
|
629
644
|
logger.warn("Extension handler timed out", {
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
* `types.ts` via the `export *` below — pi-ai still exports both as types,
|
|
20
20
|
* only the runtime `Type` builder and `StringEnum()` helper were removed.
|
|
21
21
|
*/
|
|
22
|
+
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
|
23
|
+
import type { Effort } from "@oh-my-pi/pi-catalog/effort";
|
|
24
|
+
import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
22
25
|
import {
|
|
23
26
|
calculateCost,
|
|
24
27
|
getBundledModel,
|
|
@@ -71,6 +74,12 @@ export function StringEnum<T extends string | number>(
|
|
|
71
74
|
return schema;
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
/** Clamp a historical Pi thinking level against OMP's model metadata. */
|
|
78
|
+
export function clampThinkingLevel<TApi extends Api>(model: Model<TApi>, level: Effort | "off"): Effort | "off" {
|
|
79
|
+
if (level === "off") return "off";
|
|
80
|
+
return clampThinkingLevelForModel(model, level) ?? "off";
|
|
81
|
+
}
|
|
82
|
+
|
|
74
83
|
export * from "@oh-my-pi/pi-ai";
|
|
75
84
|
/**
|
|
76
85
|
* Compatibility re-exports for catalog symbols that pi-ai historically exposed
|
|
@@ -861,6 +861,11 @@ export interface ResourceLoader {
|
|
|
861
861
|
readonly __ompLegacyPiLoader?: true;
|
|
862
862
|
}
|
|
863
863
|
|
|
864
|
+
/** Create a pre-initialization runtime for legacy extension resource loaders. */
|
|
865
|
+
export function createExtensionRuntime(): ExtensionRuntime {
|
|
866
|
+
return new ExtensionRuntime();
|
|
867
|
+
}
|
|
868
|
+
|
|
864
869
|
/**
|
|
865
870
|
* Loader-owned inputs that {@link createAgentSession} needs regardless of
|
|
866
871
|
* whether the caller provided extra options. `cwd`/`agentDir` fall back to
|
|
@@ -894,7 +899,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|
|
894
899
|
readonly __ompLegacyPiLoader = true as const;
|
|
895
900
|
#state: ResolvedLoaderState;
|
|
896
901
|
#options: DefaultResourceLoaderOptions;
|
|
897
|
-
#extensionsResult: LoadExtensionsResult = { extensions: [], errors: [], runtime:
|
|
902
|
+
#extensionsResult: LoadExtensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() };
|
|
898
903
|
#skills: Skill[] = [];
|
|
899
904
|
#skillDiagnostics: ResourceDiagnostic[] = [];
|
|
900
905
|
#prompts: PromptTemplate[] = [];
|
|
@@ -1043,7 +1048,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|
|
1043
1048
|
const { cwd, noExtensions, additionalExtensionPaths, extensionFactories, eventBus } = this.#state;
|
|
1044
1049
|
|
|
1045
1050
|
if (noExtensions && additionalExtensionPaths.length === 0 && extensionFactories.length === 0) {
|
|
1046
|
-
return { extensions: [], errors: [], runtime:
|
|
1051
|
+
return { extensions: [], errors: [], runtime: createExtensionRuntime() };
|
|
1047
1052
|
}
|
|
1048
1053
|
|
|
1049
1054
|
const paths = await discoverSessionExtensionPaths(
|
|
@@ -1366,6 +1371,13 @@ export function getPackageDir(): string {
|
|
|
1366
1371
|
return getOmpPackageDir() ?? (isCompiledBinary() ? path.dirname(process.execPath) : process.cwd());
|
|
1367
1372
|
}
|
|
1368
1373
|
|
|
1374
|
+
// Legacy pi's `@earendil-works/pi-coding-agent` re-exported `estimateTokens`
|
|
1375
|
+
// from its package root (via `./core/compaction/index.ts`). In omp it lives in
|
|
1376
|
+
// `@oh-my-pi/pi-agent-core/compaction`, and the coding-agent barrel below does
|
|
1377
|
+
// not forward it, so legacy extensions importing it fail Bun's static export
|
|
1378
|
+
// check during validation (issue #6583).
|
|
1379
|
+
export { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
|
|
1380
|
+
|
|
1369
1381
|
export * from "../index";
|
|
1370
1382
|
export { formatBytes as formatSize } from "../tools/render-utils";
|
|
1371
1383
|
export { copyToClipboard } from "../utils/clipboard";
|
|
@@ -6,5 +6,38 @@
|
|
|
6
6
|
* exposes the equivalent, broader `decodePrintableKey` helper. Keep the legacy
|
|
7
7
|
* name available without reintroducing it into the canonical package surface.
|
|
8
8
|
*/
|
|
9
|
+
import { ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui";
|
|
10
|
+
|
|
9
11
|
export * from "@oh-my-pi/pi-tui";
|
|
10
12
|
export { decodePrintableKey as decodeKittyPrintable } from "@oh-my-pi/pi-tui";
|
|
13
|
+
|
|
14
|
+
/** Report canonical terminal capabilities through the legacy Pi TUI shape. */
|
|
15
|
+
export function getCapabilities(): {
|
|
16
|
+
images: "kitty" | "iterm2" | null;
|
|
17
|
+
trueColor: boolean;
|
|
18
|
+
hyperlinks: boolean;
|
|
19
|
+
} {
|
|
20
|
+
const images =
|
|
21
|
+
TERMINAL.imageProtocol === ImageProtocol.Kitty
|
|
22
|
+
? "kitty"
|
|
23
|
+
: TERMINAL.imageProtocol === ImageProtocol.Iterm2
|
|
24
|
+
? "iterm2"
|
|
25
|
+
: null;
|
|
26
|
+
return { images, trueColor: TERMINAL.trueColor, hyperlinks: TERMINAL.hyperlinks };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Delete one Kitty graphics image by id, matching the legacy Pi TUI helper.
|
|
31
|
+
*
|
|
32
|
+
* Returns the bare control sequence exactly like upstream Pi: legacy callers
|
|
33
|
+
* (e.g. pi-sprite) apply their own tmux passthrough wrapping, so wrapping here
|
|
34
|
+
* would double-wrap under tmux and the outer terminal would drop the command.
|
|
35
|
+
*/
|
|
36
|
+
export function deleteKittyImage(imageId: number): string {
|
|
37
|
+
return `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Delete every Kitty graphics image using the legacy Pi TUI bare sequence. */
|
|
41
|
+
export function deleteAllKittyImages(): string {
|
|
42
|
+
return "\x1b_Ga=d,d=A,q=2\x1b\\";
|
|
43
|
+
}
|
|
@@ -102,6 +102,8 @@ export interface SessionStopEvent {
|
|
|
102
102
|
session_id: string;
|
|
103
103
|
session_file?: string;
|
|
104
104
|
stop_hook_active: boolean;
|
|
105
|
+
/** Cancels handler waiting when the active settle pass is aborted. */
|
|
106
|
+
signal: AbortSignal;
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
/** Preparation data for tree navigation (used by session_before_tree event) */
|
package/src/main.ts
CHANGED
|
@@ -885,6 +885,10 @@ export async function buildSessionOptions(
|
|
|
885
885
|
// - supports --provider <name> --model <pattern>
|
|
886
886
|
// - supports --model <provider>/<pattern>
|
|
887
887
|
const modelMatchPreferences = getModelMatchPreferences(activeSettings);
|
|
888
|
+
// True when a configured `default` role was deliberately left unresolved for
|
|
889
|
+
// createAgentSession's post-extension re-resolution (issue #6694); the
|
|
890
|
+
// scoped thinking-level seed below must be deferred along with the model.
|
|
891
|
+
let deferredDefaultRole = false;
|
|
888
892
|
if (parsed.model) {
|
|
889
893
|
const resolved = resolveCliModel({
|
|
890
894
|
cliProvider: parsed.provider,
|
|
@@ -946,7 +950,20 @@ export async function buildSessionOptions(
|
|
|
946
950
|
}
|
|
947
951
|
}
|
|
948
952
|
}
|
|
949
|
-
|
|
953
|
+
// A configured `default` role that doesn't resolve within the startup
|
|
954
|
+
// scope is deferred, NOT silently pinned to `scopedModels[0]`: the scope
|
|
955
|
+
// is resolved before extensions register their providers, so a role naming
|
|
956
|
+
// an extension-registered model (listed in `enabledModels`) would drop out
|
|
957
|
+
// here and the session would run on an unrelated in-scope provider without
|
|
958
|
+
// any error. Leaving `options.model` unset lets createAgentSession's
|
|
959
|
+
// post-extension default-role resolution reclaim it against the fully
|
|
960
|
+
// registered, still enabledModels-scoped catalog (issue #6694).
|
|
961
|
+
// Defer ONLY for a settings-derived scope: createAgentSession re-resolves
|
|
962
|
+
// against `settings.enabledModels` and never sees CLI `--models`, so
|
|
963
|
+
// deferring under an explicit CLI scope would let the saved default
|
|
964
|
+
// escape it — keep pinning the first scoped model there.
|
|
965
|
+
deferredDefaultRole = !options.model && Boolean(remembered) && !((parsed.models?.length ?? 0) > 0);
|
|
966
|
+
if (!options.model && !deferredDefaultRole) options.model = scopedModels[0].model;
|
|
950
967
|
}
|
|
951
968
|
|
|
952
969
|
if (parsed.noPrewalk && (parsed.prewalk || parsed.prewalkInto !== undefined)) {
|
|
@@ -1005,6 +1022,10 @@ export async function buildSessionOptions(
|
|
|
1005
1022
|
} else if (
|
|
1006
1023
|
scopedModels.length > 0 &&
|
|
1007
1024
|
scopedModels[0].explicitThinkingLevel === true &&
|
|
1025
|
+
// A deferred default role resolves its own model (and any explicit
|
|
1026
|
+
// thinking suffix) after extensions register; seeding the fallback
|
|
1027
|
+
// scoped model's level here would override it in createAgentSession.
|
|
1028
|
+
!deferredDefaultRole &&
|
|
1008
1029
|
!parsed.continue &&
|
|
1009
1030
|
!parsed.resume
|
|
1010
1031
|
) {
|
|
@@ -1691,6 +1691,12 @@ export class AcpAgent implements Agent {
|
|
|
1691
1691
|
planExists: true,
|
|
1692
1692
|
};
|
|
1693
1693
|
if (!approved) {
|
|
1694
|
+
// Rejection keeps plan mode active for another planning turn. Promote the
|
|
1695
|
+
// reviewed path into plan-mode state so the next `#buildPlanModeMessage()`
|
|
1696
|
+
// targets the plan just reviewed, not the stale state path.
|
|
1697
|
+
if (state.planFilePath !== planFilePath) {
|
|
1698
|
+
session.setPlanModeState({ ...state, planFilePath });
|
|
1699
|
+
}
|
|
1694
1700
|
const normalizedTitle = normalizePlanTitle(resolvedTitle).title;
|
|
1695
1701
|
return {
|
|
1696
1702
|
content: [
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Container,
|
|
4
|
+
Image,
|
|
5
|
+
type ImageBudget,
|
|
6
|
+
ImageProtocol,
|
|
7
|
+
Markdown,
|
|
8
|
+
replaceTabs,
|
|
9
|
+
Spacer,
|
|
10
|
+
TERMINAL,
|
|
11
|
+
Text,
|
|
12
|
+
} from "@oh-my-pi/pi-tui";
|
|
3
13
|
import { formatNumber } from "@oh-my-pi/pi-utils";
|
|
4
14
|
import chalk from "chalk";
|
|
5
15
|
import type { AssistantThinkingRenderer } from "../../extensibility/extensions/types";
|
|
6
16
|
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
|
7
|
-
import { getPreviewLines, resolveImageOptions, TRUNCATE_LENGTHS } from "../../tools/render-utils";
|
|
17
|
+
import { expandKeyHint, getPreviewLines, resolveImageOptions, TRUNCATE_LENGTHS } from "../../tools/render-utils";
|
|
8
18
|
import { canonicalizeMessage, formatThinkingForDisplay, hasDisplayableThinking } from "../../utils/thinking-display";
|
|
9
19
|
import { resolveAssistantErrorPresentation } from "../utils/transcript-render-helpers";
|
|
10
20
|
import { type CacheInvalidation, CacheInvalidationMarkerComponent } from "./cache-invalidation-marker";
|
|
@@ -196,6 +206,22 @@ export class AssistantMessageComponent extends Container {
|
|
|
196
206
|
* transcript keeps the error in history.
|
|
197
207
|
*/
|
|
198
208
|
#errorPinned = false;
|
|
209
|
+
/**
|
|
210
|
+
* Whether the inline turn-ending error block renders its full body instead of
|
|
211
|
+
* the {@link MAX_TRANSCRIPT_ERROR_LINES}-capped preview. Toggled by
|
|
212
|
+
* {@link setExpanded} so Ctrl+O (tool-output expansion) reveals a long
|
|
213
|
+
* provider error whose tail would otherwise be unreachable in the live TUI.
|
|
214
|
+
*/
|
|
215
|
+
#errorExpanded = false;
|
|
216
|
+
/**
|
|
217
|
+
* True when the current {@link updateContent} message carries a truncatable
|
|
218
|
+
* inline provider error (the `#appendErrorBlock` path) — set whether or not
|
|
219
|
+
* the inline block was actually drawn, so it stays true even while the error
|
|
220
|
+
* is suppressed under a pinned banner. Gates {@link setExpanded} so toggling
|
|
221
|
+
* expansion only re-renders assistant turns that carry such an error, not
|
|
222
|
+
* every message in the transcript.
|
|
223
|
+
*/
|
|
224
|
+
#hasTruncatableError = false;
|
|
199
225
|
/**
|
|
200
226
|
* Monotonic content version reported to the transcript container via
|
|
201
227
|
* {@link getTranscriptBlockVersion}. Bumped by {@link updateContent} — the
|
|
@@ -408,6 +434,22 @@ export class AssistantMessageComponent extends Container {
|
|
|
408
434
|
}
|
|
409
435
|
}
|
|
410
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Expand or collapse the inline turn-ending error block so Ctrl+O
|
|
439
|
+
* (tool-output expansion) can reveal a long provider error's hidden tail.
|
|
440
|
+
* Only re-renders when the current message carries a truncatable error, so
|
|
441
|
+
* toggling expansion across the transcript skips ordinary turns. Works even
|
|
442
|
+
* while the error is pinned in the banner: the inline block is drawn (in full)
|
|
443
|
+
* when expanded so the complete body is reachable without sending a message.
|
|
444
|
+
*/
|
|
445
|
+
setExpanded(expanded: boolean): void {
|
|
446
|
+
if (this.#errorExpanded === expanded) return;
|
|
447
|
+
this.#errorExpanded = expanded;
|
|
448
|
+
if (this.#hasTruncatableError && this.#lastMessage) {
|
|
449
|
+
this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
411
453
|
isTranscriptBlockFinalized(): boolean {
|
|
412
454
|
return this.#transcriptBlockFinalized;
|
|
413
455
|
}
|
|
@@ -488,12 +530,25 @@ export class AssistantMessageComponent extends Container {
|
|
|
488
530
|
}
|
|
489
531
|
|
|
490
532
|
/**
|
|
491
|
-
* Render a turn-ending provider error inline.
|
|
492
|
-
* line count to {@link MAX_TRANSCRIPT_ERROR_LINES},
|
|
493
|
-
* line so a pathological body — e.g. the HTML page a
|
|
494
|
-
* can't flood the transcript
|
|
533
|
+
* Render a turn-ending provider error inline. Collapsed (default), it drops
|
|
534
|
+
* blank lines, clamps the line count to {@link MAX_TRANSCRIPT_ERROR_LINES},
|
|
535
|
+
* and width-truncates each line so a pathological body — e.g. the HTML page a
|
|
536
|
+
* proxy returns on a 502 — can't flood the transcript, appending a dim
|
|
537
|
+
* `ctrl+o`/expand hint when lines were hidden. Expanded (via
|
|
538
|
+
* {@link setExpanded}), it renders the full body — tabs replaced, blank lines
|
|
539
|
+
* preserved — letting {@link Text} word-wrap each line to the render width so
|
|
540
|
+
* the complete message is reachable. Mirrors {@link ErrorBannerComponent}.
|
|
495
541
|
*/
|
|
496
542
|
#appendErrorBlock(message: string): void {
|
|
543
|
+
if (this.#errorExpanded) {
|
|
544
|
+
const [first = "Unknown error", ...rest] = replaceTabs(message.replace(/\s+$/, "")).split("\n");
|
|
545
|
+
this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${first}`), 1, 0));
|
|
546
|
+
for (const line of rest) {
|
|
547
|
+
this.#contentContainer.addChild(new Text(theme.fg("error", ` ${line}`), 1, 0));
|
|
548
|
+
}
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const total = message.split("\n").filter(l => l.trim()).length;
|
|
497
552
|
const lines = getPreviewLines(message, MAX_TRANSCRIPT_ERROR_LINES, TRUNCATE_LENGTHS.LINE);
|
|
498
553
|
if (lines.length === 0) lines.push("Unknown error");
|
|
499
554
|
// The caller owns the separating Spacer; adding one here doubled the gap.
|
|
@@ -501,6 +556,16 @@ export class AssistantMessageComponent extends Container {
|
|
|
501
556
|
for (const line of lines.slice(1)) {
|
|
502
557
|
this.#contentContainer.addChild(new Text(theme.fg("error", ` ${line}`), 1, 0));
|
|
503
558
|
}
|
|
559
|
+
if (total > lines.length) {
|
|
560
|
+
const hidden = total - lines.length;
|
|
561
|
+
this.#contentContainer.addChild(
|
|
562
|
+
new Text(
|
|
563
|
+
theme.fg("dim", ` … +${hidden} more line${hidden === 1 ? "" : "s"} (${expandKeyHint()} to expand)`),
|
|
564
|
+
1,
|
|
565
|
+
0,
|
|
566
|
+
),
|
|
567
|
+
);
|
|
568
|
+
}
|
|
504
569
|
}
|
|
505
570
|
|
|
506
571
|
/** Toggle rendering for assistant-native and tool-result images. */
|
|
@@ -643,7 +708,10 @@ export class AssistantMessageComponent extends Container {
|
|
|
643
708
|
if (this.#toolImagesByCallId.size > 0) return false;
|
|
644
709
|
const errorPresentation = resolveAssistantErrorPresentation(message);
|
|
645
710
|
if (errorPresentation.kind === "compact-recovered") return false;
|
|
646
|
-
if (
|
|
711
|
+
if (
|
|
712
|
+
errorPresentation.kind === "full" &&
|
|
713
|
+
!(message.stopReason === "error" && this.#errorPinned && !this.#errorExpanded)
|
|
714
|
+
) {
|
|
647
715
|
return false;
|
|
648
716
|
}
|
|
649
717
|
// Extension stability: if thinking renderers exist and any tracked thinking
|
|
@@ -777,6 +845,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
777
845
|
// Clear content container
|
|
778
846
|
this.#contentContainer.clear();
|
|
779
847
|
this.#thinkingDots = undefined;
|
|
848
|
+
this.#hasTruncatableError = false;
|
|
780
849
|
|
|
781
850
|
// Determine if we should capture Markdown instances for next fast path
|
|
782
851
|
const shouldCapture = this.#canFastPath(message);
|
|
@@ -859,11 +928,19 @@ export class AssistantMessageComponent extends Container {
|
|
|
859
928
|
this.#contentContainer.addChild(new Spacer(1));
|
|
860
929
|
this.#contentContainer.addChild(new Text(theme.fg("dim", errorPresentation.text), 1, 0));
|
|
861
930
|
} else if (!hasToolCalls && errorPresentation.kind === "full") {
|
|
862
|
-
if (
|
|
931
|
+
if (message.stopReason === "aborted") {
|
|
863
932
|
this.#contentContainer.addChild(new Spacer(1));
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
933
|
+
this.#contentContainer.addChild(new Text(theme.fg("error", errorPresentation.text), 1, 0));
|
|
934
|
+
} else {
|
|
935
|
+
// Non-aborted provider error: a truncatable inline block. Mark it so
|
|
936
|
+
// setExpanded re-renders even while the same error is pinned above.
|
|
937
|
+
this.#hasTruncatableError = true;
|
|
938
|
+
// Suppress the inline block only while pinned AND collapsed — the
|
|
939
|
+
// banner already shows the capped error there. When expanded, draw
|
|
940
|
+
// the inline block in full so the complete body is reachable without
|
|
941
|
+
// sending a message; the pinned banner stays a short reminder.
|
|
942
|
+
if (!(message.stopReason === "error" && this.#errorPinned) || this.#errorExpanded) {
|
|
943
|
+
this.#contentContainer.addChild(new Spacer(1));
|
|
867
944
|
this.#appendErrorBlock(errorPresentation.text);
|
|
868
945
|
}
|
|
869
946
|
}
|
|
@@ -303,6 +303,7 @@ export class ChatTranscriptBuilder {
|
|
|
303
303
|
proseOnlyThinking,
|
|
304
304
|
);
|
|
305
305
|
assistantComponent.setImagesVisible(settings.get("terminal.showImages"));
|
|
306
|
+
this.#trackExpandable(assistantComponent);
|
|
306
307
|
this.container.addChild(assistantComponent);
|
|
307
308
|
|
|
308
309
|
if (settings.get("display.cacheMissMarker")) {
|
|
@@ -334,6 +335,7 @@ export class ChatTranscriptBuilder {
|
|
|
334
335
|
proseOnlyThinking,
|
|
335
336
|
);
|
|
336
337
|
component.setImagesVisible(settings.get("terminal.showImages"));
|
|
338
|
+
this.#trackExpandable(component);
|
|
337
339
|
this.container.addChild(component);
|
|
338
340
|
};
|
|
339
341
|
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
CustomEditor,
|
|
9
9
|
extractBracketedImagePastePaths,
|
|
10
10
|
extractBracketedPastePaths,
|
|
11
|
+
extractImagePastePathsFromText,
|
|
11
12
|
extractImagePathFromText,
|
|
12
13
|
extractPastePathsFromText,
|
|
13
14
|
SPACE_HOLD_MECHANICAL_RUN,
|
|
@@ -182,6 +183,43 @@ describe("CustomEditor bracketed path paste", () => {
|
|
|
182
183
|
expect(editor.getText()).toBe("/tmp/report.csv");
|
|
183
184
|
expect(imagePathCalls).toBe(0);
|
|
184
185
|
});
|
|
186
|
+
|
|
187
|
+
it("attaches a spaced screenshot path as an image instead of inserting it as literal text", () => {
|
|
188
|
+
// #6578: the raw macOS "copy screenshot path" payload has unescaped
|
|
189
|
+
// spaces, which defeats the segment splitter. Before the whole-text
|
|
190
|
+
// fallback the paste degraded to literal text in the prompt.
|
|
191
|
+
const { editor } = makeEditor();
|
|
192
|
+
const screenshot = "/Users/me/Desktop/Screenshot 2026-07-24 at 1.55.12 PM.png";
|
|
193
|
+
const pasted: string[] = [];
|
|
194
|
+
editor.onPasteImagePath = path => {
|
|
195
|
+
pasted.push(path);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
editor.handleInput(bracketedPaste(screenshot));
|
|
199
|
+
|
|
200
|
+
expect(pasted).toEqual([screenshot]);
|
|
201
|
+
expect(editor.getText()).toBe("");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("keeps a two-file drag with unescaped spaces as text instead of attaching one fused path", () => {
|
|
205
|
+
// PR #6582 review: selecting two screenshots and dropping them together
|
|
206
|
+
// emits a single space-separated payload the splitter also refuses
|
|
207
|
+
// (`PM.png` carries no directory). Fusing it into one path attaches
|
|
208
|
+
// nothing — `handleImagePathPaste` hits ENOENT and only shows a status,
|
|
209
|
+
// never restoring the text — so the drop must degrade to a text paste.
|
|
210
|
+
const { editor } = makeEditor();
|
|
211
|
+
const dropped =
|
|
212
|
+
"/Users/me/Desktop/Screenshot 2026-07-24 at 1.55.12 PM.png /Users/me/Desktop/Screenshot 2026-07-24 at 1.56.00 PM.png";
|
|
213
|
+
const pasted: string[] = [];
|
|
214
|
+
editor.onPasteImagePath = path => {
|
|
215
|
+
pasted.push(path);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
editor.handleInput(bracketedPaste(dropped));
|
|
219
|
+
|
|
220
|
+
expect(pasted).toEqual([]);
|
|
221
|
+
expect(editor.getText()).toBe(dropped);
|
|
222
|
+
});
|
|
185
223
|
});
|
|
186
224
|
describe("CustomEditor configured paste image keys", () => {
|
|
187
225
|
it("routes Ghostty Cmd+V kitty key events through the macOS image-paste default", () => {
|
|
@@ -250,6 +288,12 @@ describe("extractImagePathFromText (issue #3506)", () => {
|
|
|
250
288
|
);
|
|
251
289
|
});
|
|
252
290
|
|
|
291
|
+
it("returns undefined for two spaced paths the splitter could not separate", () => {
|
|
292
|
+
// Only the whole-text pass survives the splitter here, and it must not
|
|
293
|
+
// fuse the pair into one path the loader can never resolve.
|
|
294
|
+
expect(extractImagePathFromText("/tmp/a.png /tmp/b shot.png")).toBeUndefined();
|
|
295
|
+
});
|
|
296
|
+
|
|
253
297
|
it("does not hijack prose that happens to contain a path-shaped fragment", () => {
|
|
254
298
|
// The whole-text branch is gated on ABSOLUTE_PATH_PREFIX_REGEX, so a
|
|
255
299
|
// non-anchored prefix ("see ...") never triggers it.
|
|
@@ -264,6 +308,132 @@ describe("extractPastePathsFromText", () => {
|
|
|
264
308
|
});
|
|
265
309
|
});
|
|
266
310
|
|
|
311
|
+
describe("extractImagePastePathsFromText (issue #6578)", () => {
|
|
312
|
+
const MAC_SCREENSHOT =
|
|
313
|
+
"/var/folders/xx/T/TemporaryItems/NSIRD_screencaptureui_ab/Screenshot 2026-07-24 at 1.55.12 PM.png";
|
|
314
|
+
const WINDOWS_SPACED = "C:\\Users\\me\\My Pictures\\shot 1.png";
|
|
315
|
+
|
|
316
|
+
// Every case must resolve identically on the stripped-marker route
|
|
317
|
+
// (assembled pastes) and the bracketed route, since the latter now
|
|
318
|
+
// delegates to the former.
|
|
319
|
+
const cases: { name: string; text: string; expected: string[] | undefined }[] = [
|
|
320
|
+
{ name: "a macOS screenshot path with unescaped spaces", text: MAC_SCREENSHOT, expected: [MAC_SCREENSHOT] },
|
|
321
|
+
{ name: "a Windows drive path with unescaped spaces", text: WINDOWS_SPACED, expected: [WINDOWS_SPACED] },
|
|
322
|
+
{
|
|
323
|
+
name: "a home-anchored path with unescaped spaces",
|
|
324
|
+
text: "~/Pictures/Cleanshot 2026-07-24 at 12.00.png",
|
|
325
|
+
expected: ["~/Pictures/Cleanshot 2026-07-24 at 12.00.png"],
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
name: "a shell-escaped spaced path",
|
|
329
|
+
text: "/tmp/My\\ Photos/shot\\ 1.png",
|
|
330
|
+
expected: ["/tmp/My Photos/shot 1.png"],
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
name: "a double-quoted spaced path",
|
|
334
|
+
text: '"/tmp/My Photos/shot 1.png"',
|
|
335
|
+
expected: ["/tmp/My Photos/shot 1.png"],
|
|
336
|
+
},
|
|
337
|
+
{ name: "a spaced path with a non-image extension", text: "/tmp/my report 2026.csv", expected: undefined },
|
|
338
|
+
{
|
|
339
|
+
// Ends in a real image extension, so only the absolute-prefix
|
|
340
|
+
// anchor keeps the whole-text fallback from swallowing the prose.
|
|
341
|
+
name: "prose ending in a path-shaped fragment",
|
|
342
|
+
text: "see /Users/me/Desktop/Screen Shot 1.png",
|
|
343
|
+
expected: undefined,
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: "two spaced image paths on separate lines",
|
|
347
|
+
text: "/tmp/a shot.png\n/tmp/b shot.png",
|
|
348
|
+
expected: undefined,
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
name: "a bare spaced filename with no leading separator",
|
|
352
|
+
text: "Screenshot 2026-07-24 at 1.55.12 PM.png",
|
|
353
|
+
expected: undefined,
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
name: "two POSIX paths dragged together when one has unescaped spaces",
|
|
357
|
+
text: "/tmp/a.png /tmp/b shot.png",
|
|
358
|
+
expected: undefined,
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
name: "two macOS screenshots dragged together",
|
|
362
|
+
text: `${MAC_SCREENSHOT} /var/folders/xx/T/TemporaryItems/NSIRD_screencaptureui_ab/Screenshot 2026-07-24 at 1.56.00 PM.png`,
|
|
363
|
+
expected: undefined,
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
name: "two home-anchored paths with unescaped spaces",
|
|
367
|
+
text: "~/a.png ~/Pictures/b shot.png",
|
|
368
|
+
expected: undefined,
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
name: "two Windows drive paths with unescaped spaces",
|
|
372
|
+
text: `C:\\Users\\me\\a.png ${WINDOWS_SPACED}`,
|
|
373
|
+
expected: undefined,
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: "two `file://` URLs with unescaped spaces",
|
|
377
|
+
text: "file:///tmp/a.png file:///tmp/b shot.png",
|
|
378
|
+
expected: undefined,
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
name: "two UNC paths with unescaped spaces",
|
|
382
|
+
text: "\\\\srv\\share\\a.png \\\\srv\\share\\b shot.png",
|
|
383
|
+
expected: undefined,
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
name: "a tab-separated pair of dragged paths",
|
|
387
|
+
text: "/tmp/a.png\t/tmp/b shot.png",
|
|
388
|
+
expected: undefined,
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
name: "an absolute path followed by a dot-relative path with unescaped spaces",
|
|
392
|
+
text: "/tmp/a.png ./b shot.png",
|
|
393
|
+
expected: undefined,
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
name: "an absolute path followed by a parent-relative path with unescaped spaces",
|
|
397
|
+
text: "/tmp/a.png ../pics/b shot.png",
|
|
398
|
+
expected: undefined,
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: "a Windows drive path followed by a dot-relative path with unescaped spaces",
|
|
402
|
+
text: "C:\\Users\\me\\a.png .\\b shot.png",
|
|
403
|
+
expected: undefined,
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
// The interior `Photos/shot` token after an unescaped space is the
|
|
407
|
+
// shape of a spaced directory name, not of a second dragged path —
|
|
408
|
+
// this is why bare relatives are not multi-path anchors.
|
|
409
|
+
name: "a single path with an unescaped spaced directory name",
|
|
410
|
+
text: "/Users/me/My Photos/shot 1.png",
|
|
411
|
+
expected: ["/Users/me/My Photos/shot 1.png"],
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
// The escape asserts the space belongs to the path, so the `/sub`
|
|
415
|
+
// that follows is a component rather than a second drag payload.
|
|
416
|
+
name: "a path whose escaped space precedes a slash-led component",
|
|
417
|
+
text: "/tmp/odd dir\\ /sub/a b.png",
|
|
418
|
+
expected: ["/tmp/odd dir /sub/a b.png"],
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
// Splitter-success path: both segments are explicit, so the
|
|
422
|
+
// whole-text pass never runs and the pair still attaches as two.
|
|
423
|
+
name: "two explicit image paths the splitter can separate",
|
|
424
|
+
text: "/tmp/a.png /tmp/b.png",
|
|
425
|
+
expected: ["/tmp/a.png", "/tmp/b.png"],
|
|
426
|
+
},
|
|
427
|
+
];
|
|
428
|
+
|
|
429
|
+
for (const { name, text, expected } of cases) {
|
|
430
|
+
it(`${expected ? "recovers" : "rejects"} ${name} on both paste routes`, () => {
|
|
431
|
+
expect(extractImagePastePathsFromText(text)).toEqual(expected);
|
|
432
|
+
expect(extractBracketedImagePastePaths(bracketedPaste(text))).toEqual(expected);
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
267
437
|
describe("CustomEditor space-hold push-to-talk", () => {
|
|
268
438
|
beforeAll(async () => {
|
|
269
439
|
await initTheme();
|