@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/{ai-resource-editor-ByZApv3Y.js → ai-resource-editor-QNfSU0Fi.js} +1 -1
  5. package/dist/web/assets/{audio-preview-izB0KGPQ.js → audio-preview-IokB77Cj.js} +1 -1
  6. package/dist/web/assets/chat-tab-BZNEvS88.js +13 -0
  7. package/dist/web/assets/{code-editor-Bx-Q27Nm.js → code-editor-B8YiBPOX.js} +2 -2
  8. package/dist/web/assets/{conflict-editor-B60UimS8.js → conflict-editor-CY1o5kBO.js} +1 -1
  9. package/dist/web/assets/{csv-preview-BZv2SuCz.js → csv-preview-AEg0GB-V.js} +1 -1
  10. package/dist/web/assets/{database-viewer-B5J_4lvX.js → database-viewer-C5Meg0s_.js} +1 -1
  11. package/dist/web/assets/{diff-viewer-DiytoNvX.js → diff-viewer-BRRGvZnp.js} +1 -1
  12. package/dist/web/assets/{docx-preview-CrjmF16z.js → docx-preview-DqTX7NDp.js} +1 -1
  13. package/dist/web/assets/{edit-diff-preview-BSjgyGAL.js → edit-diff-preview-_CXUdwsF.js} +1 -1
  14. package/dist/web/assets/{extension-webview-BSiQUxFD.js → extension-webview-CQceYb7O.js} +1 -1
  15. package/dist/web/assets/{git-log-panel-Fb5EefXW.js → git-log-panel-D7QlAJqu.js} +1 -1
  16. package/dist/web/assets/{glide-data-grid-Cjve7mDb.js → glide-data-grid-cGxkgluR.js} +1 -1
  17. package/dist/web/assets/group-chat-tab-B-eA5ytm.js +1 -0
  18. package/dist/web/assets/{image-preview-BiVYVqa9.js → image-preview-DRUzYptI.js} +1 -1
  19. package/dist/web/assets/{index-CuAijZGh.js → index-BuX7JkCt.js} +5 -5
  20. package/dist/web/assets/index-PX2pZ3bH.css +2 -0
  21. package/dist/web/assets/{keybindings-store-sakq3gsF.js → keybindings-store-B3plqyxF.js} +1 -1
  22. package/dist/web/assets/{markdown-renderer-BsKIIM83.js → markdown-renderer-BKDbjc3g.js} +1 -1
  23. package/dist/web/assets/{markdown-renderer-Cct9JHnx.js → markdown-renderer-C9Gy8tM2.js} +1 -1
  24. package/dist/web/assets/{notification-store-D4TsAnN3.js → notification-store-jFDauzse.js} +1 -1
  25. package/dist/web/assets/panel-store-D-RxcUgH.js +1 -0
  26. package/dist/web/assets/{pdf-preview-B6G1QpE6.js → pdf-preview-CzHUqXJB.js} +1 -1
  27. package/dist/web/assets/{postgres-viewer-CdFu2Yj9.js → postgres-viewer-BeantJZe.js} +1 -1
  28. package/dist/web/assets/{settings-tab-Dh3bD_dg.js → settings-tab-CqQHhqV1.js} +1 -1
  29. package/dist/web/assets/{sql-query-editor-C8XXOY2R.js → sql-query-editor-DqU9zfVy.js} +1 -1
  30. package/dist/web/assets/{sqlite-viewer-B1eXvaLK.js → sqlite-viewer-BH891DC0.js} +1 -1
  31. package/dist/web/assets/{system-monitor-tab-D2QcCXl1.js → system-monitor-tab-Bue4p4Vb.js} +1 -1
  32. package/dist/web/assets/{tab-store-ByORqglW.js → tab-store-CNxCwdXw.js} +1 -1
  33. package/dist/web/assets/{terminal-tab-D2-buBAQ.js → terminal-tab-BfNnKU71.js} +1 -1
  34. package/dist/web/assets/tool-cards-CSOwe7_i.js +7 -0
  35. package/dist/web/assets/use-blob-url-jSRmyXVg.js +1 -0
  36. package/dist/web/assets/{use-monaco-theme-DMlVAYKD.js → use-monaco-theme-5XjAzOzE.js} +1 -1
  37. package/dist/web/assets/{video-preview-Cg3A7vrt.js → video-preview-DMqLOqmc.js} +1 -1
  38. package/dist/web/index.html +4 -4
  39. package/dist/web/sw.js +1 -1
  40. package/package.json +1 -1
  41. package/skills-lock.json +11 -0
  42. package/spike-memory-region-dump.ps1 +69 -0
  43. package/src/providers/claude-agent-sdk.ts +4 -3
  44. package/src/server/routes/chat.ts +9 -3
  45. package/src/server/routes/fs-browse.ts +13 -2
  46. package/src/services/db.service.ts +25 -1
  47. package/src/services/jsonl-transcript-parser.ts +2 -1
  48. package/src/services/slash-discovery/builtin-commands.ts +7 -3
  49. package/src/services/slash-discovery/index.ts +8 -1
  50. package/src/services/slash-discovery/sdk-commands.ts +126 -0
  51. package/src/services/slash-discovery/types.ts +5 -0
  52. package/src/shared/fuzzy-search.ts +37 -5
  53. package/src/shared/image-extensions.ts +19 -0
  54. package/src/shared/tool-result-content.ts +70 -0
  55. package/src/web/components/chat/chat-tab.tsx +25 -5
  56. package/src/web/components/chat/image-preview-geometry.ts +83 -0
  57. package/src/web/components/chat/image-preview-states.tsx +51 -0
  58. package/src/web/components/chat/slash-command-picker.tsx +2 -0
  59. package/src/web/components/chat/tool-cards.tsx +27 -2
  60. package/src/web/components/chat/tool-image-preview.tsx +225 -0
  61. package/src/web/components/editor/audio-preview.tsx +1 -1
  62. package/src/web/components/editor/image-preview.tsx +1 -1
  63. package/src/web/components/editor/video-preview.tsx +1 -1
  64. package/src/web/components/layout/tab-bar.tsx +3 -3
  65. package/src/web/{components/editor → hooks}/use-blob-url.ts +14 -3
  66. package/src/web/stores/dock-actions.ts +14 -4
  67. package/src/web/stores/panel-store.ts +8 -14
  68. package/src/web/styles/globals.css +72 -0
  69. package/dist/web/assets/chat-tab-CwWL5DbD.js +0 -13
  70. package/dist/web/assets/group-chat-tab-DT0X7Cbz.js +0 -1
  71. package/dist/web/assets/index-BW2cOdzp.css +0 -2
  72. package/dist/web/assets/panel-store-DsDZX8Mi.js +0 -1
  73. package/dist/web/assets/tool-cards-DDxShmxN.js +0 -7
  74. package/dist/web/assets/use-blob-url-kvtWmJtt.js +0 -1
  75. /package/dist/web/assets/{csv-parser-BSnYgPcx.js → csv-parser-BPS94T2h.js} +0 -0
  76. /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 get a rank boost (sorted earlier within same rank tier).
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
- const recentSet = new Set(recentNames);
67
- const scored: Array<{ item: T; rank: number; distance: number; recent: boolean }> = [];
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) scored.push({ item, rank: best.rank, distance: best.distance, recent: recentSet.has(item.name) });
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
- return (t === "Edit" || t === "MultiEdit") && !failed;
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
- {hasResult && !(isFileMutation && !isError) && (
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>