@hienlh/ppm 0.10.2 → 0.10.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 +11 -0
- package/dist/web/assets/chat-tab-By7krQ3s.js +10 -0
- package/dist/web/assets/{code-editor-BB_JdEmM.js → code-editor-BoKL57Co.js} +2 -2
- package/dist/web/assets/{conflict-editor-DWJbUNQd.js → conflict-editor-HvxI1A29.js} +1 -1
- package/dist/web/assets/{database-viewer-Ch30LM68.js → database-viewer-BgCXPc4e.js} +1 -1
- package/dist/web/assets/{diff-viewer-C16rHzAI.js → diff-viewer-blzXAJHd.js} +1 -1
- package/dist/web/assets/{extension-webview-CVB5CXIc.js → extension-webview-Dvk_61ON.js} +1 -1
- package/dist/web/assets/index-DPnjO2FY.css +2 -0
- package/dist/web/assets/{index-BIO_fcCU.js → index-EgCQVN13.js} +2 -2
- package/dist/web/assets/{markdown-renderer-Cl38Rm7-.js → markdown-renderer-Hcj-59AX.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-CME4r9GB.js → port-forwarding-tab-CUgwDn_5.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BNx3BNzV.js → postgres-viewer-BEUI1N1X.js} +1 -1
- package/dist/web/assets/{settings-tab-qzle6GNb.js → settings-tab-BGvgK51L.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DKLXjS3g.js → sqlite-viewer-sQs615K6.js} +1 -1
- package/dist/web/assets/{terminal-tab-DJwMCQll.js → terminal-tab-CUyHmiHH.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/server/routes/chat.ts +28 -3
- package/src/services/db.service.ts +36 -0
- package/src/services/slash-discovery/cache.ts +38 -0
- package/src/services/slash-discovery/fuzzy-search.ts +12 -3
- package/src/services/slash-discovery/index.ts +9 -2
- package/src/services/slash-items.service.ts +1 -1
- package/src/web/components/chat/chat-tab.tsx +12 -2
- package/src/web/components/chat/message-input.tsx +54 -33
- package/src/web/components/chat/message-list.tsx +8 -15
- package/src/web/components/chat/slash-command-picker.tsx +120 -46
- package/dist/web/assets/chat-tab-DpfwSKRB.js +0 -10
- package/dist/web/assets/index-CQJBmJiN.css +0 -2
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { SlashItem } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
interface CacheEntry {
|
|
4
|
+
items: SlashItem[];
|
|
5
|
+
cachedAt: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** In-memory cache keyed by projectPath */
|
|
9
|
+
const cache = new Map<string, CacheEntry>();
|
|
10
|
+
|
|
11
|
+
/** Default TTL: 5 minutes */
|
|
12
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
/** Get cached items if still valid, or null */
|
|
15
|
+
export function getCached(projectPath: string): SlashItem[] | null {
|
|
16
|
+
const entry = cache.get(projectPath);
|
|
17
|
+
if (!entry) return null;
|
|
18
|
+
if (Date.now() - entry.cachedAt > DEFAULT_TTL_MS) {
|
|
19
|
+
cache.delete(projectPath);
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return entry.items;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Store items in cache */
|
|
26
|
+
export function setCache(projectPath: string, items: SlashItem[]): void {
|
|
27
|
+
cache.set(projectPath, { items, cachedAt: Date.now() });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Invalidate cache for a specific project */
|
|
31
|
+
export function invalidateCache(projectPath: string): void {
|
|
32
|
+
cache.delete(projectPath);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Invalidate all cached entries */
|
|
36
|
+
export function invalidateAll(): void {
|
|
37
|
+
cache.clear();
|
|
38
|
+
}
|
|
@@ -46,18 +46,21 @@ export function scoreFuzzy(query: string, candidate: string): FuzzyScore | null
|
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Search slash items by query with fuzzy matching.
|
|
49
|
+
* Recently used items get a rank boost (sorted earlier within same rank tier).
|
|
49
50
|
* Returns ranked results (best match first), truncated to limit.
|
|
50
51
|
*/
|
|
51
52
|
export function searchSlashItems(
|
|
52
53
|
items: SlashItem[],
|
|
53
54
|
query: string,
|
|
54
55
|
limit = 20,
|
|
56
|
+
recentNames: string[] = [],
|
|
55
57
|
): SlashItem[] {
|
|
56
58
|
if (!query) return items;
|
|
57
59
|
// Cap query length to prevent quadratic blowup in Levenshtein
|
|
58
60
|
query = query.slice(0, 50);
|
|
59
61
|
|
|
60
|
-
const
|
|
62
|
+
const recentSet = new Set(recentNames);
|
|
63
|
+
const scored: Array<{ item: SlashItem; rank: number; distance: number; recent: boolean }> = [];
|
|
61
64
|
|
|
62
65
|
for (const item of items) {
|
|
63
66
|
// Score against name and description, keep best
|
|
@@ -67,10 +70,16 @@ export function searchSlashItems(
|
|
|
67
70
|
.filter((s): s is FuzzyScore => s !== null)
|
|
68
71
|
.sort((a, b) => a.rank - b.rank || a.distance - b.distance)[0];
|
|
69
72
|
|
|
70
|
-
if (best) scored.push({ item, rank: best.rank, distance: best.distance });
|
|
73
|
+
if (best) scored.push({ item, rank: best.rank, distance: best.distance, recent: recentSet.has(item.name) });
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
|
|
76
|
+
// Within same rank+distance, recent items come first
|
|
77
|
+
scored.sort((a, b) =>
|
|
78
|
+
a.rank - b.rank
|
|
79
|
+
|| a.distance - b.distance
|
|
80
|
+
|| (a.recent === b.recent ? 0 : a.recent ? -1 : 1)
|
|
81
|
+
|| a.item.name.localeCompare(b.item.name),
|
|
82
|
+
);
|
|
74
83
|
|
|
75
84
|
return scored.slice(0, limit).map((s) => s.item);
|
|
76
85
|
}
|
|
@@ -2,11 +2,13 @@ import { discoverSkillRoots } from "./discover-skill-roots.ts";
|
|
|
2
2
|
import { loadItemsFromRoots } from "./skill-loader.ts";
|
|
3
3
|
import { resolveOverrides } from "./resolve-overrides.ts";
|
|
4
4
|
import { getBuiltinSlashItems } from "./builtin-commands.ts";
|
|
5
|
+
import { getCached, setCache } from "./cache.ts";
|
|
5
6
|
import type { SlashItem, SlashItemWithSource, DiscoveryResult } from "./types.ts";
|
|
6
7
|
|
|
7
8
|
export { searchSlashItems } from "./fuzzy-search.ts";
|
|
8
9
|
export { isPpmHandled, getBuiltinByName } from "./builtin-commands.ts";
|
|
9
10
|
export { executeBuiltin } from "./builtin-handlers.ts";
|
|
11
|
+
export { invalidateCache, invalidateAll } from "./cache.ts";
|
|
10
12
|
export type { SlashItem, SlashItemWithSource, ShadowedItem, DiscoveryResult, SkillRoot, DefinitionSource } from "./types.ts";
|
|
11
13
|
|
|
12
14
|
/**
|
|
@@ -34,9 +36,14 @@ export function listSlashItemsDetailed(projectPath: string): DiscoveryResult {
|
|
|
34
36
|
|
|
35
37
|
/**
|
|
36
38
|
* Backward-compatible: returns flat list of active items (no source metadata).
|
|
37
|
-
*
|
|
39
|
+
* Uses in-memory cache (5 min TTL) to avoid repeated filesystem scans.
|
|
38
40
|
*/
|
|
39
41
|
export function listSlashItems(projectPath: string): SlashItem[] {
|
|
42
|
+
const cached = getCached(projectPath);
|
|
43
|
+
if (cached) return cached;
|
|
44
|
+
|
|
40
45
|
const { active } = listSlashItemsDetailed(projectPath);
|
|
41
|
-
|
|
46
|
+
const items = active.map(({ source, rootPath, filePath, ...item }) => item);
|
|
47
|
+
setCache(projectPath, items);
|
|
48
|
+
return items;
|
|
42
49
|
}
|
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
* Thin re-export wrapper — actual discovery logic lives in slash-discovery/.
|
|
3
3
|
* Kept for backward compatibility with existing imports.
|
|
4
4
|
*/
|
|
5
|
-
export { listSlashItems, searchSlashItems } from "./slash-discovery/index.ts";
|
|
5
|
+
export { listSlashItems, searchSlashItems, invalidateCache } from "./slash-discovery/index.ts";
|
|
6
6
|
export type { SlashItem } from "./slash-discovery/types.ts";
|
|
@@ -38,6 +38,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
38
38
|
const [slashFilter, setSlashFilter] = useState("");
|
|
39
39
|
const [slashRanked, setSlashRanked] = useState(false);
|
|
40
40
|
const [slashSelected, setSlashSelected] = useState<SlashItem | null>(null);
|
|
41
|
+
const [slashRecentNames, setSlashRecentNames] = useState<string[]>([]);
|
|
41
42
|
|
|
42
43
|
// File picker state
|
|
43
44
|
const [fileItems, setFileItems] = useState<FileNode[]>([]);
|
|
@@ -251,9 +252,10 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
251
252
|
|
|
252
253
|
/** Stable callback for slash items loaded — prevents MessageInput memo break */
|
|
253
254
|
const handleSlashItemsLoaded = useCallback(
|
|
254
|
-
(items: SlashItem[], ranked?: boolean) => {
|
|
255
|
+
(items: SlashItem[], ranked?: boolean, recentNames?: string[]) => {
|
|
255
256
|
setSlashItems(items);
|
|
256
257
|
if (ranked !== undefined) setSlashRanked(ranked);
|
|
258
|
+
if (recentNames) setSlashRecentNames(recentNames);
|
|
257
259
|
},
|
|
258
260
|
[],
|
|
259
261
|
);
|
|
@@ -270,7 +272,13 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
270
272
|
setShowSlashPicker(false);
|
|
271
273
|
setSlashFilter("");
|
|
272
274
|
setTimeout(() => setSlashSelected(null), 50);
|
|
273
|
-
|
|
275
|
+
// Record usage for recents (fire-and-forget)
|
|
276
|
+
if (projectName) {
|
|
277
|
+
api.post(`${projectUrl(projectName)}/chat/slash-recents`, { name: item.name, type: item.type }).catch(() => {});
|
|
278
|
+
// Optimistic update: add to front of recents
|
|
279
|
+
setSlashRecentNames((prev) => [item.name, ...prev.filter((n) => n !== item.name)].slice(0, 5));
|
|
280
|
+
}
|
|
281
|
+
}, [projectName]);
|
|
274
282
|
|
|
275
283
|
const handleSlashClose = useCallback(() => {
|
|
276
284
|
setShowSlashPicker(false);
|
|
@@ -404,6 +412,8 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
404
412
|
onClose={handleSlashClose}
|
|
405
413
|
visible={showSlashPicker}
|
|
406
414
|
ranked={slashRanked}
|
|
415
|
+
recentNames={slashRecentNames}
|
|
416
|
+
projectName={projectName}
|
|
407
417
|
/>
|
|
408
418
|
<FilePicker
|
|
409
419
|
items={fileItems}
|
|
@@ -32,7 +32,7 @@ interface MessageInputProps {
|
|
|
32
32
|
projectName?: string;
|
|
33
33
|
/** Slash picker state change */
|
|
34
34
|
onSlashStateChange?: (visible: boolean, filter: string) => void;
|
|
35
|
-
onSlashItemsLoaded?: (items: SlashItem[], ranked?: boolean) => void;
|
|
35
|
+
onSlashItemsLoaded?: (items: SlashItem[], ranked?: boolean, recentNames?: string[]) => void;
|
|
36
36
|
slashSelected?: SlashItem | null;
|
|
37
37
|
/** File picker state change */
|
|
38
38
|
onFileStateChange?: (visible: boolean, filter: string) => void;
|
|
@@ -95,6 +95,11 @@ export const MessageInput = memo(function MessageInput({
|
|
|
95
95
|
// Track picker open state to avoid unnecessary parent callbacks per keystroke
|
|
96
96
|
const slashPickerOpenRef = useRef(false);
|
|
97
97
|
const filePickerOpenRef = useRef(false);
|
|
98
|
+
// CSS field-sizing: content handles auto-resize natively (Safari 18.2+, Chrome 123+).
|
|
99
|
+
// Only fall back to JS scrollHeight resize when unsupported.
|
|
100
|
+
const needsJsResize = useRef(
|
|
101
|
+
typeof CSS === "undefined" || !CSS.supports("field-sizing", "content"),
|
|
102
|
+
);
|
|
98
103
|
|
|
99
104
|
/** Write value to both textareas + ref + update hasText state */
|
|
100
105
|
const writeTextareas = useCallback((newValue: string) => {
|
|
@@ -119,14 +124,16 @@ export const MessageInput = memo(function MessageInput({
|
|
|
119
124
|
const prefix = preVoiceTextRef.current;
|
|
120
125
|
const newValue = prefix ? prefix + " " + text : text;
|
|
121
126
|
writeTextareas(newValue);
|
|
122
|
-
// Auto-resize textarea
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
ta
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
// Auto-resize textarea (only when CSS field-sizing is unsupported)
|
|
128
|
+
if (needsJsResize.current) {
|
|
129
|
+
requestAnimationFrame(() => {
|
|
130
|
+
const ta = getVisibleTextarea();
|
|
131
|
+
if (ta) {
|
|
132
|
+
ta.style.height = "auto";
|
|
133
|
+
ta.style.height = Math.min(ta.scrollHeight, 160) + "px";
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
130
137
|
}, [writeTextareas, getVisibleTextarea]);
|
|
131
138
|
const handleVoiceToggle = useCallback(() => {
|
|
132
139
|
if (voice.isListening) {
|
|
@@ -162,25 +169,35 @@ export const MessageInput = memo(function MessageInput({
|
|
|
162
169
|
setTimeout(() => { getVisibleTextarea()?.focus(); }, 100);
|
|
163
170
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
164
171
|
|
|
165
|
-
// Fetch slash items
|
|
166
|
-
|
|
172
|
+
// Fetch slash items from server
|
|
173
|
+
const fetchSlashItems = useCallback(() => {
|
|
167
174
|
if (!projectName) {
|
|
168
175
|
slashItemsRef.current = [];
|
|
169
|
-
onSlashItemsLoaded?.([], false);
|
|
176
|
+
onSlashItemsLoaded?.([], false, []);
|
|
170
177
|
return;
|
|
171
178
|
}
|
|
172
179
|
api
|
|
173
|
-
.get<SlashItem[]>(`${projectUrl(projectName)}/chat/slash-items`)
|
|
174
|
-
.then((
|
|
175
|
-
slashItemsRef.current = items;
|
|
180
|
+
.get<{ items: SlashItem[]; recentNames: string[] }>(`${projectUrl(projectName)}/chat/slash-items`)
|
|
181
|
+
.then((data) => {
|
|
182
|
+
slashItemsRef.current = data.items;
|
|
176
183
|
slashRankedRef.current = false;
|
|
177
|
-
onSlashItemsLoaded?.(items, false);
|
|
184
|
+
onSlashItemsLoaded?.(data.items, false, data.recentNames);
|
|
178
185
|
})
|
|
179
186
|
.catch(() => {
|
|
180
187
|
slashItemsRef.current = [];
|
|
181
|
-
onSlashItemsLoaded?.([], false);
|
|
188
|
+
onSlashItemsLoaded?.([], false, []);
|
|
182
189
|
});
|
|
183
|
-
}, [projectName]);
|
|
190
|
+
}, [projectName, onSlashItemsLoaded]);
|
|
191
|
+
|
|
192
|
+
// Fetch slash items when projectName changes
|
|
193
|
+
useEffect(() => { fetchSlashItems(); }, [fetchSlashItems]);
|
|
194
|
+
|
|
195
|
+
// Re-fetch when cache is invalidated via refresh button
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
const handler = () => fetchSlashItems();
|
|
198
|
+
window.addEventListener("ppm:slash-items-refresh", handler);
|
|
199
|
+
return () => window.removeEventListener("ppm:slash-items-refresh", handler);
|
|
200
|
+
}, [fetchSlashItems]);
|
|
184
201
|
|
|
185
202
|
// Cleanup debounce timer on unmount
|
|
186
203
|
useEffect(() => {
|
|
@@ -375,8 +392,10 @@ export const MessageInput = memo(function MessageInput({
|
|
|
375
392
|
setAttachments([]);
|
|
376
393
|
setPendingSend(false);
|
|
377
394
|
setPriority('next');
|
|
378
|
-
if (
|
|
379
|
-
|
|
395
|
+
if (needsJsResize.current) {
|
|
396
|
+
if (textareaRef.current) textareaRef.current.style.height = "auto";
|
|
397
|
+
if (mobileTextareaRef.current) mobileTextareaRef.current.style.height = "auto";
|
|
398
|
+
}
|
|
380
399
|
}, [attachments, onSend, onSlashStateChange, onFileStateChange, isStreaming, priority, writeTextareas]);
|
|
381
400
|
|
|
382
401
|
const handleSend = useCallback(() => {
|
|
@@ -428,12 +447,12 @@ export const MessageInput = memo(function MessageInput({
|
|
|
428
447
|
const requestId = ++slashSearchIdRef.current;
|
|
429
448
|
slashDebounceRef.current = setTimeout(() => {
|
|
430
449
|
api
|
|
431
|
-
.get<SlashItem[]>(`${projectUrl(projectName)}/chat/slash-items?q=${encodeURIComponent(query)}`)
|
|
432
|
-
.then((
|
|
450
|
+
.get<{ items: SlashItem[]; recentNames: string[] }>(`${projectUrl(projectName)}/chat/slash-items?q=${encodeURIComponent(query)}`)
|
|
451
|
+
.then((data) => {
|
|
433
452
|
if (requestId !== slashSearchIdRef.current) return; // stale response
|
|
434
|
-
slashItemsRef.current = items;
|
|
453
|
+
slashItemsRef.current = data.items;
|
|
435
454
|
slashRankedRef.current = true;
|
|
436
|
-
onSlashItemsLoaded?.(items, true);
|
|
455
|
+
onSlashItemsLoaded?.(data.items, true, data.recentNames);
|
|
437
456
|
})
|
|
438
457
|
.catch(() => {
|
|
439
458
|
if (requestId !== slashSearchIdRef.current) return;
|
|
@@ -509,13 +528,15 @@ export const MessageInput = memo(function MessageInput({
|
|
|
509
528
|
setHasText(text.trim().length > 0);
|
|
510
529
|
// Update picker state (slash/file autocomplete)
|
|
511
530
|
updatePickerState(text, el.selectionStart);
|
|
512
|
-
//
|
|
513
|
-
if (
|
|
514
|
-
|
|
515
|
-
resizeRafRef.current =
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
531
|
+
// JS auto-resize fallback — only when CSS field-sizing: content is unsupported
|
|
532
|
+
if (needsJsResize.current) {
|
|
533
|
+
if (resizeRafRef.current) cancelAnimationFrame(resizeRafRef.current);
|
|
534
|
+
resizeRafRef.current = requestAnimationFrame(() => {
|
|
535
|
+
resizeRafRef.current = 0;
|
|
536
|
+
el.style.height = "auto";
|
|
537
|
+
el.style.height = Math.min(el.scrollHeight, el === mobileTextareaRef.current ? 80 : 160) + "px";
|
|
538
|
+
});
|
|
539
|
+
}
|
|
519
540
|
},
|
|
520
541
|
[updatePickerState],
|
|
521
542
|
);
|
|
@@ -630,7 +651,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
630
651
|
placeholder={isStreaming ? "Follow-up..." : "Ask anything..."}
|
|
631
652
|
disabled={disabled}
|
|
632
653
|
rows={1}
|
|
633
|
-
className="flex-1 resize-none bg-transparent py-1.5 text-sm text-foreground placeholder:text-text-subtle focus:outline-none disabled:opacity-50 max-h-20"
|
|
654
|
+
className="flex-1 resize-none bg-transparent py-1.5 text-sm text-foreground placeholder:text-text-subtle focus:outline-none disabled:opacity-50 max-h-20 [field-sizing:content]"
|
|
634
655
|
/>
|
|
635
656
|
{voice.supported && (
|
|
636
657
|
<button
|
|
@@ -680,7 +701,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
680
701
|
placeholder={isStreaming ? "Follow-up or Stop..." : "Ask anything..."}
|
|
681
702
|
disabled={disabled}
|
|
682
703
|
rows={1}
|
|
683
|
-
className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-sm text-foreground placeholder:text-text-subtle focus:outline-none disabled:opacity-50 max-h-40"
|
|
704
|
+
className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-sm text-foreground placeholder:text-text-subtle focus:outline-none disabled:opacity-50 max-h-40 [field-sizing:content]"
|
|
684
705
|
/>
|
|
685
706
|
<div className="flex items-center justify-between px-3 pb-2">
|
|
686
707
|
<div className="flex items-center gap-1">
|
|
@@ -117,7 +117,7 @@ export function MessageList({
|
|
|
117
117
|
|
|
118
118
|
return (
|
|
119
119
|
<div className="relative flex-1 overflow-hidden flex flex-col min-h-0">
|
|
120
|
-
<StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden" resize="smooth" initial="instant">
|
|
120
|
+
<StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden [contain:strict]" resize="smooth" initial="instant">
|
|
121
121
|
<StickToBottom.Content className="p-4 space-y-4 select-none">
|
|
122
122
|
{hasMore && (
|
|
123
123
|
<button onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
|
|
@@ -807,21 +807,14 @@ function ThinkingBlock({ content, isStreaming }: { content: string; isStreaming:
|
|
|
807
807
|
* When `isStreaming=true`, shows a blinking cursor at the end.
|
|
808
808
|
*/
|
|
809
809
|
function StreamingText({ content, animate: isStreaming, projectName }: { content: string; animate: boolean; projectName?: string }) {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
const cleaned = stripTeammateMessages(content);
|
|
815
|
-
return (
|
|
816
|
-
<>
|
|
817
|
-
{cleaned && (
|
|
818
|
-
<div className="prose prose-invert max-w-none whitespace-pre-wrap break-words select-text">{cleaned}</div>
|
|
819
|
-
)}
|
|
810
|
+
return (
|
|
811
|
+
<>
|
|
812
|
+
<MarkdownContent content={content} projectName={projectName} isStreaming={isStreaming} />
|
|
813
|
+
{isStreaming && (
|
|
820
814
|
<span className="text-text-subtle text-sm animate-pulse">Thinking...</span>
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
return <MarkdownContent content={content} projectName={projectName} />;
|
|
815
|
+
)}
|
|
816
|
+
</>
|
|
817
|
+
);
|
|
825
818
|
}
|
|
826
819
|
|
|
827
820
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useState, useEffect, useRef, useCallback, type KeyboardEvent } from "react";
|
|
2
|
-
import { Sparkles, Terminal, Zap } from "lucide-react";
|
|
1
|
+
import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent } from "react";
|
|
2
|
+
import { Sparkles, Terminal, Zap, RefreshCw, Clock } from "lucide-react";
|
|
3
|
+
import { api, projectUrl } from "@/lib/api-client";
|
|
3
4
|
|
|
4
5
|
export interface SlashItem {
|
|
5
6
|
type: "skill" | "command" | "builtin";
|
|
@@ -19,6 +20,10 @@ interface SlashCommandPickerProps {
|
|
|
19
20
|
visible: boolean;
|
|
20
21
|
/** When true, items are pre-ranked by server — skip client-side filtering */
|
|
21
22
|
ranked?: boolean;
|
|
23
|
+
/** Recently used item names (most recent first) */
|
|
24
|
+
recentNames?: string[];
|
|
25
|
+
/** Project name for cache invalidation */
|
|
26
|
+
projectName?: string;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export function SlashCommandPicker({
|
|
@@ -28,19 +33,46 @@ export function SlashCommandPicker({
|
|
|
28
33
|
onClose,
|
|
29
34
|
visible,
|
|
30
35
|
ranked,
|
|
36
|
+
recentNames = [],
|
|
37
|
+
projectName,
|
|
31
38
|
}: SlashCommandPickerProps) {
|
|
32
39
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
40
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
33
41
|
const listRef = useRef<HTMLDivElement>(null);
|
|
34
42
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
const recentSet = useMemo(() => new Set(recentNames), [recentNames]);
|
|
44
|
+
|
|
45
|
+
// Build display list: when no filter + not ranked, put recents first
|
|
46
|
+
const displayItems = useMemo(() => {
|
|
47
|
+
let base: SlashItem[];
|
|
48
|
+
if (ranked) {
|
|
49
|
+
base = items;
|
|
50
|
+
} else if (filter) {
|
|
51
|
+
const q = filter.toLowerCase();
|
|
52
|
+
base = items.filter(
|
|
53
|
+
(item) => item.name.toLowerCase().includes(q) || item.description.toLowerCase().includes(q),
|
|
54
|
+
);
|
|
55
|
+
} else {
|
|
56
|
+
base = items;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Reorder: recents first when no filter and not server-ranked
|
|
60
|
+
if (!filter && !ranked && recentNames.length > 0) {
|
|
61
|
+
const recents: SlashItem[] = [];
|
|
62
|
+
const rest: SlashItem[] = [];
|
|
63
|
+
for (const item of base) {
|
|
64
|
+
if (recentSet.has(item.name)) recents.push(item);
|
|
65
|
+
else rest.push(item);
|
|
66
|
+
}
|
|
67
|
+
// Sort recents by their order in recentNames (most recent first)
|
|
68
|
+
recents.sort((a, b) => recentNames.indexOf(a.name) - recentNames.indexOf(b.name));
|
|
69
|
+
return { items: [...recents, ...rest], recentCount: recents.length };
|
|
70
|
+
}
|
|
71
|
+
return { items: base, recentCount: 0 };
|
|
72
|
+
}, [items, filter, ranked, recentNames, recentSet]);
|
|
73
|
+
|
|
74
|
+
const filtered = displayItems.items;
|
|
75
|
+
const recentCount = displayItems.recentCount;
|
|
44
76
|
|
|
45
77
|
// Reset selection when filter changes
|
|
46
78
|
useEffect(() => {
|
|
@@ -95,49 +127,91 @@ export function SlashCommandPicker({
|
|
|
95
127
|
return () => document.removeEventListener("keydown", handler, true);
|
|
96
128
|
}, [visible, handleKeyDown]);
|
|
97
129
|
|
|
130
|
+
const handleRefresh = useCallback(() => {
|
|
131
|
+
if (!projectName || refreshing) return;
|
|
132
|
+
setRefreshing(true);
|
|
133
|
+
api.del(`${projectUrl(projectName)}/chat/slash-items/cache`)
|
|
134
|
+
.then(() => {
|
|
135
|
+
// Trigger re-fetch by dispatching custom event (MessageInput listens on projectName)
|
|
136
|
+
window.dispatchEvent(new CustomEvent("ppm:slash-items-refresh"));
|
|
137
|
+
})
|
|
138
|
+
.finally(() => setRefreshing(false));
|
|
139
|
+
}, [projectName, refreshing]);
|
|
140
|
+
|
|
98
141
|
if (!visible || filtered.length === 0) return null;
|
|
99
142
|
|
|
100
143
|
return (
|
|
101
144
|
<div className="max-h-52 overflow-y-auto border-b border-border bg-surface">
|
|
102
145
|
<div ref={listRef} className="py-1">
|
|
103
|
-
{filtered.map((item, i) =>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
146
|
+
{filtered.map((item, i) => {
|
|
147
|
+
// Show "Recent" separator before first item, "All" before first non-recent
|
|
148
|
+
const showRecentLabel = recentCount > 0 && i === 0;
|
|
149
|
+
const showAllLabel = recentCount > 0 && i === recentCount;
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div key={`${item.type}-${item.name}`}>
|
|
153
|
+
{showRecentLabel && (
|
|
154
|
+
<div className="flex items-center justify-between px-3 pt-1 pb-0.5">
|
|
155
|
+
<span className="text-[10px] font-medium text-text-subtle uppercase tracking-wider flex items-center gap-1">
|
|
156
|
+
<Clock className="size-3" />
|
|
157
|
+
Recent
|
|
158
|
+
</span>
|
|
159
|
+
{projectName && (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={(e) => { e.stopPropagation(); handleRefresh(); }}
|
|
163
|
+
className="text-text-subtle hover:text-text-primary transition-colors p-0.5 rounded"
|
|
164
|
+
title="Refresh skill list"
|
|
165
|
+
aria-label="Refresh skill list"
|
|
166
|
+
>
|
|
167
|
+
<RefreshCw className={`size-3 ${refreshing ? "animate-spin" : ""}`} />
|
|
168
|
+
</button>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
121
171
|
)}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
{item.argumentHint && (
|
|
127
|
-
<span className="text-xs text-text-subtle">{item.argumentHint}</span>
|
|
128
|
-
)}
|
|
129
|
-
<span className="text-xs text-text-subtle capitalize ml-auto">
|
|
130
|
-
{item.scope === "bundled" ? "PPM" : item.scope === "user" ? "global" : item.type}
|
|
131
|
-
</span>
|
|
132
|
-
</div>
|
|
133
|
-
{item.description && (
|
|
134
|
-
<p className="text-xs text-text-subtle mt-0.5 line-clamp-2">
|
|
135
|
-
{item.description}
|
|
136
|
-
</p>
|
|
172
|
+
{showAllLabel && (
|
|
173
|
+
<div className="px-3 pt-1.5 pb-0.5">
|
|
174
|
+
<span className="text-[10px] font-medium text-text-subtle uppercase tracking-wider">All</span>
|
|
175
|
+
</div>
|
|
137
176
|
)}
|
|
177
|
+
<button
|
|
178
|
+
className={`flex items-start gap-3 w-full px-3 py-2 text-left transition-colors ${
|
|
179
|
+
i === selectedIndex
|
|
180
|
+
? "bg-primary/10 text-primary"
|
|
181
|
+
: "hover:bg-surface-hover text-text-primary"
|
|
182
|
+
}`}
|
|
183
|
+
onMouseEnter={() => setSelectedIndex(i)}
|
|
184
|
+
onClick={() => onSelect(item)}
|
|
185
|
+
>
|
|
186
|
+
<span className="shrink-0 mt-0.5">
|
|
187
|
+
{item.type === "builtin" ? (
|
|
188
|
+
<Zap className="size-4 text-emerald-500" />
|
|
189
|
+
) : item.type === "skill" ? (
|
|
190
|
+
<Sparkles className="size-4 text-amber-500" />
|
|
191
|
+
) : (
|
|
192
|
+
<Terminal className="size-4 text-blue-500" />
|
|
193
|
+
)}
|
|
194
|
+
</span>
|
|
195
|
+
<div className="min-w-0 flex-1">
|
|
196
|
+
<div className="flex items-baseline gap-2">
|
|
197
|
+
<span className="font-medium text-sm">/{item.name}</span>
|
|
198
|
+
{item.argumentHint && (
|
|
199
|
+
<span className="text-xs text-text-subtle">{item.argumentHint}</span>
|
|
200
|
+
)}
|
|
201
|
+
<span className="text-xs text-text-subtle capitalize ml-auto">
|
|
202
|
+
{item.scope === "bundled" ? "PPM" : item.scope === "user" ? "global" : item.type}
|
|
203
|
+
</span>
|
|
204
|
+
</div>
|
|
205
|
+
{item.description && (
|
|
206
|
+
<p className="text-xs text-text-subtle mt-0.5 line-clamp-2">
|
|
207
|
+
{item.description}
|
|
208
|
+
</p>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
</button>
|
|
138
212
|
</div>
|
|
139
|
-
|
|
140
|
-
)
|
|
213
|
+
);
|
|
214
|
+
})}
|
|
141
215
|
</div>
|
|
142
216
|
</div>
|
|
143
217
|
);
|