@hienlh/ppm 0.17.32 → 0.17.34
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 +16 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{ai-resource-editor-ByZApv3Y.js → ai-resource-editor-QNfSU0Fi.js} +1 -1
- package/dist/web/assets/{audio-preview-izB0KGPQ.js → audio-preview-IokB77Cj.js} +1 -1
- package/dist/web/assets/chat-tab-BZNEvS88.js +13 -0
- package/dist/web/assets/{code-editor-Bx-Q27Nm.js → code-editor-B8YiBPOX.js} +2 -2
- package/dist/web/assets/{conflict-editor-B60UimS8.js → conflict-editor-CY1o5kBO.js} +1 -1
- package/dist/web/assets/{csv-preview-BZv2SuCz.js → csv-preview-AEg0GB-V.js} +1 -1
- package/dist/web/assets/{database-viewer-B5J_4lvX.js → database-viewer-C5Meg0s_.js} +1 -1
- package/dist/web/assets/{diff-viewer-DiytoNvX.js → diff-viewer-BRRGvZnp.js} +1 -1
- package/dist/web/assets/{docx-preview-CrjmF16z.js → docx-preview-DqTX7NDp.js} +1 -1
- package/dist/web/assets/{edit-diff-preview-BSjgyGAL.js → edit-diff-preview-_CXUdwsF.js} +1 -1
- package/dist/web/assets/{extension-webview-BSiQUxFD.js → extension-webview-CQceYb7O.js} +1 -1
- package/dist/web/assets/{git-log-panel-Fb5EefXW.js → git-log-panel-D7QlAJqu.js} +1 -1
- package/dist/web/assets/{glide-data-grid-Cjve7mDb.js → glide-data-grid-cGxkgluR.js} +1 -1
- package/dist/web/assets/group-chat-tab-B-eA5ytm.js +1 -0
- package/dist/web/assets/{image-preview-BiVYVqa9.js → image-preview-DRUzYptI.js} +1 -1
- package/dist/web/assets/{index-CuAijZGh.js → index-BuX7JkCt.js} +5 -5
- package/dist/web/assets/index-PX2pZ3bH.css +2 -0
- package/dist/web/assets/{keybindings-store-sakq3gsF.js → keybindings-store-B3plqyxF.js} +1 -1
- package/dist/web/assets/{markdown-renderer-BsKIIM83.js → markdown-renderer-BKDbjc3g.js} +1 -1
- package/dist/web/assets/{markdown-renderer-Cct9JHnx.js → markdown-renderer-C9Gy8tM2.js} +1 -1
- package/dist/web/assets/{notification-store-D4TsAnN3.js → notification-store-jFDauzse.js} +1 -1
- package/dist/web/assets/panel-store-D-RxcUgH.js +1 -0
- package/dist/web/assets/{pdf-preview-B6G1QpE6.js → pdf-preview-CzHUqXJB.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CdFu2Yj9.js → postgres-viewer-BeantJZe.js} +1 -1
- package/dist/web/assets/{settings-tab-Dh3bD_dg.js → settings-tab-CqQHhqV1.js} +1 -1
- package/dist/web/assets/{sql-query-editor-C8XXOY2R.js → sql-query-editor-DqU9zfVy.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-B1eXvaLK.js → sqlite-viewer-BH891DC0.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-D2QcCXl1.js → system-monitor-tab-Bue4p4Vb.js} +1 -1
- package/dist/web/assets/{tab-store-ByORqglW.js → tab-store-CNxCwdXw.js} +1 -1
- package/dist/web/assets/{terminal-tab-D2-buBAQ.js → terminal-tab-BfNnKU71.js} +1 -1
- package/dist/web/assets/tool-cards-CSOwe7_i.js +7 -0
- package/dist/web/assets/use-blob-url-jSRmyXVg.js +1 -0
- package/dist/web/assets/{use-monaco-theme-DMlVAYKD.js → use-monaco-theme-5XjAzOzE.js} +1 -1
- package/dist/web/assets/{video-preview-Cg3A7vrt.js → video-preview-DMqLOqmc.js} +1 -1
- package/dist/web/index.html +4 -4
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/skills-lock.json +11 -0
- package/spike-memory-region-dump.ps1 +69 -0
- package/src/providers/claude-agent-sdk.ts +4 -3
- package/src/server/routes/chat.ts +9 -3
- package/src/server/routes/fs-browse.ts +13 -2
- package/src/services/db.service.ts +25 -1
- package/src/services/jsonl-transcript-parser.ts +2 -1
- package/src/services/slash-discovery/builtin-commands.ts +7 -3
- package/src/services/slash-discovery/index.ts +8 -1
- package/src/services/slash-discovery/sdk-commands.ts +126 -0
- package/src/services/slash-discovery/types.ts +5 -0
- package/src/shared/fuzzy-search.ts +37 -5
- package/src/shared/image-extensions.ts +19 -0
- package/src/shared/tool-result-content.ts +70 -0
- package/src/web/components/chat/chat-tab.tsx +25 -5
- package/src/web/components/chat/image-preview-geometry.ts +83 -0
- package/src/web/components/chat/image-preview-states.tsx +51 -0
- package/src/web/components/chat/slash-command-picker.tsx +2 -0
- package/src/web/components/chat/tool-cards.tsx +27 -2
- package/src/web/components/chat/tool-image-preview.tsx +225 -0
- package/src/web/components/editor/audio-preview.tsx +1 -1
- package/src/web/components/editor/image-preview.tsx +1 -1
- package/src/web/components/editor/video-preview.tsx +1 -1
- package/src/web/components/layout/tab-bar.tsx +3 -3
- package/src/web/{components/editor → hooks}/use-blob-url.ts +14 -3
- package/src/web/stores/dock-actions.ts +14 -4
- package/src/web/stores/panel-store.ts +8 -14
- package/src/web/styles/globals.css +72 -0
- package/dist/web/assets/chat-tab-CwWL5DbD.js +0 -13
- package/dist/web/assets/group-chat-tab-DT0X7Cbz.js +0 -1
- package/dist/web/assets/index-BW2cOdzp.css +0 -2
- package/dist/web/assets/panel-store-DsDZX8Mi.js +0 -1
- package/dist/web/assets/tool-cards-DDxShmxN.js +0 -7
- package/dist/web/assets/use-blob-url-kvtWmJtt.js +0 -1
- /package/dist/web/assets/{csv-parser-BSnYgPcx.js → csv-parser-BPS94T2h.js} +0 -0
- /package/dist/web/assets/{libesm-Dd8zbj1v.js → libesm-BS7D9de3.js} +0 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { invalidateCache } from "./cache.ts";
|
|
2
|
+
import type { SlashItem } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Claude's own built-ins (/context, /init, /usage, …) live inside the CLI binary,
|
|
6
|
+
* so the filesystem scan in skill-loader.ts can never see them. The only way to
|
|
7
|
+
* enumerate them is to ask a live SDK session, which costs a CLI spawn — hence a
|
|
8
|
+
* much longer TTL than the filesystem cache.
|
|
9
|
+
*/
|
|
10
|
+
const TTL_MS = 30 * 60 * 1000;
|
|
11
|
+
const FETCH_TIMEOUT_MS = 20_000;
|
|
12
|
+
|
|
13
|
+
interface CacheEntry {
|
|
14
|
+
items: SlashItem[];
|
|
15
|
+
at: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const cache = new Map<string, CacheEntry>();
|
|
19
|
+
const inflight = new Map<string, Promise<SlashItem[]>>();
|
|
20
|
+
|
|
21
|
+
function isFresh(entry: CacheEntry | undefined): entry is CacheEntry {
|
|
22
|
+
return !!entry && Date.now() - entry.at < TTL_MS;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Keep only the SDK commands nothing else already provides. The SDK reports every
|
|
27
|
+
* command it knows — including the skills the filesystem scan just found — so
|
|
28
|
+
* dropping known names leaves exactly the ones that live inside the CLI binary.
|
|
29
|
+
*/
|
|
30
|
+
export function selectSdkOnlyCommands(sdkItems: SlashItem[], knownNames: Iterable<string>): SlashItem[] {
|
|
31
|
+
const known = new Set(knownNames);
|
|
32
|
+
return sdkItems.filter((item) => !known.has(item.name));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Cached SDK commands, or [] when none were fetched yet. Never spawns. */
|
|
36
|
+
export function getSdkCommands(projectPath: string): SlashItem[] {
|
|
37
|
+
const entry = cache.get(projectPath);
|
|
38
|
+
return isFresh(entry) ? entry.items : [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Drop cached SDK commands so the next ensure() re-spawns. */
|
|
42
|
+
export function invalidateSdkCommands(projectPath?: string): void {
|
|
43
|
+
if (projectPath) cache.delete(projectPath);
|
|
44
|
+
else cache.clear();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function fetchFromSdk(projectPath: string): Promise<SlashItem[]> {
|
|
48
|
+
// Loaded lazily: the CLI (`ppm skills`) uses this module's sync path only and
|
|
49
|
+
// must not pull in the provider's config/database dependencies.
|
|
50
|
+
const [{ query }, provider] = await Promise.all([
|
|
51
|
+
import("@anthropic-ai/claude-agent-sdk"),
|
|
52
|
+
import("../../providers/claude-agent-sdk.ts"),
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const cliPath = provider.resolveCliExecutablePath();
|
|
56
|
+
|
|
57
|
+
// supportedCommands() resolves during the init handshake, but the CLI keeps
|
|
58
|
+
// running until its input stream ends — so hold the stream open until we're done.
|
|
59
|
+
let closeInput!: () => void;
|
|
60
|
+
const inputClosed = new Promise<void>((r) => { closeInput = r; });
|
|
61
|
+
async function* idleInput(): AsyncGenerator<never, void, undefined> {
|
|
62
|
+
await inputClosed;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const q = query({
|
|
66
|
+
prompt: idleInput(),
|
|
67
|
+
options: {
|
|
68
|
+
cwd: projectPath,
|
|
69
|
+
...(provider.needsNodeInterpreter(process.platform, cliPath) && { executable: "node" as const }),
|
|
70
|
+
...(cliPath && { pathToClaudeCodeExecutable: cliPath }),
|
|
71
|
+
settingSources: ["user", "project"],
|
|
72
|
+
maxTurns: 1,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
77
|
+
try {
|
|
78
|
+
const commands = await Promise.race([
|
|
79
|
+
q.supportedCommands(),
|
|
80
|
+
new Promise<never>((_, reject) => {
|
|
81
|
+
timer = setTimeout(() => reject(new Error("supportedCommands timed out")), FETCH_TIMEOUT_MS);
|
|
82
|
+
}),
|
|
83
|
+
]);
|
|
84
|
+
return commands.map((cmd) => ({
|
|
85
|
+
type: "builtin" as const,
|
|
86
|
+
name: cmd.name,
|
|
87
|
+
description: cmd.description ?? "",
|
|
88
|
+
argumentHint: cmd.argumentHint || undefined,
|
|
89
|
+
scope: "bundled" as const,
|
|
90
|
+
category: "session",
|
|
91
|
+
handler: "sdk" as const,
|
|
92
|
+
}));
|
|
93
|
+
} finally {
|
|
94
|
+
if (timer) clearTimeout(timer);
|
|
95
|
+
closeInput();
|
|
96
|
+
try { await q.interrupt?.(); } catch { /* already exiting */ }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fetch SDK commands, reusing the cache. Concurrent callers share one spawn.
|
|
102
|
+
* Failures are cached too — an unauthenticated CLI would otherwise re-spawn on
|
|
103
|
+
* every picker open; the picker's refresh button clears it.
|
|
104
|
+
*/
|
|
105
|
+
export function ensureSdkCommands(projectPath: string): Promise<SlashItem[]> {
|
|
106
|
+
const entry = cache.get(projectPath);
|
|
107
|
+
if (isFresh(entry)) return Promise.resolve(entry.items);
|
|
108
|
+
|
|
109
|
+
const existing = inflight.get(projectPath);
|
|
110
|
+
if (existing) return existing;
|
|
111
|
+
|
|
112
|
+
const pending = fetchFromSdk(projectPath)
|
|
113
|
+
.catch((e: unknown) => {
|
|
114
|
+
console.warn(`[slash] SDK command discovery failed: ${(e as Error).message}`);
|
|
115
|
+
return [] as SlashItem[];
|
|
116
|
+
})
|
|
117
|
+
.then((items) => {
|
|
118
|
+
cache.set(projectPath, { items, at: Date.now() });
|
|
119
|
+
invalidateCache(projectPath); // merged item list is now stale
|
|
120
|
+
return items;
|
|
121
|
+
})
|
|
122
|
+
.finally(() => { inflight.delete(projectPath); });
|
|
123
|
+
|
|
124
|
+
inflight.set(projectPath, pending);
|
|
125
|
+
return pending;
|
|
126
|
+
}
|
|
@@ -16,6 +16,9 @@ export type SlashItemType = "skill" | "command" | "builtin" | "agent";
|
|
|
16
16
|
export type SlashItemScope = "project" | "user" | "bundled";
|
|
17
17
|
export type ItemOrigin = "skills" | "commands" | "agents";
|
|
18
18
|
|
|
19
|
+
/** Who runs a built-in: PPM server, the web client, or the Claude SDK */
|
|
20
|
+
export type SlashHandler = "ppm" | "sdk" | "client";
|
|
21
|
+
|
|
19
22
|
export interface SlashItem {
|
|
20
23
|
type: SlashItemType;
|
|
21
24
|
/** Slash name, e.g. "review", "devops/deploy", "ck:research" */
|
|
@@ -30,6 +33,8 @@ export interface SlashItem {
|
|
|
30
33
|
model?: string;
|
|
31
34
|
/** Agent-only: allowed tools (parsed from comma-separated string or YAML list) */
|
|
32
35
|
tools?: string[];
|
|
36
|
+
/** Built-in only: which layer executes the command */
|
|
37
|
+
handler?: SlashHandler;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
export interface SkillRoot {
|
|
@@ -48,9 +48,22 @@ export function scoreFuzzy(query: string, candidate: string): FuzzyScore | null
|
|
|
48
48
|
return null;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Promotion a recently used item gets, measured in match tiers. One tier lets a
|
|
53
|
+
* recent "contains" hit edge out a stranger's "prefix" hit, while a two-tier gap
|
|
54
|
+
* (prefix vs fuzzy) still wins on relevance.
|
|
55
|
+
*
|
|
56
|
+
* Only granted when the query matched the item's NAME. Descriptions are prose and
|
|
57
|
+
* match almost any short query, so boosting those would float every recent item
|
|
58
|
+
* to the top of every search.
|
|
59
|
+
*/
|
|
60
|
+
const RECENT_TIER_BOOST = 1;
|
|
61
|
+
|
|
51
62
|
/**
|
|
52
63
|
* Search items by query with fuzzy matching.
|
|
53
|
-
* Recently used items
|
|
64
|
+
* Recently used items are promoted by one match tier and then ordered
|
|
65
|
+
* most-recent-first, so a filtered list keeps the recency ordering the
|
|
66
|
+
* unfiltered picker shows.
|
|
54
67
|
* Returns ranked results (best match first), truncated to limit.
|
|
55
68
|
*/
|
|
56
69
|
export function searchFuzzy<T extends FuzzySearchable>(
|
|
@@ -63,8 +76,10 @@ export function searchFuzzy<T extends FuzzySearchable>(
|
|
|
63
76
|
// Cap query length to prevent quadratic blowup in Levenshtein
|
|
64
77
|
query = query.slice(0, 50);
|
|
65
78
|
|
|
66
|
-
|
|
67
|
-
|
|
79
|
+
// Position in recentNames, not a boolean: the caller's list is already ordered
|
|
80
|
+
// most-recent-first and that order has to survive into the results.
|
|
81
|
+
const recentRank = new Map(recentNames.map((name, i) => [name, i]));
|
|
82
|
+
const scored: Array<{ item: T; rank: number; distance: number; recency: number }> = [];
|
|
68
83
|
|
|
69
84
|
for (const item of items) {
|
|
70
85
|
const nameScore = scoreFuzzy(query, item.name);
|
|
@@ -73,13 +88,30 @@ export function searchFuzzy<T extends FuzzySearchable>(
|
|
|
73
88
|
.filter((s): s is FuzzyScore => s !== null)
|
|
74
89
|
.sort((a, b) => a.rank - b.rank || a.distance - b.distance)[0];
|
|
75
90
|
|
|
76
|
-
if (best)
|
|
91
|
+
if (best) {
|
|
92
|
+
const recency = recentRank.get(item.name);
|
|
93
|
+
// "Name reached the winning tier", not "name outscored the description":
|
|
94
|
+
// a description can happen to match at a smaller offset and would then
|
|
95
|
+
// silently disqualify a perfectly good name match from the boost.
|
|
96
|
+
const boosted = recency !== undefined && nameScore?.rank === best.rank;
|
|
97
|
+
scored.push({
|
|
98
|
+
item,
|
|
99
|
+
rank: best.rank - (boosted ? RECENT_TIER_BOOST : 0),
|
|
100
|
+
distance: best.distance,
|
|
101
|
+
// Sentinel, not Infinity: Infinity - Infinity is NaN and would silently
|
|
102
|
+
// neutralise the comparator for every pair of non-recent items.
|
|
103
|
+
recency: recency ?? recentNames.length,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
77
106
|
}
|
|
78
107
|
|
|
108
|
+
// Recency outranks distance so it actually takes effect: for "contains" matches
|
|
109
|
+
// distance is the match offset, which differs per item and previously decided
|
|
110
|
+
// every comparison before recency was ever consulted.
|
|
79
111
|
scored.sort((a, b) =>
|
|
80
112
|
a.rank - b.rank
|
|
113
|
+
|| a.recency - b.recency
|
|
81
114
|
|| a.distance - b.distance
|
|
82
|
-
|| (a.recent === b.recent ? 0 : a.recent ? -1 : 1)
|
|
83
115
|
|| a.item.name.localeCompare(b.item.name),
|
|
84
116
|
);
|
|
85
117
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image file extensions, shared by the raw-file route and the chat image renderer so the
|
|
3
|
+
* server never refuses a path the UI is about to render (or the reverse).
|
|
4
|
+
*
|
|
5
|
+
* SVG is deliberately absent. The raw-file route serves files inline with their real
|
|
6
|
+
* Content-Type and accepts a `?token=` query parameter, so an SVG streamed from an arbitrary
|
|
7
|
+
* path could execute script in the app's own origin. `Read` also returns SVG as text, so
|
|
8
|
+
* there is nothing to display anyway.
|
|
9
|
+
*/
|
|
10
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
11
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".avif", ".ico",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/** Check whether a path names an image file, by extension. */
|
|
15
|
+
export function isImageExtension(path: string): boolean {
|
|
16
|
+
const dot = path.lastIndexOf(".");
|
|
17
|
+
if (dot === -1) return false;
|
|
18
|
+
return IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
19
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialization of a tool_result `content` value for transport to the chat UI.
|
|
3
|
+
*
|
|
4
|
+
* Tools that read images return base64 content blocks. Stringifying those verbatim sends
|
|
5
|
+
* ~1.37 bytes of text per image byte over the websocket, into the replay buffer, and on into
|
|
6
|
+
* the DOM, where nothing truncates it. Swap image blocks for a short placeholder instead.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Rewrite image blocks to placeholders, then stringify. Non-image input is untouched. */
|
|
10
|
+
export function stringifyToolResultContent(content: unknown): string {
|
|
11
|
+
if (typeof content === "string") return content;
|
|
12
|
+
if (!Array.isArray(content)) return JSON.stringify(content) ?? "";
|
|
13
|
+
|
|
14
|
+
let hasImage = false;
|
|
15
|
+
const rewritten = content.map((block) => {
|
|
16
|
+
if (!isImageBlock(block)) return block;
|
|
17
|
+
hasImage = true;
|
|
18
|
+
return { type: "text", text: imagePlaceholder(block) };
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Only rebuild when an image was actually present, so consumers that parse the block
|
|
22
|
+
// array (e.g. the Agent/Task result renderer) see a byte-identical string otherwise.
|
|
23
|
+
return JSON.stringify(hasImage ? rewritten : content) ?? "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isImageBlock(block: unknown): block is { source?: { data?: unknown } } {
|
|
27
|
+
return !!block && typeof block === "object" && (block as { type?: unknown }).type === "image";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function imagePlaceholder(block: { source?: { data?: unknown } }): string {
|
|
31
|
+
const data = block.source?.data;
|
|
32
|
+
if (typeof data !== "string" || data.length === 0) return "[image]";
|
|
33
|
+
return `[image · ${formatBytes(base64ByteLength(data))}]`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function base64ByteLength(data: string): number {
|
|
37
|
+
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
38
|
+
return Math.max(0, Math.floor((data.length * 3) / 4) - padding);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatBytes(bytes: number): string {
|
|
42
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
43
|
+
if (bytes >= 1024) return `${Math.round(bytes / 1024)}KB`;
|
|
44
|
+
return `${bytes}B`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A whole text block that is exactly a placeholder this module produced. */
|
|
48
|
+
const PLACEHOLDER_BLOCK = /^\[image(?: · [\d.]+(?:B|KB|MB))?\]$/;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether a serialized tool result actually carried an image.
|
|
52
|
+
*
|
|
53
|
+
* A file extension cannot answer this: SVG comes back as text, and formats the API cannot
|
|
54
|
+
* decode come back as text or as an error. Callers that hide textual output in favour of a
|
|
55
|
+
* rendered image must key off the real result rather than the requested path.
|
|
56
|
+
*/
|
|
57
|
+
export function resultHasImagePlaceholder(output: string): boolean {
|
|
58
|
+
if (!output.startsWith("[")) return false;
|
|
59
|
+
let blocks: unknown;
|
|
60
|
+
try {
|
|
61
|
+
blocks = JSON.parse(output);
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(blocks)) return false;
|
|
66
|
+
return blocks.some((block) => {
|
|
67
|
+
const b = block as { type?: unknown; text?: unknown } | null;
|
|
68
|
+
return b?.type === "text" && typeof b.text === "string" && PLACEHOLDER_BLOCK.test(b.text);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -227,11 +227,11 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
227
227
|
return () => clearTimeout(t);
|
|
228
228
|
}, [editForking]);
|
|
229
229
|
|
|
230
|
-
const handleNewSession = useCallback(() => {
|
|
230
|
+
const handleNewSession = useCallback((title?: string, clearedFrom?: string) => {
|
|
231
231
|
useTabStore.getState().openTab({
|
|
232
232
|
type: "chat",
|
|
233
|
-
title: "AI Chat",
|
|
234
|
-
metadata: { projectName, providerId },
|
|
233
|
+
title: title || "AI Chat",
|
|
234
|
+
metadata: { projectName, providerId, ...(clearedFrom && { clearedFrom }) },
|
|
235
235
|
projectId: projectName || null,
|
|
236
236
|
closable: true,
|
|
237
237
|
});
|
|
@@ -379,6 +379,8 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
379
379
|
const session = await api.post<Session>(`${projectUrl(pName)}/chat/sessions`, {
|
|
380
380
|
providerId,
|
|
381
381
|
title: content.slice(0, 50),
|
|
382
|
+
// Set by /clear — the session only exists now, so persist the lineage here.
|
|
383
|
+
clearedFrom: metadata?.clearedFrom as string | undefined,
|
|
382
384
|
});
|
|
383
385
|
setSessionId(session.id);
|
|
384
386
|
setProviderId(session.providerId);
|
|
@@ -392,12 +394,30 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
392
394
|
}
|
|
393
395
|
sendMessage(fullContent, { permissionMode, priority });
|
|
394
396
|
},
|
|
395
|
-
[sessionId, providerId, projectName, sendMessage, buildMessageWithAttachments, permissionMode],
|
|
397
|
+
[sessionId, providerId, projectName, sendMessage, buildMessageWithAttachments, permissionMode, metadata],
|
|
396
398
|
);
|
|
397
399
|
|
|
400
|
+
// Read through a ref so handleInputSend keeps a stable identity — it is passed to
|
|
401
|
+
// the memoized MessageInput, and slashItems changes once the picker list loads.
|
|
402
|
+
const slashItemsRef = useRef(slashItems);
|
|
403
|
+
slashItemsRef.current = slashItems;
|
|
404
|
+
|
|
398
405
|
/** Stable wrapper for MessageInput onSend — clears forkDraft + draft and delegates to handleSend */
|
|
399
406
|
const handleInputSend = useCallback(
|
|
400
407
|
(content: string, attachments: ChatAttachment[], priority?: MessagePriority) => {
|
|
408
|
+
// Client-handled built-ins act on the UI, so they must not reach the SDK.
|
|
409
|
+
const slash = content.trim().match(/^\/(\S+)(?:\s+([\s\S]+))?$/);
|
|
410
|
+
if (slash) {
|
|
411
|
+
const item = slashItemsRef.current.find(
|
|
412
|
+
(i) => i.handler === "client" && (i.name === slash[1] || i.aliases?.includes(slash[1]!)),
|
|
413
|
+
);
|
|
414
|
+
if (item?.name === "clear") {
|
|
415
|
+
clearDraft();
|
|
416
|
+
handleNewSession(slash[2]?.trim(), sessionId ?? undefined);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
401
421
|
setForkDraft(undefined);
|
|
402
422
|
clearDraft();
|
|
403
423
|
if (editFork && sessionId && projectName) {
|
|
@@ -408,7 +428,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
408
428
|
}
|
|
409
429
|
handleSend(content, attachments, priority);
|
|
410
430
|
},
|
|
411
|
-
[handleSend, clearDraft, editFork, sessionId, projectName, handleEditSend, buildMessageWithAttachments],
|
|
431
|
+
[handleSend, clearDraft, editFork, sessionId, projectName, handleEditSend, buildMessageWithAttachments, handleNewSession],
|
|
412
432
|
);
|
|
413
433
|
|
|
414
434
|
// Past user messages for the composer's ArrowUp/Down recall. Read through a ref
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geometry and formatting for the Read-tool image result panel.
|
|
3
|
+
*
|
|
4
|
+
* The tile's aspect ratio follows the image so nothing is cropped, but only within a
|
|
5
|
+
* clamp: very tall captures would become unreadable slivers, and panoramas would waste
|
|
6
|
+
* the width they could use.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const TILE_MIN_RATIO = 0.62;
|
|
10
|
+
export const TILE_MAX_RATIO = 2.4;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Tile width in px; the narrow value applies under a 470px container.
|
|
14
|
+
*
|
|
15
|
+
* The rendered widths come from Tailwind classes in tool-image-preview.tsx, which cannot
|
|
16
|
+
* read these constants — change both together. Layout is computed at the wide width, and
|
|
17
|
+
* a native-size image that no longer fits the narrow tile is capped by the box rather than
|
|
18
|
+
* overflowing it, so the two staying in sync only affects which images read as native.
|
|
19
|
+
*/
|
|
20
|
+
export const TILE_WIDTH = 132;
|
|
21
|
+
export const TILE_WIDTH_NARROW = 64;
|
|
22
|
+
|
|
23
|
+
export interface NaturalSize {
|
|
24
|
+
w: number;
|
|
25
|
+
h: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ImageFit =
|
|
29
|
+
/** Box matches the image, so cover crops nothing. */
|
|
30
|
+
| "cover"
|
|
31
|
+
/** Taller than the clamp: fit inside the box on the checkerboard. */
|
|
32
|
+
| "contain"
|
|
33
|
+
/** Smaller than the box: native size, centered, never upscaled. */
|
|
34
|
+
| "native";
|
|
35
|
+
|
|
36
|
+
export interface PreviewLayout {
|
|
37
|
+
/** `band` drops the two-column split for panoramas. */
|
|
38
|
+
variant: "split" | "band";
|
|
39
|
+
/** width / height of the rendered box. */
|
|
40
|
+
aspect: number;
|
|
41
|
+
fit: ImageFit;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function previewLayout(natural: NaturalSize, tileWidth: number): PreviewLayout {
|
|
45
|
+
const ratio = natural.w / natural.h;
|
|
46
|
+
|
|
47
|
+
if (ratio > TILE_MAX_RATIO) {
|
|
48
|
+
return { variant: "band", aspect: ratio, fit: "contain" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const aspect = Math.max(TILE_MIN_RATIO, ratio);
|
|
52
|
+
const boxHeight = tileWidth / aspect;
|
|
53
|
+
if (natural.w < tileWidth && natural.h < boxHeight) {
|
|
54
|
+
return { variant: "split", aspect, fit: "native" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { variant: "split", aspect, fit: ratio < TILE_MIN_RATIO ? "contain" : "cover" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatBytes(bytes: number): string {
|
|
61
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
62
|
+
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
63
|
+
return `${bytes} B`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function dimensionsLabel(natural: NaturalSize, fit: ImageFit): string {
|
|
67
|
+
const base = `${natural.w} × ${natural.h} px`;
|
|
68
|
+
return fit === "native" ? `${base} · shown 1:1` : base;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Natural sizes survive unmount so a card that scrolls out and back does not resolve
|
|
73
|
+
* its height a second time — a late height change mid-transcript jumps the scroll.
|
|
74
|
+
*/
|
|
75
|
+
const naturalSizes = new Map<string, NaturalSize>();
|
|
76
|
+
|
|
77
|
+
export function getCachedNatural(path: string): NaturalSize | undefined {
|
|
78
|
+
return naturalSizes.get(path);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function cacheNatural(path: string, size: NaturalSize): void {
|
|
82
|
+
naturalSizes.set(path, size);
|
|
83
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Fragment, type ReactNode } from "react";
|
|
2
|
+
import { AlertCircle } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
/** Metadata label styling, shared with the loaded panel so the skeleton lines up. */
|
|
5
|
+
const LABEL = "text-[var(--img-meta-label)] @max-[470px]:hidden";
|
|
6
|
+
|
|
7
|
+
/** Widths that keep the placeholder bars from looking like a uniform block. */
|
|
8
|
+
const BAR_WIDTHS = ["60%", "45%", "80%"];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Loading state for the image panel.
|
|
12
|
+
*
|
|
13
|
+
* Occupies the same box as the loaded panel — same tile ratio, same three metadata rows,
|
|
14
|
+
* same action row — so the transcript does not shift under the reader when the image lands.
|
|
15
|
+
* `aspect` should come from the cached natural size when the file has been seen before.
|
|
16
|
+
*/
|
|
17
|
+
export function ImagePreviewSkeleton({ aspect, actions }: { aspect: number; actions: ReactNode }) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="grid grid-cols-[132px_1fr] items-start gap-[14px] overflow-hidden rounded-lg border border-border bg-panel p-[14px] @max-[470px]:grid-cols-[64px_1fr] @max-[470px]:gap-[11px] @max-[470px]:p-[11px]">
|
|
20
|
+
<div className="img-skeleton rounded-lg" style={{ aspectRatio: aspect }} />
|
|
21
|
+
<div className="flex min-w-0 flex-col gap-[9px]">
|
|
22
|
+
<div className="img-skeleton h-[13px] w-2/5 rounded @max-[470px]:hidden" />
|
|
23
|
+
{/* Labels stay as text so only the values arrive later — the row keeps its box. */}
|
|
24
|
+
<dl className="grid grid-cols-[auto_1fr] gap-x-[14px] gap-y-[4px] font-mono text-[11.5px] @max-[470px]:grid-cols-[1fr]">
|
|
25
|
+
{["dimensions", "size", "path"].map((label, i) => (
|
|
26
|
+
<Fragment key={label}>
|
|
27
|
+
<dt className={LABEL}>{label}</dt>
|
|
28
|
+
<dd className="flex items-center">
|
|
29
|
+
<span className="img-skeleton h-[11px] rounded" style={{ width: BAR_WIDTHS[i] }} />
|
|
30
|
+
</dd>
|
|
31
|
+
</Fragment>
|
|
32
|
+
))}
|
|
33
|
+
</dl>
|
|
34
|
+
{actions}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Failure state: a compact chip, since there is nothing to show and no space to justify. */
|
|
41
|
+
export function ImagePreviewFailure({ code, onRetry }: { code?: string; onRetry: () => void }) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex items-center gap-2 rounded-lg border border-border bg-panel px-2.5 py-2 font-mono text-[12px] text-text-2">
|
|
44
|
+
<AlertCircle className="size-3 shrink-0" />
|
|
45
|
+
<span className="truncate">preview unavailable{code ? ` · ${code}` : ""}</span>
|
|
46
|
+
<button type="button" onClick={onRetry} className="ml-auto shrink-0 text-accent">
|
|
47
|
+
retry
|
|
48
|
+
</button>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -13,6 +13,8 @@ export interface SlashItem {
|
|
|
13
13
|
aliases?: string[];
|
|
14
14
|
/** Agent-only: model the subagent runs on */
|
|
15
15
|
model?: string;
|
|
16
|
+
/** Built-in only: which layer executes the command */
|
|
17
|
+
handler?: "ppm" | "sdk" | "client";
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
interface SlashCommandPickerProps {
|
|
@@ -67,6 +67,9 @@ import type { ChatEvent } from "../../../types/chat";
|
|
|
67
67
|
import { useShallow } from "zustand/react/shallow";
|
|
68
68
|
import { useTabStore } from "@/stores/tab-store";
|
|
69
69
|
import { basename } from "@/lib/utils";
|
|
70
|
+
import { isImageExtension } from "../../../shared/image-extensions";
|
|
71
|
+
import { resultHasImagePlaceholder } from "../../../shared/tool-result-content";
|
|
72
|
+
import { ToolImagePreview } from "./tool-image-preview";
|
|
70
73
|
|
|
71
74
|
/** Extract tool name and input from a ChatEvent */
|
|
72
75
|
function extractToolInfo(tool: ChatEvent): { toolName: string; input: Record<string, unknown> } {
|
|
@@ -84,6 +87,18 @@ function extractToolInfo(tool: ChatEvent): { toolName: string; input: Record<str
|
|
|
84
87
|
return { toolName, input };
|
|
85
88
|
}
|
|
86
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Path of the image a Read call targeted, or null when it is not a displayable image read.
|
|
92
|
+
* Only absolute paths qualify, matching what useBlobUrl can resolve to the external
|
|
93
|
+
* raw-file endpoint — and what the Read tool always supplies.
|
|
94
|
+
*/
|
|
95
|
+
function imageReadPath(tool: ChatEvent): string | null {
|
|
96
|
+
if (tool.type !== "tool_use" || tool.tool !== "Read") return null;
|
|
97
|
+
const path = (tool.input as Record<string, unknown> | undefined)?.file_path;
|
|
98
|
+
if (typeof path !== "string" || !/^(\/|[A-Za-z]:[/\\])/.test(path)) return null;
|
|
99
|
+
return isImageExtension(path) ? path : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
87
102
|
/** Unified tool card: shows tool-specific summary + expandable details */
|
|
88
103
|
export function ToolCard({
|
|
89
104
|
tool,
|
|
@@ -103,7 +118,10 @@ export function ToolCard({
|
|
|
103
118
|
// a click — unless the edit failed (diff was never applied, so it's noise).
|
|
104
119
|
const t = tool.type === "tool_use" ? tool.tool : (tool as any).tool;
|
|
105
120
|
const failed = result?.type === "tool_result" && !!(result as any).isError;
|
|
106
|
-
|
|
121
|
+
if ((t === "Edit" || t === "MultiEdit") && !failed) return true;
|
|
122
|
+
// Same reasoning for reading an image: the thumbnail is the point of the card,
|
|
123
|
+
// so show it without requiring a click.
|
|
124
|
+
return !!imageReadPath(tool);
|
|
107
125
|
});
|
|
108
126
|
|
|
109
127
|
if (tool.type === "error") {
|
|
@@ -127,6 +145,10 @@ export function ToolCard({
|
|
|
127
145
|
// File-mutation tools show their change via the inline diff/content preview — the SDK's
|
|
128
146
|
// "file updated successfully" boilerplate is noise, so suppress it (but keep error output).
|
|
129
147
|
const isFileMutation = ["Edit", "MultiEdit", "Write", "NotebookEdit"].includes(toolName);
|
|
148
|
+
const imagePath = imageReadPath(tool);
|
|
149
|
+
// Hide the text output only when the result really carried an image. Extension is not
|
|
150
|
+
// enough: an SVG or an undecodable format comes back as text that must stay visible.
|
|
151
|
+
const resultIsImage = hasResult && resultHasImagePlaceholder(String((result as any).output ?? ""));
|
|
130
152
|
|
|
131
153
|
// Read partial output for streaming Bash/PowerShell tools
|
|
132
154
|
const toolUseId = tool.type === "tool_use" ? (tool as any).toolUseId as string | undefined : undefined;
|
|
@@ -194,7 +216,10 @@ export function ToolCard({
|
|
|
194
216
|
{hasChildren && (
|
|
195
217
|
<SubagentChildren events={children!} projectName={projectName} />
|
|
196
218
|
)}
|
|
197
|
-
{
|
|
219
|
+
{imagePath && (
|
|
220
|
+
<ToolImagePreview filePath={imagePath} projectName={projectName ?? ""} />
|
|
221
|
+
)}
|
|
222
|
+
{hasResult && !(isFileMutation && !isError) && !(resultIsImage && !isError) && (
|
|
198
223
|
<ToolResultView toolName={toolName} output={(result as any).output} />
|
|
199
224
|
)}
|
|
200
225
|
</div>
|