@hienlh/ppm 0.17.26 → 0.17.28

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 (61) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CLAUDE.md +1 -0
  3. package/assets/skills/ppm/SKILL.md +1 -1
  4. package/assets/skills/ppm/references/cli-reference.md +4 -4
  5. package/assets/skills/ppm/references/http-api.md +1 -1
  6. package/dist/web/assets/{ai-resource-editor-qnr1gXKX.js → ai-resource-editor-BCgyv1KF.js} +1 -1
  7. package/dist/web/assets/{audio-preview-IJyTkZhv.js → audio-preview-BPuiX8ma.js} +1 -1
  8. package/dist/web/assets/chat-tab-WnnM6GKA.js +13 -0
  9. package/dist/web/assets/{code-editor-DbgRk7IQ.js → code-editor-CnGUyOuU.js} +2 -2
  10. package/dist/web/assets/{conflict-editor-7379QQTo.js → conflict-editor-BuLnwxFE.js} +1 -1
  11. package/dist/web/assets/{csv-preview-CkZtgJJd.js → csv-preview-DdA_IpTo.js} +1 -1
  12. package/dist/web/assets/{database-viewer-BOWXg1eU.js → database-viewer-BcF3uuk5.js} +1 -1
  13. package/dist/web/assets/{diff-viewer-3q8WtMhX.js → diff-viewer-tyMHX20V.js} +1 -1
  14. package/dist/web/assets/{docx-preview-uAcT8IrY.js → docx-preview-BTclTd2v.js} +1 -1
  15. package/dist/web/assets/{extension-webview-C9B286ef.js → extension-webview-CBLWXhjt.js} +1 -1
  16. package/dist/web/assets/{git-log-panel-CWfaQmIt.js → git-log-panel-tmGgY2kK.js} +1 -1
  17. package/dist/web/assets/{glide-data-grid-BTbN5x8y.js → glide-data-grid-CkCW7sFw.js} +3 -3
  18. package/dist/web/assets/{group-chat-tab-CD1tHFCt.js → group-chat-tab-D_x5CRae.js} +1 -1
  19. package/dist/web/assets/{image-preview-D35tUSfv.js → image-preview-CQ57RN6l.js} +1 -1
  20. package/dist/web/assets/{index-DMEso_fH.js → index-Cp3e9olf.js} +3 -3
  21. package/dist/web/assets/index-DCJ29LBG.css +2 -0
  22. package/dist/web/assets/{keybindings-store-C56hagRp.js → keybindings-store-CpgEwnFA.js} +1 -1
  23. package/dist/web/assets/{markdown-renderer-w7cIJc1V.js → markdown-renderer-C7clwuDe.js} +1 -1
  24. package/dist/web/assets/{markdown-renderer-CaVoLF6f.js → markdown-renderer-devgS0zw.js} +1 -1
  25. package/dist/web/assets/{notification-store-CijmEBze.js → notification-store-BohufOcS.js} +1 -1
  26. package/dist/web/assets/{pdf-preview-URqY4tEa.js → pdf-preview-Cw_GRd7e.js} +1 -1
  27. package/dist/web/assets/{postgres-viewer-D3iSQpJq.js → postgres-viewer-DN40Bdc-.js} +1 -1
  28. package/dist/web/assets/{settings-tab-BMAlaxBD.js → settings-tab-DkpOd_kU.js} +1 -1
  29. package/dist/web/assets/{sql-query-editor-Qrom7wG2.js → sql-query-editor-Df1pzXAj.js} +1 -1
  30. package/dist/web/assets/{sqlite-viewer-CPzA2RA4.js → sqlite-viewer-Dbh3LluT.js} +1 -1
  31. package/dist/web/assets/{system-monitor-tab-DEXXqZIz.js → system-monitor-tab-bbct9aEk.js} +1 -1
  32. package/dist/web/assets/{terminal-tab-Cn1szPMh.js → terminal-tab-Cqs7xZJt.js} +1 -1
  33. package/dist/web/assets/{tool-cards-DNftJX5Y.js → tool-cards-BonPvpUI.js} +2 -2
  34. package/dist/web/assets/{use-monaco-theme-XO5ZyYBW.js → use-monaco-theme-Cqo17yKA.js} +1 -1
  35. package/dist/web/assets/{video-preview-U2aMrCxv.js → video-preview-kZgggFAn.js} +1 -1
  36. package/dist/web/index.html +2 -2
  37. package/dist/web/sw.js +1 -1
  38. package/docs/codebase-summary.md +2 -1
  39. package/docs/lessons-learned.md +24 -0
  40. package/package.json +1 -1
  41. package/packages/vscode-compat/node_modules/.bin/tsc.bunx +0 -0
  42. package/packages/vscode-compat/node_modules/.bin/tsc.exe +0 -0
  43. package/packages/vscode-compat/node_modules/.bin/tsserver.bunx +0 -0
  44. package/packages/vscode-compat/node_modules/.bin/tsserver.exe +0 -0
  45. package/scripts/check-ppm-dir-usage.sh +0 -0
  46. package/scripts/install.sh +0 -0
  47. package/scripts/release.sh +0 -0
  48. package/src/services/file-watcher/ignore-rules.ts +63 -0
  49. package/src/services/file-watcher/watch-tree.ts +244 -0
  50. package/src/services/file-watcher.service.ts +68 -29
  51. package/src/web/components/chat/chat-scroll-nav.tsx +234 -0
  52. package/src/web/components/chat/chat-tab.tsx +14 -0
  53. package/src/web/components/chat/message-history-recall.ts +22 -0
  54. package/src/web/components/chat/message-input.tsx +57 -4
  55. package/src/web/components/chat/message-list.tsx +7 -210
  56. package/src/web/components/chat/user-message-parse.ts +159 -0
  57. package/src/web/hooks/use-global-keybindings.ts +15 -0
  58. package/src/web/lib/owns-global-shortcut.ts +21 -0
  59. package/src/web/stores/keybindings-store.ts +2 -0
  60. package/dist/web/assets/chat-tab-Bf7bP_mH.js +0 -13
  61. package/dist/web/assets/index-DjkJR3PP.css +0 -2
