@gajae-code/coding-agent 0.9.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/types/config/settings-schema.d.ts +14 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
- package/dist/types/modes/components/thinking-selector.d.ts +2 -2
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
- package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
- package/dist/types/notifications/config-commands.d.ts +9 -0
- package/dist/types/notifications/config.d.ts +16 -0
- package/dist/types/notifications/index.d.ts +2 -0
- package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
- package/dist/types/notifications/reply-sent-store.d.ts +53 -0
- package/dist/types/notifications/rich-draft.d.ts +68 -0
- package/dist/types/notifications/rich-render.d.ts +90 -0
- package/dist/types/notifications/telegram-daemon-cli.d.ts +3 -3
- package/dist/types/notifications/telegram-daemon.d.ts +22 -0
- package/dist/types/notifications/telegram-reference.d.ts +7 -0
- package/dist/types/notifications/threaded-render.d.ts +8 -0
- package/dist/types/utils/pasted-image-path.d.ts +30 -0
- package/package.json +8 -11
- package/src/cli/notify-cli.ts +11 -9
- package/src/config/keybindings.ts +1 -1
- package/src/config/settings-schema.ts +8 -0
- package/src/gjc-runtime/launch-tmux.ts +6 -0
- package/src/gjc-runtime/team-runtime.ts +11 -4
- package/src/gjc-runtime/tmux-common.ts +1 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/lsp/client.ts +1 -0
- package/src/modes/components/footer.ts +7 -2
- package/src/modes/components/thinking-selector.ts +10 -8
- package/src/modes/controllers/event-controller.ts +11 -2
- package/src/modes/controllers/extension-ui-controller.ts +8 -1
- package/src/modes/controllers/input-controller.ts +12 -17
- package/src/modes/controllers/selector-controller.ts +41 -0
- package/src/modes/interactive-mode.ts +5 -0
- package/src/modes/rpc/rpc-mode.ts +3 -9
- package/src/modes/rpc/rpc-socket-security.ts +13 -1
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/injected-user-submission.ts +94 -0
- package/src/notifications/config-commands.ts +20 -0
- package/src/notifications/config.ts +27 -0
- package/src/notifications/index.ts +28 -5
- package/src/notifications/lifecycle-commands.ts +37 -16
- package/src/notifications/lifecycle-control-runtime.ts +8 -3
- package/src/notifications/reply-sent-store.ts +134 -0
- package/src/notifications/rich-draft.ts +107 -0
- package/src/notifications/rich-render.ts +142 -0
- package/src/notifications/telegram-daemon-cli.ts +52 -5
- package/src/notifications/telegram-daemon.ts +346 -89
- package/src/notifications/telegram-reference.ts +17 -0
- package/src/notifications/threaded-render.ts +28 -1
- package/src/prompts/tools/search-tool-bm25.md +5 -0
- package/src/sdk.ts +36 -8
- package/src/session/agent-session.ts +6 -4
- package/src/slash-commands/builtin-registry.ts +78 -1
- package/src/utils/pasted-image-path.ts +170 -0
|
@@ -21,3 +21,8 @@ Returns JSON with:
|
|
|
21
21
|
- `activated_tools` — tools activated by this search call
|
|
22
22
|
- `match_count` — number of ranked matches returned by the search
|
|
23
23
|
- `total_tools`
|
|
24
|
+
|
|
25
|
+
Match details include:
|
|
26
|
+
- `server_name` — MCP server name when the activated result is an MCP tool
|
|
27
|
+
- `mcp_tool_name` — original MCP tool name when applicable
|
|
28
|
+
- `schema_keys` — searchable input property names
|
package/src/sdk.ts
CHANGED
|
@@ -121,7 +121,12 @@ import {
|
|
|
121
121
|
} from "./system-prompt";
|
|
122
122
|
import { AgentOutputManager } from "./task/output-manager";
|
|
123
123
|
import { parseThinkingLevel, resolveThinkingLevelForModel, toReasoningEffort } from "./thinking";
|
|
124
|
-
import {
|
|
124
|
+
import {
|
|
125
|
+
collectDiscoverableTools,
|
|
126
|
+
type DiscoverableTool,
|
|
127
|
+
isMCPBridgeTool,
|
|
128
|
+
isMCPToolName,
|
|
129
|
+
} from "./tool-discovery/tool-index";
|
|
125
130
|
import {
|
|
126
131
|
applyConfiguredSearchTimeout,
|
|
127
132
|
BashTool,
|
|
@@ -1789,7 +1794,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1789
1794
|
{ source: "builtin" },
|
|
1790
1795
|
)
|
|
1791
1796
|
: [];
|
|
1792
|
-
const
|
|
1797
|
+
const discoverableMCPTools: DiscoverableTool[] = mcpDiscoveryEnabled
|
|
1798
|
+
? collectDiscoverableTools(
|
|
1799
|
+
Array.from(tools.values()).filter(
|
|
1800
|
+
tool => isMCPToolName(tool.name) && !activeToolNames.has(tool.name),
|
|
1801
|
+
),
|
|
1802
|
+
{ source: "mcp" },
|
|
1803
|
+
)
|
|
1804
|
+
: [];
|
|
1805
|
+
const discoverableToolsForDesc: DiscoverableTool[] = [...discoverableBuiltinTools, ...discoverableMCPTools];
|
|
1793
1806
|
const promptTools = buildSystemPromptToolMetadata(tools, {
|
|
1794
1807
|
search_tool_bm25: { description: renderSearchToolBm25Description(discoverableToolsForDesc) },
|
|
1795
1808
|
});
|
|
@@ -1838,8 +1851,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1838
1851
|
intentField,
|
|
1839
1852
|
mcpDiscoveryMode: false,
|
|
1840
1853
|
mcpDiscoveryServerSummaries: [],
|
|
1841
|
-
toolDiscoveryActive: effectiveDiscoveryMode === "all",
|
|
1842
|
-
discoverableTools: discoverableBuiltinTools
|
|
1854
|
+
toolDiscoveryActive: effectiveDiscoveryMode === "all" || mcpDiscoveryEnabled,
|
|
1855
|
+
discoverableTools: [...discoverableBuiltinTools, ...discoverableMCPTools]
|
|
1843
1856
|
.map(tool => ({ name: tool.name, summary: tool.summary }))
|
|
1844
1857
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
1845
1858
|
eagerTasks,
|
|
@@ -1870,11 +1883,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1870
1883
|
: toolNamesFromRegistry;
|
|
1871
1884
|
const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name));
|
|
1872
1885
|
const requestedToolNameSet = new Set(normalizedRequested);
|
|
1873
|
-
//
|
|
1874
|
-
//
|
|
1886
|
+
// Generic tool discovery hides non-essential built-ins behind search_tool_bm25.
|
|
1887
|
+
// Legacy MCP discovery remains separately controlled by mcp.discoveryMode so
|
|
1888
|
+
// loaded MCP catalogs can be searched without enabling every hidden built-in.
|
|
1875
1889
|
const toolsDiscoveryModeSetting = settings.get("tools.discoveryMode");
|
|
1876
1890
|
const effectiveDiscoveryMode: "off" | "all" = toolsDiscoveryModeSetting === "all" ? "all" : "off";
|
|
1877
|
-
const mcpDiscoveryEnabled =
|
|
1891
|
+
const mcpDiscoveryEnabled = settings.get("mcp.discoveryMode") === true;
|
|
1878
1892
|
const defaultInactiveToolNames = new Set(
|
|
1879
1893
|
registeredTools.filter(tool => tool.definition.defaultInactive).map(tool => tool.definition.name),
|
|
1880
1894
|
);
|
|
@@ -1887,6 +1901,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1887
1901
|
const discoveryDefaultServerToolNames: string[] = [];
|
|
1888
1902
|
let initialSelectedMCPToolNames: string[] = [];
|
|
1889
1903
|
let defaultSelectedMCPToolNames: string[] = [];
|
|
1904
|
+
if (mcpDiscoveryEnabled) {
|
|
1905
|
+
const defaultServerNames = new Set(settings.get("mcp.discoveryDefaultServers") ?? []);
|
|
1906
|
+
for (const tool of toolRegistry.values()) {
|
|
1907
|
+
if (!isMCPBridgeTool(tool)) continue;
|
|
1908
|
+
discoverableMCPToolNames.add(tool.name);
|
|
1909
|
+
if (initialRequestedActiveToolNames.includes(tool.name)) {
|
|
1910
|
+
explicitlyRequestedMCPToolNames.push(tool.name);
|
|
1911
|
+
}
|
|
1912
|
+
const serverName = (tool as AgentTool & { mcpServerName?: string }).mcpServerName;
|
|
1913
|
+
if (serverName && defaultServerNames.has(serverName)) {
|
|
1914
|
+
discoveryDefaultServerToolNames.push(tool.name);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1890
1918
|
let initialToolNames = [...initialRequestedActiveToolNames];
|
|
1891
1919
|
if (mcpDiscoveryEnabled) {
|
|
1892
1920
|
const restoredSelectedMCPToolNames = existingSession.selectedMCPToolNames.filter(name =>
|
|
@@ -2247,7 +2275,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
2247
2275
|
initialSelectedMCPToolNames,
|
|
2248
2276
|
defaultSelectedMCPToolNames,
|
|
2249
2277
|
persistInitialMCPToolSelection: !hasExistingSession,
|
|
2250
|
-
defaultSelectedMCPServerNames: [],
|
|
2278
|
+
defaultSelectedMCPServerNames: settings.get("mcp.discoveryDefaultServers") ?? [],
|
|
2251
2279
|
ttsrManager,
|
|
2252
2280
|
obfuscator,
|
|
2253
2281
|
agentId: resolvedAgentId,
|
|
@@ -1786,7 +1786,7 @@ export class AgentSession {
|
|
|
1786
1786
|
high = mid - 1;
|
|
1787
1787
|
}
|
|
1788
1788
|
}
|
|
1789
|
-
return { message: bestMessage, tokens:
|
|
1789
|
+
return { message: bestMessage, tokens: bestTokens };
|
|
1790
1790
|
};
|
|
1791
1791
|
|
|
1792
1792
|
for (let i = providerMessages.length - 1; i >= 0; i--) {
|
|
@@ -1806,9 +1806,11 @@ export class AgentSession {
|
|
|
1806
1806
|
recordSkip("token-limit");
|
|
1807
1807
|
if (selected.length === 0) {
|
|
1808
1808
|
const truncated = truncateMessageToTokenBudget(sanitized);
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1809
|
+
if (truncated.tokens <= maxTokens) {
|
|
1810
|
+
selected.unshift(truncated.message);
|
|
1811
|
+
approximateTokens = truncated.tokens;
|
|
1812
|
+
skippedReasons["newest-message-truncated"] = (skippedReasons["newest-message-truncated"] ?? 0) + 1;
|
|
1813
|
+
}
|
|
1812
1814
|
}
|
|
1813
1815
|
break;
|
|
1814
1816
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import
|
|
3
|
+
import { ThinkingLevel } from "@gajae-code/agent-core";
|
|
4
4
|
import { type Model, modelsAreEqual } from "@gajae-code/ai";
|
|
5
5
|
import { getOAuthProviders } from "@gajae-code/ai/utils/oauth";
|
|
6
6
|
import { Spacer, Text } from "@gajae-code/tui";
|
|
@@ -379,6 +379,60 @@ function modelSelectionUsage(runtime: SlashCommandRuntime, currentModelLine?: st
|
|
|
379
379
|
.join("\n\n");
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
const EFFORT_COMMAND_INPUT_HINT = "[inherit|off|minimal|low|medium|high|xhigh|max]";
|
|
383
|
+
const EFFORT_COMMAND_ACCEPTED_VALUES = ["inherit", "off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
384
|
+
|
|
385
|
+
function effortCommandUsage(prefix?: string): string {
|
|
386
|
+
return [prefix, `Usage: /effort ${EFFORT_COMMAND_INPUT_HINT}`]
|
|
387
|
+
.filter((line): line is string => Boolean(line))
|
|
388
|
+
.join("\n");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function formatEffortStatus(runtime: SlashCommandRuntime): string {
|
|
392
|
+
const current = runtime.session.thinkingLevel ?? ThinkingLevel.Off;
|
|
393
|
+
const configuredDefault = runtime.settings.get("defaultThinkingLevel");
|
|
394
|
+
const supported = runtime.session.getAvailableThinkingLevels();
|
|
395
|
+
return [
|
|
396
|
+
`Current effective effort: ${current}`,
|
|
397
|
+
`Configured default effort: ${configuredDefault}`,
|
|
398
|
+
`Accepted values: ${EFFORT_COMMAND_ACCEPTED_VALUES.join(", ")}`,
|
|
399
|
+
`Current-model supported levels: ${supported.length > 0 ? supported.join(", ") : "(none reported)"}`,
|
|
400
|
+
].join("\n");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function handleEffortCommand(
|
|
404
|
+
command: ParsedSlashCommand,
|
|
405
|
+
runtime: SlashCommandRuntime,
|
|
406
|
+
): Promise<SlashCommandResult> {
|
|
407
|
+
const tokens = command.args.trim().split(/\s+/).filter(Boolean);
|
|
408
|
+
if (tokens.length === 0) {
|
|
409
|
+
await runtime.output(formatEffortStatus(runtime));
|
|
410
|
+
return commandConsumed();
|
|
411
|
+
}
|
|
412
|
+
if (tokens.length !== 1) {
|
|
413
|
+
return usage(effortCommandUsage("Invalid effort input."), runtime);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const requestedToken = tokens[0];
|
|
417
|
+
const requestedLevel = parseThinkingLevel(requestedToken);
|
|
418
|
+
if (!requestedToken || !requestedLevel) {
|
|
419
|
+
return usage(effortCommandUsage(`Invalid effort: ${tokens[0] ?? ""}.`), runtime);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const levelToApply =
|
|
423
|
+
requestedLevel === ThinkingLevel.Inherit ? runtime.settings.get("defaultThinkingLevel") : requestedLevel;
|
|
424
|
+
runtime.session.setThinkingLevel(levelToApply, false);
|
|
425
|
+
const effectiveLevel = runtime.session.thinkingLevel ?? ThinkingLevel.Off;
|
|
426
|
+
const requestedLabel =
|
|
427
|
+
requestedLevel === ThinkingLevel.Inherit ? `${requestedLevel} (${levelToApply})` : requestedLevel;
|
|
428
|
+
const clampedSuffix =
|
|
429
|
+
effectiveLevel === levelToApply ? "" : ` Requested ${levelToApply}; effective ${effectiveLevel}.`;
|
|
430
|
+
await runtime.output(
|
|
431
|
+
`Reasoning effort set to ${requestedLabel}. Effective effort: ${effectiveLevel}.${clampedSuffix}`,
|
|
432
|
+
);
|
|
433
|
+
return commandConsumed();
|
|
434
|
+
}
|
|
435
|
+
|
|
382
436
|
function refreshStatusLine(ctx: InteractiveModeContext): void {
|
|
383
437
|
ctx.statusLine.invalidate();
|
|
384
438
|
ctx.updateEditorTopBorder();
|
|
@@ -587,6 +641,29 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
587
641
|
runtime.ctx.editor.setText("");
|
|
588
642
|
},
|
|
589
643
|
},
|
|
644
|
+
{
|
|
645
|
+
name: "effort",
|
|
646
|
+
description: "Show or set model reasoning effort",
|
|
647
|
+
acpDescription: "Show or set model reasoning effort",
|
|
648
|
+
inlineHint: EFFORT_COMMAND_INPUT_HINT,
|
|
649
|
+
acpInputHint: EFFORT_COMMAND_INPUT_HINT,
|
|
650
|
+
allowArgs: true,
|
|
651
|
+
handle: handleEffortCommand,
|
|
652
|
+
handleTui: async (command, runtime) => {
|
|
653
|
+
if (command.args.trim()) {
|
|
654
|
+
const result = await handleEffortCommand(command, toSlashCommandRuntime(runtime));
|
|
655
|
+
runtime.ctx.statusLine.invalidate();
|
|
656
|
+
runtime.ctx.updateEditorBorderColor();
|
|
657
|
+
runtime.ctx.updateEditorTopBorder();
|
|
658
|
+
runtime.ctx.editor.setText("");
|
|
659
|
+
runtime.ctx.ui.requestRender();
|
|
660
|
+
return result;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
runtime.ctx.showEffortSelector();
|
|
664
|
+
runtime.ctx.editor.setText("");
|
|
665
|
+
},
|
|
666
|
+
},
|
|
590
667
|
{
|
|
591
668
|
name: "fast",
|
|
592
669
|
description: "Toggle priority service tier (OpenAI service_tier=priority, Anthropic speed=fast)",
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve pasted editor text to an image file path.
|
|
3
|
+
*
|
|
4
|
+
* Terminals insert a shell-escaped filesystem path when a file is drag-dropped
|
|
5
|
+
* onto them (e.g. macOS `Screenshot\ 2026-07-07\ at\ 11.06.38 PM.png`, where
|
|
6
|
+
* the visible "space" before AM/PM is U+202F and stays unescaped). When the
|
|
7
|
+
* entire paste is a single path to an existing image file, the interactive
|
|
8
|
+
* editor attaches the image and inserts an `[image N]` placeholder instead of
|
|
9
|
+
* leaving the raw path in the prompt.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
const IMAGE_FILE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp)$/i;
|
|
17
|
+
|
|
18
|
+
export interface DecodePastedPathOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Platform whose path semantics apply when decoding (shell unescaping,
|
|
21
|
+
* `file://` drive letters / UNC hosts). Defaults to `process.platform`;
|
|
22
|
+
* injectable so tests can pin the win32 contract from any host.
|
|
23
|
+
*/
|
|
24
|
+
platform?: NodeJS.Platform;
|
|
25
|
+
/** Home directory for `~/` expansion. Defaults to `os.homedir()`. */
|
|
26
|
+
homedir?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ResolvePastedImagePathOptions extends DecodePastedPathOptions {
|
|
30
|
+
/** Base directory for relative paths. Defaults to `process.cwd()`. */
|
|
31
|
+
cwd?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Convert a `file://` URI to a filesystem path for the given platform, or
|
|
36
|
+
* `undefined` when the URI is invalid.
|
|
37
|
+
*
|
|
38
|
+
* The production path (injected platform === host platform, always true
|
|
39
|
+
* outside tests) delegates to Node's `fileURLToPath`. When tests inject a
|
|
40
|
+
* foreign platform, a mirror of `fileURLToPath`'s rules is applied instead —
|
|
41
|
+
* Bun (1.3) accepts but ignores `fileURLToPath`'s `windows` option, so the
|
|
42
|
+
* native call cannot emulate a foreign platform.
|
|
43
|
+
*/
|
|
44
|
+
function decodeFileUrl(candidate: string, platform: NodeJS.Platform): string | undefined {
|
|
45
|
+
let url: URL;
|
|
46
|
+
try {
|
|
47
|
+
url = new URL(candidate);
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
if (url.protocol !== "file:") return undefined;
|
|
52
|
+
|
|
53
|
+
if (platform === process.platform) {
|
|
54
|
+
try {
|
|
55
|
+
return fileURLToPath(url);
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Foreign-platform mirror of fileURLToPath (tests only).
|
|
62
|
+
if (/%2f/i.test(url.pathname) || (platform === "win32" && /%5c/i.test(url.pathname))) return undefined;
|
|
63
|
+
let pathname: string;
|
|
64
|
+
try {
|
|
65
|
+
pathname = decodeURIComponent(url.pathname);
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
if (platform === "win32") {
|
|
70
|
+
if (url.hostname && url.hostname !== "localhost") {
|
|
71
|
+
// UNC: file://server/share/img.png -> \\server\share\img.png
|
|
72
|
+
return `\\\\${url.hostname}${pathname.replaceAll("/", "\\")}`;
|
|
73
|
+
}
|
|
74
|
+
// Drive letter required: file:///C:/x -> C:\x
|
|
75
|
+
if (!/^\/[A-Za-z]:/.test(pathname)) return undefined;
|
|
76
|
+
return pathname.slice(1).replaceAll("/", "\\");
|
|
77
|
+
}
|
|
78
|
+
if (url.hostname && url.hostname !== "localhost") return undefined;
|
|
79
|
+
return pathname;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Decode pasted text into a filesystem path candidate. No filesystem access.
|
|
84
|
+
*
|
|
85
|
+
* Handles terminal drag-drop shell escaping (`\ `, `\(`, ...; skipped on
|
|
86
|
+
* win32 where `\` is the path separator), quoted paths, `file://` URIs
|
|
87
|
+
* (drive-letter, `file://localhost`, and UNC forms on win32), and `~/`
|
|
88
|
+
* expansion. Returns `undefined` when the text is empty, spans multiple
|
|
89
|
+
* lines, or is an invalid `file://` URI.
|
|
90
|
+
*/
|
|
91
|
+
export function decodePastedPathCandidate(text: string, options?: DecodePastedPathOptions): string | undefined {
|
|
92
|
+
const platform = options?.platform ?? process.platform;
|
|
93
|
+
let candidate = text.trim();
|
|
94
|
+
if (!candidate || /[\r\n]/.test(candidate)) return undefined;
|
|
95
|
+
|
|
96
|
+
// Quoted paths (some terminals quote instead of escaping).
|
|
97
|
+
if (
|
|
98
|
+
candidate.length >= 2 &&
|
|
99
|
+
(candidate.startsWith('"') || candidate.startsWith("'")) &&
|
|
100
|
+
candidate.endsWith(candidate[0])
|
|
101
|
+
) {
|
|
102
|
+
candidate = candidate.slice(1, -1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (candidate.startsWith("file://")) {
|
|
106
|
+
const decoded = decodeFileUrl(candidate, platform);
|
|
107
|
+
if (decoded === undefined) return undefined;
|
|
108
|
+
candidate = decoded;
|
|
109
|
+
} else if (platform !== "win32") {
|
|
110
|
+
// Terminal drag-drop escapes shell-special characters (`\ `, `\(`, ...).
|
|
111
|
+
// Skipped on Windows where `\` is the path separator.
|
|
112
|
+
candidate = candidate.replace(/\\(.)/g, "$1");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (candidate.startsWith("~/")) {
|
|
116
|
+
candidate = path.join(options?.homedir ?? os.homedir(), candidate.slice(2));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return candidate;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Sniff the file header for a supported image signature (PNG, JPEG, GIF,
|
|
124
|
+
* WEBP). Prevents existing non-image files with image-looking extensions
|
|
125
|
+
* (e.g. a text file named `not-image.png`) from being treated as image
|
|
126
|
+
* candidates — consuming such a paste would lose the raw path when the
|
|
127
|
+
* image loader later rejects the content.
|
|
128
|
+
*/
|
|
129
|
+
function hasSupportedImageMagic(filePath: string): boolean {
|
|
130
|
+
let fd: number;
|
|
131
|
+
try {
|
|
132
|
+
fd = fs.openSync(filePath, "r");
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
// Zero-filled; short reads leave trailing zeros so comparisons fail safely.
|
|
138
|
+
const header = Buffer.alloc(12);
|
|
139
|
+
fs.readSync(fd, header, 0, 12, 0);
|
|
140
|
+
if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4e && header[3] === 0x47) return true; // PNG
|
|
141
|
+
if (header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) return true; // JPEG
|
|
142
|
+
const ascii6 = header.toString("latin1", 0, 6);
|
|
143
|
+
if (ascii6 === "GIF87a" || ascii6 === "GIF89a") return true; // GIF
|
|
144
|
+
if (header.toString("latin1", 0, 4) === "RIFF" && header.toString("latin1", 8, 12) === "WEBP") return true; // WEBP
|
|
145
|
+
return false;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
} finally {
|
|
149
|
+
fs.closeSync(fd);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Returns the resolved path when the whole pasted text is a single path to an
|
|
155
|
+
* existing image file (verified by extension AND content signature),
|
|
156
|
+
* otherwise `undefined` (the paste is inserted as text).
|
|
157
|
+
*/
|
|
158
|
+
export function resolvePastedImagePath(text: string, options?: ResolvePastedImagePathOptions): string | undefined {
|
|
159
|
+
const candidate = decodePastedPathCandidate(text, options);
|
|
160
|
+
if (!candidate || !IMAGE_FILE_EXTENSION_PATTERN.test(candidate)) return undefined;
|
|
161
|
+
|
|
162
|
+
const resolved = path.resolve(options?.cwd ?? process.cwd(), candidate);
|
|
163
|
+
try {
|
|
164
|
+
if (!fs.statSync(resolved).isFile()) return undefined;
|
|
165
|
+
} catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
if (!hasSupportedImageMagic(resolved)) return undefined;
|
|
169
|
+
return resolved;
|
|
170
|
+
}
|