@@ -39,10 +39,12 @@ import {
39
39
  Pencil,
40
40
  } from "lucide-react";
41
41
  import { ChatWelcome } from "./chat-welcome";
42
+ import { ChatScrollNav } from "./chat-scroll-nav";
42
43
  import { MessageActionBar, ActionButton } from "./message-action-bar";
43
44
  import { VersionSwitcher } from "./version-switcher";
44
45
  import type { VersionGroup } from "../../../types/api";
45
46
  import { QuestionCard } from "./question-card";
47
+ import { parseUserMessage, type SystemTag } from "./user-message-parse";
46
48
  import type { Question } from "./question-card";
47
49
  import { useTabStore } from "@/stores/tab-store";
48
50
  import { api } from "@/lib/api-client";
@@ -387,94 +389,7 @@ export function MessageList({
387
389
  )}
388
390
  </div>
389
391
  </div>
390
- <ScrollNavButtons scrollElement={scrollEl} userIndices={userIndices} scrollToBottom={scrollToBottom} />
391
- </div>
392
- );
393
- }
394
-
395
- /**
396
- * Floating bottom-right navigation between the user's own messages. Every bubble is
397
- * now in the real DOM (no virtualization), so navigation queries the rendered rows by
398
- * their `data-msg-index` and scrolls to the target. Up jumps to the nearest user
399
- * message above the top of the viewport; Down jumps to the nearest one below, or to
400
- * the very bottom when none remain.
401
- */
402
- function ScrollNavButtons({ scrollElement, userIndices, scrollToBottom }: {
403
- scrollElement: HTMLDivElement | null;
404
- userIndices: number[];
405
- scrollToBottom: (opts?: { animation?: "instant" | "smooth" }) => void | Promise<boolean> | boolean;
406
- }) {
407
- const [hasAbove, setHasAbove] = useState(false);
408
- const [atBottom, setAtBottom] = useState(true);
409
-
410
- // Top of a row in the scroll container's content coordinates (scrollTop space).
411
- const rowTop = (el: HTMLElement, row: HTMLElement) =>
412
- row.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop;
413
-
414
- const userRows = useCallback((el: HTMLElement) =>
415
- Array.from(el.querySelectorAll<HTMLElement>("[data-msg-index]"))
416
- .filter((r) => userIndices.includes(Number(r.dataset.msgIndex))),
417
- [userIndices]);
418
-
419
- useEffect(() => {
420
- const el = scrollElement;
421
- if (!el) return;
422
- let raf = 0;
423
- const recompute = () => {
424
- const top = el.scrollTop;
425
- const above = userRows(el).some((r) => rowTop(el, r) < top - 4);
426
- setHasAbove((prev) => (prev === above ? prev : above));
427
- const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8;
428
- setAtBottom((prev) => (prev === bottom ? prev : bottom));
429
- };
430
- const schedule = () => {
431
- if (raf) cancelAnimationFrame(raf);
432
- raf = requestAnimationFrame(recompute);
433
- };
434
- recompute();
435
- el.addEventListener("scroll", schedule, { passive: true });
436
- const ro = new ResizeObserver(schedule);
437
- ro.observe(el);
438
- return () => {
439
- if (raf) cancelAnimationFrame(raf);
440
- el.removeEventListener("scroll", schedule);
441
- ro.disconnect();
442
- };
443
- }, [scrollElement, userRows]);
444
-
445
- const goUp = useCallback(() => {
446
- const el = scrollElement;
447
- if (!el) return;
448
- const top = el.scrollTop;
449
- let target: number | undefined;
450
- for (const r of userRows(el)) {
451
- const t = rowTop(el, r);
452
- if (t < top - 4) target = t; // ascending — keep the last one still above
453
- else break;
454
- }
455
- if (target != null) el.scrollTo({ top: target, behavior: "auto" });
456
- }, [scrollElement, userRows]);
457
-
458
- const goDown = useCallback(() => {
459
- const el = scrollElement;
460
- if (!el) return;
461
- const top = el.scrollTop;
462
- const next = userRows(el).map((r) => rowTop(el, r)).find((t) => t > top + 4);
463
- if (next != null) el.scrollTo({ top: next, behavior: "auto" });
464
- else scrollToBottom({ animation: "instant" });
465
- }, [scrollElement, userRows, scrollToBottom]);
466
-
467
- const btnClass =
468
- "size-8 flex items-center justify-center rounded-full bg-surface-elevated/60 border border-border/60 text-text-secondary shadow-md backdrop-blur-sm transition-all hover:bg-surface-elevated hover:text-foreground disabled:opacity-30 disabled:cursor-default disabled:hover:bg-surface-elevated/60 disabled:hover:text-text-secondary";
469
-
470
- return (
471
- <div className="absolute bottom-4 right-4 z-10 flex flex-col gap-2">
472
- <button type="button" onClick={goUp} disabled={!hasAbove} aria-label="Jump to previous message" className={btnClass}>
473
- <ChevronUp className="size-4" />
474
- </button>
475
- <button type="button" onClick={goDown} disabled={atBottom} aria-label="Jump to next message" className={btnClass}>
476
- <ChevronDown className="size-4" />
477
- </button>
392
+ <ChatScrollNav scrollElement={scrollEl} userIndices={userIndices} scrollToBottom={scrollToBottom} />
478
393
  </div>
479
394
  );
480
395
  }
@@ -568,100 +483,6 @@ const MessageBubble = memo(function MessageBubble({ message, isStreaming, isLast
568
483
  /** Image extensions that can be previewed inline */
569
484
  const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
570
485
 
571
- interface SystemTag {
572
- name: string;
573
- label: string;
574
- content: string;
575
- }
576
-
577
- const TAG_LABELS: Record<string, string> = {
578
- "system-reminder": "Context",
579
- "claudeMd": "CLAUDE.md",
580
- "gitStatus": "Git Status",
581
- "currentDate": "Date",
582
- "fast_mode_info": "Fast Mode",
583
- "available-deferred-tools": "Tools",
584
- "task-notification": "Task Result",
585
- "environment_details": "Environment",
586
- "local-command-caveat": "System",
587
- };
588
-
589
- /** Extract system-injected XML tags into structured objects + clean text */
590
- function extractSystemTags(text: string): { cleanText: string; tags: SystemTag[] } {
591
- const tags: SystemTag[] = [];
592
- const tagPattern = /<(system-reminder|available-deferred-tools|antml:[\w-]+|fast_mode_info|claudeMd|gitStatus|currentDate|task-notification|environment_details|local-command-caveat)[^>]*>([\s\S]*?)<\/\1>/g;
593
- let match;
594
- while ((match = tagPattern.exec(text)) !== null) {
595
- const name = match[1]!;
596
- tags.push({
597
- name,
598
- label: TAG_LABELS[name] ?? name.replace(/^antml:/, "").replace(/-/g, " "),
599
- content: match[2]!.trim(),
600
- });
601
- }
602
- const cleanText = text.replace(tagPattern, "").trim();
603
- return { cleanText, tags };
604
- }
605
-
606
- /** Extract slash command tags from user message content */
607
- interface SlashCommand {
608
- name: string;
609
- args?: string;
610
- }
611
- function parseCommandTags(text: string): { command: SlashCommand | null; cleanText: string } {
612
- const nameMatch = text.match(/<command-name>([\s\S]*?)<\/command-name>/);
613
- if (!nameMatch) return { command: null, cleanText: text };
614
- const name = nameMatch[1]!.trim();
615
- const argsMatch = text.match(/<command-args>([\s\S]*?)<\/command-args>/);
616
- const args = argsMatch?.[1]?.trim() || undefined;
617
- // Strip all command tags regardless of order
618
- const cleanText = text
619
- .replace(/<command-name>[\s\S]*?<\/command-name>/g, "")
620
- .replace(/<command-message>[\s\S]*?<\/command-message>/g, "")
621
- .replace(/<command-args>[\s\S]*?<\/command-args>/g, "")
622
- .trim();
623
- return { command: { name, args }, cleanText };
624
- }
625
-
626
- /** Detect the leading "Use the <agent> agent to" delegation prompt, split off as a chip */
627
- function parseAgentTag(text: string): { agent: string | null; cleanText: string } {
628
- const m = text.match(/^Use the (\S+) agent to\s?/);
629
- if (!m) return { agent: null, cleanText: text };
630
- return { agent: m[1]!, cleanText: text.slice(m[0].length) };
631
- }
632
-
633
- /** Extract the IDE-injected <ide_opened_file> context tag — returns the open file path + cleaned text */
634
- function parseIdeOpenedFile(text: string): { idePath: string | null; cleanText: string } {
635
- const tagRe = /<ide_opened_file>([\s\S]*?)<\/ide_opened_file>/g;
636
- const m = tagRe.exec(text);
637
- if (!m) return { idePath: null, cleanText: text };
638
- // Inner format: "The user opened the file <path> in the IDE. ..."
639
- const pathMatch = m[1]!.match(/opened the file (.+?) in the IDE/);
640
- const idePath = pathMatch?.[1]?.trim() ?? null;
641
- const cleanText = text.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/g, "").trim();
642
- return { idePath, cleanText };
643
- }
644
-
645
- /** Parse user message content, extracting attached file paths and the actual text */
646
- function parseUserAttachments(content: string): { files: string[]; text: string } {
647
- // Match: [Attached file: /path] or [Attached files:\n/path1\n/path2\n]
648
- // Trailing newlines are optional — an attachment-only message has the marker
649
- // trimmed to the end of the string (extractTerminalBlocks trims), so the
650
- // separator newlines may be absent.
651
- const singleMatch = content.match(/^\[Attached file: (.+?)\]\n*/);
652
- if (singleMatch) {
653
- return { files: [singleMatch[1]!], text: content.slice(singleMatch[0].length) };
654
- }
655
-
656
- const multiMatch = content.match(/^\[Attached files:\n([\s\S]+?)\]\n*/);
657
- if (multiMatch) {
658
- const files = multiMatch[1]!.split("\n").map((l) => l.trim()).filter(Boolean);
659
- return { files, text: content.slice(multiMatch[0].length) };
660
- }
661
-
662
- return { files: [], text: content };
663
- }
664
-
665
486
  /** Build a preview URL for an uploaded file (served from /chat/uploads/:filename) */
666
487
  function uploadPreviewUrl(filePath: string, projectName?: string): string {
667
488
  const filename = basename(filePath);
@@ -683,19 +504,6 @@ function isPdfPath(path: string): boolean {
683
504
  /** Detect if tags contain system-injected content (not real user input) */
684
505
  const SYSTEM_TAG_NAMES = new Set(["task-notification", "environment_details", "local-command-caveat"]);
685
506
 
686
- /** Extract leading terminal code fences from message text */
687
- function extractTerminalBlocks(text: string): { blocks: string[]; remainingText: string } {
688
- const blocks: string[] = [];
689
- let remaining = text;
690
- const re = /^```(?:bash|sh|shell|zsh)\n([\s\S]*?)\n```\s*(?:\n\n?)?/;
691
- let match;
692
- while ((match = remaining.match(re)) !== null) {
693
- blocks.push(match[1]!);
694
- remaining = remaining.slice(match[0].length);
695
- }
696
- return { blocks, remainingText: remaining.trim() };
697
- }
698
-
699
507
  /** User message bubble — full width, collapsible, with system tag badges */
700
508
  function UserBubble({ content, messageId, timestamp, projectName, onFork, onEdit, isEditing, sessionId, providerId, versionGroup, onNavigateVersion, versionNavDisabled }: {
701
509
  content: string;
@@ -711,21 +519,10 @@ function UserBubble({ content, messageId, timestamp, projectName, onFork, onEdit
711
519
  onNavigateVersion?: (sessionId: string) => void;
712
520
  versionNavDisabled?: boolean;
713
521
  }) {
714
- const { files, text, tags, command, terminalBlocks, idePath, agent } = useMemo(() => {
715
- const { idePath, cleanText: afterIde } = parseIdeOpenedFile(content);
716
- const { blocks, remainingText: afterBlocks } = extractTerminalBlocks(afterIde);
717
- const parsed = parseUserAttachments(afterBlocks);
718
- // Strip local-command-stdout/stderr tags but keep their content as plain text
719
- const withoutCmdOutput = parsed.text
720
- .replace(/<local-command-(?:stdout|stderr)>([\s\S]*?)<\/local-command-(?:stdout|stderr)>/g, "$1");
721
- const { cleanText: noSysTags, tags } = extractSystemTags(withoutCmdOutput);
722
- const { command, cleanText } = parseCommandTags(noSysTags);
723
- const { agent, cleanText: afterAgent } = parseAgentTag(cleanText);
724
- const bodyText = command?.args
725
- ? (afterAgent ? `${command.args}\n\n${afterAgent}` : command.args)
726
- : afterAgent;
727
- return { files: parsed.files, text: bodyText, tags, command, terminalBlocks: blocks, idePath, agent };
728
- }, [content]);
522
+ const { files, text, tags, command, terminalBlocks, idePath, agent } = useMemo(
523
+ () => parseUserMessage(content),
524
+ [content],
525
+ );
729
526
 
730
527
  const isSystemContext = tags.some((t) => SYSTEM_TAG_NAMES.has(t.name));
731
528
 
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Parsing for stored user-message content.
3
+ *
4
+ * What lands in the transcript is not what the user typed: the composer and the
5
+ * runtime wrap it with attachment markers, slash-command tags, an agent
6
+ * delegation prefix, and IDE/system context tags. Both the transcript bubble and
7
+ * the composer's history recall need the same decomposition, so it lives here.
8
+ */
9
+
10
+ export interface SystemTag {
11
+ name: string;
12
+ label: string;
13
+ content: string;
14
+ }
15
+
16
+ export interface SlashCommand {
17
+ name: string;
18
+ args?: string;
19
+ }
20
+
21
+ const TAG_LABELS: Record<string, string> = {
22
+ "system-reminder": "Context",
23
+ claudeMd: "CLAUDE.md",
24
+ gitStatus: "Git Status",
25
+ currentDate: "Date",
26
+ fast_mode_info: "Fast Mode",
27
+ "available-deferred-tools": "Tools",
28
+ "task-notification": "Task Result",
29
+ environment_details: "Environment",
30
+ "local-command-caveat": "System",
31
+ };
32
+
33
+ /** Extract system-injected XML tags into structured objects + clean text */
34
+ export function extractSystemTags(text: string): { cleanText: string; tags: SystemTag[] } {
35
+ const tags: SystemTag[] = [];
36
+ const tagPattern = /<(system-reminder|available-deferred-tools|antml:[\w-]+|fast_mode_info|claudeMd|gitStatus|currentDate|task-notification|environment_details|local-command-caveat)[^>]*>([\s\S]*?)<\/\1>/g;
37
+ let match;
38
+ while ((match = tagPattern.exec(text)) !== null) {
39
+ const name = match[1]!;
40
+ tags.push({
41
+ name,
42
+ label: TAG_LABELS[name] ?? name.replace(/^antml:/, "").replace(/-/g, " "),
43
+ content: match[2]!.trim(),
44
+ });
45
+ }
46
+ const cleanText = text.replace(tagPattern, "").trim();
47
+ return { cleanText, tags };
48
+ }
49
+
50
+ /** Extract slash command tags from user message content */
51
+ export function parseCommandTags(text: string): { command: SlashCommand | null; cleanText: string } {
52
+ const nameMatch = text.match(/<command-name>([\s\S]*?)<\/command-name>/);
53
+ if (!nameMatch) return { command: null, cleanText: text };
54
+ const name = nameMatch[1]!.trim();
55
+ const argsMatch = text.match(/<command-args>([\s\S]*?)<\/command-args>/);
56
+ const args = argsMatch?.[1]?.trim() || undefined;
57
+ // Strip all command tags regardless of order
58
+ const cleanText = text
59
+ .replace(/<command-name>[\s\S]*?<\/command-name>/g, "")
60
+ .replace(/<command-message>[\s\S]*?<\/command-message>/g, "")
61
+ .replace(/<command-args>[\s\S]*?<\/command-args>/g, "")
62
+ .trim();
63
+ return { command: { name, args }, cleanText };
64
+ }
65
+
66
+ /** Detect the leading "Use the <agent> agent to" delegation prompt, split off as a chip */
67
+ export function parseAgentTag(text: string): { agent: string | null; cleanText: string } {
68
+ const m = text.match(/^Use the (\S+) agent to\s?/);
69
+ if (!m) return { agent: null, cleanText: text };
70
+ return { agent: m[1]!, cleanText: text.slice(m[0].length) };
71
+ }
72
+
73
+ /** Extract the IDE-injected <ide_opened_file> context tag — returns the open file path + cleaned text */
74
+ export function parseIdeOpenedFile(text: string): { idePath: string | null; cleanText: string } {
75
+ const tagRe = /<ide_opened_file>([\s\S]*?)<\/ide_opened_file>/g;
76
+ const m = tagRe.exec(text);
77
+ if (!m) return { idePath: null, cleanText: text };
78
+ // Inner format: "The user opened the file <path> in the IDE. ..."
79
+ const pathMatch = m[1]!.match(/opened the file (.+?) in the IDE/);
80
+ const idePath = pathMatch?.[1]?.trim() ?? null;
81
+ const cleanText = text.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/g, "").trim();
82
+ return { idePath, cleanText };
83
+ }
84
+
85
+ /** Parse user message content, extracting attached file paths and the actual text */
86
+ export function parseUserAttachments(content: string): { files: string[]; text: string } {
87
+ // Match: [Attached file: /path] or [Attached files:\n/path1\n/path2\n]
88
+ // Trailing newlines are optional — an attachment-only message has the marker
89
+ // trimmed to the end of the string (extractTerminalBlocks trims), so the
90
+ // separator newlines may be absent.
91
+ const singleMatch = content.match(/^\[Attached file: (.+?)\]\n*/);
92
+ if (singleMatch) {
93
+ return { files: [singleMatch[1]!], text: content.slice(singleMatch[0].length) };
94
+ }
95
+
96
+ const multiMatch = content.match(/^\[Attached files:\n([\s\S]+?)\]\n*/);
97
+ if (multiMatch) {
98
+ const files = multiMatch[1]!.split("\n").map((l) => l.trim()).filter(Boolean);
99
+ return { files, text: content.slice(multiMatch[0].length) };
100
+ }
101
+
102
+ return { files: [], text: content };
103
+ }
104
+
105
+ /** Extract leading terminal code fences from message text */
106
+ export function extractTerminalBlocks(text: string): { blocks: string[]; remainingText: string } {
107
+ const blocks: string[] = [];
108
+ let remaining = text;
109
+ const re = /^```(?:bash|sh|shell|zsh)\n([\s\S]*?)\n```\s*(?:\n\n?)?/;
110
+ let match;
111
+ while ((match = remaining.match(re)) !== null) {
112
+ blocks.push(match[1]!);
113
+ remaining = remaining.slice(match[0].length);
114
+ }
115
+ return { blocks, remainingText: remaining.trim() };
116
+ }
117
+
118
+ export interface ParsedUserMessage {
119
+ files: string[];
120
+ /** Body text with the slash-command args folded in, for transcript display */
121
+ text: string;
122
+ /** Body text alone, with the command args kept separate — for recomposing input */
123
+ body: string;
124
+ tags: SystemTag[];
125
+ command: SlashCommand | null;
126
+ terminalBlocks: string[];
127
+ idePath: string | null;
128
+ agent: string | null;
129
+ }
130
+
131
+ /** Full decomposition of a stored user message, outermost wrapper first. */
132
+ export function parseUserMessage(content: string): ParsedUserMessage {
133
+ const { idePath, cleanText: afterIde } = parseIdeOpenedFile(content);
134
+ const { blocks, remainingText: afterBlocks } = extractTerminalBlocks(afterIde);
135
+ const parsed = parseUserAttachments(afterBlocks);
136
+ // Strip local-command-stdout/stderr tags but keep their content as plain text
137
+ const withoutCmdOutput = parsed.text
138
+ .replace(/<local-command-(?:stdout|stderr)>([\s\S]*?)<\/local-command-(?:stdout|stderr)>/g, "$1");
139
+ const { cleanText: noSysTags, tags } = extractSystemTags(withoutCmdOutput);
140
+ const { command, cleanText } = parseCommandTags(noSysTags);
141
+ const { agent, cleanText: body } = parseAgentTag(cleanText);
142
+ const text = command?.args ? (body ? `${command.args}\n\n${body}` : command.args) : body;
143
+ return { files: parsed.files, text, body, tags, command, terminalBlocks: blocks, idePath, agent };
144
+ }
145
+
146
+ /**
147
+ * Rebuild what the user originally typed, for prefilling the composer on history
148
+ * recall. Attachments, terminal blocks, and injected context are dropped — they
149
+ * cannot be re-attached from a keystroke — while a slash command is restored to
150
+ * its typed `/name args` form and the agent comes back as a chip.
151
+ */
152
+ export function toComposerDraft(content: string): { agent: string | null; text: string } {
153
+ const { agent, body, command } = parseUserMessage(content);
154
+ if (!command) return { agent, text: body };
155
+ // Stored names appear both with and without the leading slash — normalise to one.
156
+ const name = command.name.replace(/^\/+/, "");
157
+ const typed = `/${name}${command.args ? ` ${command.args}` : ""}`;
158
+ return { agent, text: body ? `${typed}\n\n${body}` : typed };
159
+ }
@@ -34,6 +34,15 @@ export function useGlobalKeybindings() {
34
34
  function onCompositionStart() { composing = true; }
35
35
  function onCompositionEnd() { composing = false; }
36
36
 
37
+ /** Chat transcript jumps. Returns true when the event was consumed. */
38
+ function dispatchChatNav(e: KeyboardEvent, m: (ev: KeyboardEvent, id: string) => boolean) {
39
+ const dir = m(e, "chat-nav-prev") ? "prev" : m(e, "chat-nav-next") ? "next" : null;
40
+ if (!dir) return false;
41
+ e.preventDefault();
42
+ window.dispatchEvent(new CustomEvent(`chat-nav-${dir}`));
43
+ return true;
44
+ }
45
+
37
46
  function handler(e: KeyboardEvent) {
38
47
  // Track whether Shift is pressed alone (not as a modifier for another key)
39
48
  if (e.type === "keydown" && e.key === "Shift") {
@@ -84,6 +93,9 @@ export function useGlobalKeybindings() {
84
93
  e.preventDefault();
85
94
  usePanelStore.getState().toggleDock();
86
95
  }
96
+ // The composer holds focus for most of a chat session, so the transcript
97
+ // jumps have to survive this early return or they are unreachable.
98
+ dispatchChatNav(e, m);
87
99
  return;
88
100
  }
89
101
 
@@ -184,6 +196,9 @@ export function useGlobalKeybindings() {
184
196
  return;
185
197
  }
186
198
 
199
+ // Chat transcript navigation — the focused panel's chat tab picks these up
200
+ if (dispatchChatNav(e, match)) return;
201
+
187
202
  // Compare Files — seed A from active editor tab if applicable, then open picker
188
203
  if (match(e, "compare-files")) {
189
204
  e.preventDefault();
@@ -0,0 +1,21 @@
1
+ import { usePanelStore } from "@/stores/panel-store";
2
+
3
+ /**
4
+ * Decides whether a tab should act on a window-level keyboard shortcut.
5
+ *
6
+ * Every tab stays mounted: tab-pool reparents them and hides inactive ones with
7
+ * `display: none`, and a split grid can show several panels at once. A global
8
+ * shortcut must therefore resolve to exactly one tab — the active tab of the
9
+ * focused panel. Checking visibility alone would match every pane in a split,
10
+ * so two open chats would both react to a single key press.
11
+ *
12
+ * Pass any node that lives inside the claiming tab.
13
+ */
14
+ export function ownsGlobalShortcut(el: HTMLElement | null | undefined): boolean {
15
+ if (!el) return false;
16
+ const tabId = el.closest<HTMLElement>("[data-tab-pool-id]")?.dataset.tabPoolId;
17
+ // Not rendered through the tab pool (dialogs, previews) — visibility is enough.
18
+ if (!tabId) return el.offsetParent !== null;
19
+ const { panels, focusedPanelId } = usePanelStore.getState();
20
+ return panels[focusedPanelId]?.activeTabId === tabId;
21
+ }
@@ -38,6 +38,8 @@ export const KEY_ACTIONS: KeyAction[] = [
38
38
  { id: "open-git-status", label: "Git Status (sidebar)", category: "tabs", defaultKey: "Mod+Shift+E" },
39
39
  { id: "open-search", label: "Search Files (sidebar)", category: "tabs", defaultKey: "Mod+Shift+F" },
40
40
  { id: "voice-input", label: "Voice Input", category: "general", defaultKey: "Mod+Shift+V", note: "Toggle speech-to-text in chat" },
41
+ { id: "chat-nav-prev", label: "Previous Chat Message", category: "general", defaultKey: "Alt+ArrowUp", note: "Jump to your previous message in the visible chat transcript" },
42
+ { id: "chat-nav-next", label: "Next Chat Message", category: "general", defaultKey: "Alt+ArrowDown", note: "Jump to your next message in the visible chat transcript" },
41
43
  { id: "compare-files", label: "Compare Files...", category: "general", defaultKey: "Mod+Alt+D", note: "Open file-compare picker (seeds active file as A)" },
42
44
  // Projects — Mod+1..9
43
45
  ...Array.from({ length: 9 }, (_, i) => ({