@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
@@ -1,15 +1,23 @@
1
- import { watch, type FSWatcher } from "node:fs";
1
+ import { WatchTree } from "./file-watcher/watch-tree.ts";
2
2
 
3
- const IGNORE = new Set([
4
- ".git", "node_modules", "dist", "build", ".next",
5
- ".turbo", "coverage", "__pycache__", "bun.lock",
6
- ]);
7
3
  const DEBOUNCE_MS = 500;
4
+ /**
5
+ * Directory budgets. One covered directory costs one inotify watch on Linux, and
6
+ * the machine-wide default ceiling is 524288 shared with every other process, so
7
+ * PPM stays well inside it even with several projects watched at once.
8
+ *
9
+ * The per-project cap is hard. The total is best-effort: a native recursive watch
10
+ * picks up directories created later without telling us, so exact accounting is not
11
+ * possible — the worst case is a few projects at their own cap, still ~4% of the
12
+ * kernel ceiling.
13
+ */
14
+ const MAX_DIRS_PER_PROJECT = 8_000;
15
+ const MAX_DIRS_TOTAL = 20_000;
8
16
 
9
17
  type ChangeCallback = (projectName: string, path: string) => void;
10
18
 
11
19
  interface WatchEntry {
12
- watcher: FSWatcher;
20
+ tree: WatchTree;
13
21
  refCount: number;
14
22
  timer?: ReturnType<typeof setTimeout>;
15
23
  pending: Set<string>;
@@ -24,9 +32,28 @@ export function onFileChange(cb: ChangeCallback): void {
24
32
  changeCallbacks.push(cb);
25
33
  }
26
34
 
27
- function shouldIgnore(filePath: string): boolean {
28
- const parts = filePath.split(/[/\\]/);
29
- return parts.some((p) => IGNORE.has(p));
35
+ function totalCoveredDirs(): number {
36
+ let total = 0;
37
+ for (const entry of watchers.values()) total += entry.tree.stats().dirs;
38
+ return total;
39
+ }
40
+
41
+ /**
42
+ * Emit on a fixed window rather than restarting the timer per event: a project
43
+ * under continuous churn (a build, a large checkout) would otherwise keep pushing
44
+ * the deadline back and never notify the UI at all.
45
+ */
46
+ function queue(entry: WatchEntry, projectName: string, relPath: string): void {
47
+ entry.pending.add(relPath);
48
+ if (entry.timer) return;
49
+ entry.timer = setTimeout(() => {
50
+ entry.timer = undefined;
51
+ const paths = [...entry.pending];
52
+ entry.pending.clear();
53
+ for (const path of paths) {
54
+ for (const cb of changeCallbacks) cb(projectName, path);
55
+ }
56
+ }, DEBOUNCE_MS);
30
57
  }
31
58
 
32
59
  /** Start watching a project directory (ref-counted — safe to call multiple times) */
@@ -37,28 +64,40 @@ export function startWatching(projectName: string, projectPath: string): void {
37
64
  return;
38
65
  }
39
66
 
40
- try {
41
- const watcher = watch(projectPath, { recursive: true }, (_event, filename) => {
42
- if (!filename || shouldIgnore(filename)) return;
43
- const entry = watchers.get(projectName);
44
- if (!entry) return;
67
+ const maxDirs = Math.max(0, Math.min(MAX_DIRS_PER_PROJECT, MAX_DIRS_TOTAL - totalCoveredDirs()));
68
+ const entry: WatchEntry = {
69
+ tree: new WatchTree({
70
+ root: projectPath,
71
+ maxDirs,
72
+ onChange: (relPath) => {
73
+ // Look the entry up again: it may have been stopped while an event was queued.
74
+ const current = watchers.get(projectName);
75
+ if (current) queue(current, projectName, relPath);
76
+ },
77
+ }),
78
+ refCount: 1,
79
+ pending: new Set(),
80
+ };
81
+ watchers.set(projectName, entry);
45
82
 
46
- entry.pending.add(filename);
47
- if (entry.timer) clearTimeout(entry.timer);
48
- entry.timer = setTimeout(() => {
49
- const paths = [...entry.pending];
50
- entry.pending.clear();
51
- for (const p of paths) {
52
- const relPath = p.replaceAll("\\", "/");
53
- for (const cb of changeCallbacks) cb(projectName, relPath);
54
- }
55
- }, DEBOUNCE_MS);
56
- });
57
-
58
- watchers.set(projectName, { watcher, refCount: 1, pending: new Set() });
59
- console.log(`[file-watcher] Started watching: ${projectName}`);
83
+ try {
84
+ entry.tree.start();
60
85
  } catch (e) {
86
+ entry.tree.close(); // release whatever attached before the failure
87
+ watchers.delete(projectName);
61
88
  console.warn(`[file-watcher] Failed to watch ${projectPath}: ${(e as Error).message}`);
89
+ return;
90
+ }
91
+
92
+ const { dirs, watchers: handles, truncated } = entry.tree.stats();
93
+ console.log(
94
+ `[file-watcher] Started watching: ${projectName} (${dirs} dirs, ${handles} handles${truncated ? ", capped" : ""})`,
95
+ );
96
+ if (truncated) {
97
+ console.warn(
98
+ `[file-watcher] ${projectName} exceeded the ${maxDirs}-directory budget — ` +
99
+ `parts of the tree are not watched. Add build/cache directories to the ignore list.`,
100
+ );
62
101
  }
63
102
  }
64
103
 
@@ -69,7 +108,7 @@ export function stopWatching(projectName: string): void {
69
108
  entry.refCount--;
70
109
  if (entry.refCount <= 0) {
71
110
  if (entry.timer) clearTimeout(entry.timer);
72
- entry.watcher.close();
111
+ entry.tree.close();
73
112
  watchers.delete(projectName);
74
113
  console.log(`[file-watcher] Stopped watching: ${projectName}`);
75
114
  }
@@ -0,0 +1,234 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { ChevronUp, ChevronDown, ChevronsDown, ChevronsUpDown } from "lucide-react";
3
+ import { cn } from "@/lib/utils";
4
+ import { useIsMobile } from "@/hooks/use-is-mobile";
5
+ import { ownsGlobalShortcut } from "@/lib/owns-global-shortcut";
6
+
7
+ /** Collapse delay after opening or after the last navigation. */
8
+ const AUTO_COLLAPSE_MS = 3200;
9
+ /** Shorter collapse used when a keyboard shortcut opens the cluster just as feedback. */
10
+ const KEYBOARD_PEEK_MS = 1000;
11
+
12
+ interface NavState {
13
+ hasAbove: boolean;
14
+ atBottom: boolean;
15
+ /** 1-based position of the last user message at or above the viewport top. */
16
+ idx: number;
17
+ total: number;
18
+ }
19
+
20
+ const INITIAL: NavState = { hasAbove: false, atBottom: true, idx: 0, total: 0 };
21
+
22
+ /**
23
+ * Floating bottom-right navigation between the user's own messages.
24
+ *
25
+ * At rest this is a single dim 30px puck so it reads as chrome and stops covering
26
+ * the tail of long user bubbles. Clicking it expands a vertical cluster
27
+ * (previous · position · next · latest) that auto-collapses after use.
28
+ *
29
+ * Every bubble is in the real DOM (no virtualization), so navigation queries the
30
+ * rendered rows by their `data-msg-index`. Scrolling stays `behavior: "auto"` to
31
+ * avoid fighting `use-stick-to-bottom`.
32
+ */
33
+ export function ChatScrollNav({ scrollElement, userIndices, scrollToBottom }: {
34
+ scrollElement: HTMLDivElement | null;
35
+ userIndices: number[];
36
+ scrollToBottom: (opts?: { animation?: "instant" | "smooth" }) => void | Promise<boolean> | boolean;
37
+ }) {
38
+ const isMobile = useIsMobile();
39
+ const [nav, setNav] = useState<NavState>(INITIAL);
40
+ const [open, setOpen] = useState(false);
41
+ /** Bumped on every jump so the collapse timer restarts even when `idx` is unchanged. */
42
+ const [jumpTick, setJumpTick] = useState(0);
43
+ const collapseMsRef = useRef(AUTO_COLLAPSE_MS);
44
+ const clusterRef = useRef<HTMLDivElement>(null);
45
+ const puckRef = useRef<HTMLButtonElement>(null);
46
+ /** Set while focus lives inside the cluster, so collapsing can hand it back. */
47
+ const heldFocusRef = useRef(false);
48
+ /** True when the cluster was opened by activating the puck rather than by a shortcut. */
49
+ const focusOnOpenRef = useRef(false);
50
+
51
+ // Top of a row in the scroll container's content coordinates (scrollTop space).
52
+ const rowTop = (el: HTMLElement, row: HTMLElement, elTop: number) =>
53
+ row.getBoundingClientRect().top - elTop + el.scrollTop;
54
+
55
+ const userRows = useCallback((el: HTMLElement) =>
56
+ Array.from(el.querySelectorAll<HTMLElement>("[data-msg-index]"))
57
+ .filter((r) => userIndices.includes(Number(r.dataset.msgIndex))),
58
+ [userIndices]);
59
+
60
+ useEffect(() => {
61
+ const el = scrollElement;
62
+ if (!el) return;
63
+ let raf = 0;
64
+ const recompute = () => {
65
+ const top = el.scrollTop;
66
+ const elTop = el.getBoundingClientRect().top;
67
+ const tops = userRows(el).map((r) => rowTop(el, r, elTop));
68
+ let idx = 0;
69
+ for (let i = 0; i < tops.length; i++) if (tops[i]! <= top + 12) idx = i + 1;
70
+ const next: NavState = {
71
+ hasAbove: tops.some((t) => t < top - 4),
72
+ atBottom: el.scrollHeight - top - el.clientHeight < 8,
73
+ idx,
74
+ total: tops.length,
75
+ };
76
+ setNav((prev) => (
77
+ prev.hasAbove === next.hasAbove && prev.atBottom === next.atBottom
78
+ && prev.idx === next.idx && prev.total === next.total
79
+ ) ? prev : next);
80
+ };
81
+ const schedule = () => {
82
+ if (raf) cancelAnimationFrame(raf);
83
+ raf = requestAnimationFrame(recompute);
84
+ };
85
+ recompute();
86
+ el.addEventListener("scroll", schedule, { passive: true });
87
+ const ro = new ResizeObserver(schedule);
88
+ ro.observe(el);
89
+ return () => {
90
+ if (raf) cancelAnimationFrame(raf);
91
+ el.removeEventListener("scroll", schedule);
92
+ ro.disconnect();
93
+ };
94
+ }, [scrollElement, userRows]);
95
+
96
+ // Auto-collapse. Restarts on every jump so a burst of navigation keeps it open.
97
+ useEffect(() => {
98
+ if (!open) return;
99
+ const t = setTimeout(() => setOpen(false), collapseMsRef.current);
100
+ return () => clearTimeout(t);
101
+ }, [open, nav.idx, jumpTick]);
102
+
103
+ // Opening unmounts the puck, so focus would otherwise fall to <body> and restart
104
+ // tab order at the document top. Only pull focus when the puck was activated —
105
+ // the keyboard shortcut fires while the user is reading and must not steal it.
106
+ useEffect(() => {
107
+ if (!open || !focusOnOpenRef.current) return;
108
+ focusOnOpenRef.current = false;
109
+ const box = clusterRef.current;
110
+ (box?.querySelector<HTMLButtonElement>("button:not(:disabled)") ?? box)?.focus();
111
+ }, [open]);
112
+
113
+ // Collapsing unmounts whatever the user was on — hand focus back to the puck.
114
+ useEffect(() => {
115
+ if (open || !heldFocusRef.current) return;
116
+ heldFocusRef.current = false;
117
+ puckRef.current?.focus();
118
+ }, [open]);
119
+
120
+ const afterJump = useCallback(() => {
121
+ collapseMsRef.current = AUTO_COLLAPSE_MS;
122
+ setJumpTick((n) => n + 1);
123
+ }, []);
124
+
125
+ const goUp = useCallback(() => {
126
+ const el = scrollElement;
127
+ if (!el) return;
128
+ const top = el.scrollTop;
129
+ const elTop = el.getBoundingClientRect().top;
130
+ let target: number | undefined;
131
+ for (const r of userRows(el)) {
132
+ const t = rowTop(el, r, elTop);
133
+ if (t < top - 4) target = t; // ascending — keep the last one still above
134
+ else break;
135
+ }
136
+ if (target != null) el.scrollTo({ top: target, behavior: "auto" });
137
+ afterJump();
138
+ }, [scrollElement, userRows, afterJump]);
139
+
140
+ const goDown = useCallback(() => {
141
+ const el = scrollElement;
142
+ if (!el) return;
143
+ const top = el.scrollTop;
144
+ const elTop = el.getBoundingClientRect().top;
145
+ const next = userRows(el).map((r) => rowTop(el, r, elTop)).find((t) => t > top + 4);
146
+ if (next != null) el.scrollTo({ top: next, behavior: "auto" });
147
+ else scrollToBottom({ animation: "instant" });
148
+ afterJump();
149
+ }, [scrollElement, userRows, scrollToBottom, afterJump]);
150
+
151
+ const goBottom = useCallback(() => {
152
+ scrollToBottom({ animation: "instant" });
153
+ afterJump();
154
+ }, [scrollToBottom, afterJump]);
155
+
156
+ // Global Alt+Arrow shortcuts arrive as window events from useGlobalKeybindings.
157
+ useEffect(() => {
158
+ const el = scrollElement;
159
+ if (!el) return;
160
+ const run = (fn: () => void) => () => {
161
+ if (!ownsGlobalShortcut(el)) return;
162
+ fn();
163
+ // Must follow the jump — the jump handlers reset the delay to the full value.
164
+ collapseMsRef.current = KEYBOARD_PEEK_MS;
165
+ setOpen(true);
166
+ };
167
+ const onPrev = run(goUp);
168
+ const onNext = run(goDown);
169
+ window.addEventListener("chat-nav-prev", onPrev);
170
+ window.addEventListener("chat-nav-next", onNext);
171
+ return () => {
172
+ window.removeEventListener("chat-nav-prev", onPrev);
173
+ window.removeEventListener("chat-nav-next", onNext);
174
+ };
175
+ }, [scrollElement, goUp, goDown]);
176
+
177
+ const rowClass = cn(
178
+ "w-[34px] flex items-center justify-center rounded-[9px] text-text-secondary",
179
+ "motion-safe:transition-colors hover:bg-surface-elevated hover:text-foreground",
180
+ "disabled:opacity-30 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-text-secondary",
181
+ isMobile ? "h-9" : "h-[30px]",
182
+ );
183
+
184
+ return (
185
+ <div className="absolute bottom-3 right-2.5 z-10 flex flex-col items-end gap-1.5">
186
+ {open ? (
187
+ <div
188
+ ref={clusterRef}
189
+ tabIndex={-1}
190
+ className="flex flex-col p-[3px] rounded-xl border border-border bg-surface-elevated/95 backdrop-blur-[8px] shadow-lg outline-none"
191
+ onFocus={() => { heldFocusRef.current = true; }}
192
+ onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}
193
+ >
194
+ <button type="button" onClick={goUp} disabled={!nav.hasAbove} aria-label="Jump to previous message" className={rowClass}>
195
+ <ChevronUp className="size-[15px]" />
196
+ </button>
197
+ <span
198
+ className="font-mono text-[10px] font-medium text-text-subtle text-center py-px"
199
+ aria-label={`Message ${nav.idx || 1} of ${nav.total}`}
200
+ >
201
+ {nav.total ? `${nav.idx || 1}/${nav.total}` : "0/0"}
202
+ </span>
203
+ <button type="button" onClick={goDown} disabled={nav.atBottom} aria-label="Jump to next message" className={rowClass}>
204
+ <ChevronDown className="size-[15px]" />
205
+ </button>
206
+ <button type="button" onClick={goBottom} disabled={nav.atBottom} aria-label="Jump to latest" className={rowClass}>
207
+ <ChevronsDown className="size-[13px]" />
208
+ </button>
209
+ </div>
210
+ ) : (
211
+ <button
212
+ ref={puckRef}
213
+ type="button"
214
+ onClick={() => { collapseMsRef.current = AUTO_COLLAPSE_MS; focusOnOpenRef.current = true; setOpen(true); }}
215
+ aria-label="Navigate messages"
216
+ aria-expanded={false}
217
+ title="Navigate messages"
218
+ // Mobile keeps a 36px circle inside a 44px hit area.
219
+ className={cn("group flex items-center justify-center", isMobile ? "size-11" : "size-[30px]")}
220
+ >
221
+ <span
222
+ className={cn(
223
+ "flex items-center justify-center rounded-full border border-border bg-surface-elevated/55 backdrop-blur-[6px] text-text-subtle",
224
+ "motion-safe:transition-colors group-hover:bg-surface-elevated group-hover:text-foreground",
225
+ isMobile ? "size-9" : "size-[30px]",
226
+ )}
227
+ >
228
+ <ChevronsUpDown className="size-[15px]" />
229
+ </span>
230
+ </button>
231
+ )}
232
+ </div>
233
+ );
234
+ }
@@ -411,6 +411,19 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
411
411
  [handleSend, clearDraft, editFork, sessionId, projectName, handleEditSend, buildMessageWithAttachments],
412
412
  );
413
413
 
414
+ // Past user messages for the composer's ArrowUp/Down recall. Read through a ref
415
+ // so the getter identity stays stable — `messages` changes on every stream chunk,
416
+ // and a fresh array each time would break MessageInput's memo().
417
+ const messagesRef = useRef(messages);
418
+ messagesRef.current = messages;
419
+ const getUserHistory = useCallback(
420
+ () =>
421
+ messagesRef.current
422
+ .filter((m) => m.role === "user" && m.content.trim())
423
+ .map((m) => m.content),
424
+ [],
425
+ );
426
+
414
427
  /** Draft auto-save callback — called by MessageInput on content change */
415
428
  const handleContentChange = useCallback(
416
429
  (content: string, attachments?: DraftAttachment[]) => {
@@ -680,6 +693,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
680
693
  onExternalPathsConsumed={handleExternalPathsConsumed}
681
694
  onDisambiguate={handleDisambiguate}
682
695
  onContentChange={handleContentChange}
696
+ getUserHistory={getUserHistory}
683
697
  permissionMode={permissionMode}
684
698
  onModeChange={setPermissionMode}
685
699
  providerId={providerId}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Shell-style recall over a chat session's past user messages.
3
+ *
4
+ * `index` counts back from the newest message (0 = newest, 1 = the one before
5
+ * it, …); -1 means the composer holds a live draft rather than a recalled
6
+ * message. `history` arrives oldest-first, matching transcript order.
7
+ */
8
+ export interface HistoryStep {
9
+ index: number;
10
+ text: string;
11
+ }
12
+
13
+ /**
14
+ * Move `delta` steps into the past (+1) or back toward the draft (-1).
15
+ * Returns null when the step would fall outside the history.
16
+ */
17
+ export function stepHistory(history: string[], index: number, delta: number): HistoryStep | null {
18
+ if (history.length === 0) return null;
19
+ const next = index + delta;
20
+ if (next < -1 || next >= history.length) return null;
21
+ return { index: next, text: next === -1 ? "" : history[history.length - 1 - next]! };
22
+ }
@@ -3,8 +3,11 @@ import { ArrowUp, Square, Paperclip, Loader2, Mic, MicOff, Zap, ListOrdered, Clo
3
3
  import { useVoiceInput } from "@/hooks/use-voice-input";
4
4
  import { api, projectUrl, getAuthToken } from "@/lib/api-client";
5
5
  import { randomId } from "@/lib/utils";
6
+ import { ownsGlobalShortcut } from "@/lib/owns-global-shortcut";
6
7
  import { isImageFile } from "@/lib/file-support";
7
8
  import { AttachmentChips } from "./attachment-chips";
9
+ import { stepHistory } from "./message-history-recall";
10
+ import { toComposerDraft } from "./user-message-parse";
8
11
  import { ModeSelector, getModeLabel, getModeIcon } from "./mode-selector";
9
12
  import { ProviderSelector } from "./provider-selector";
10
13
  import { ModelThinkingSelector } from "./model-thinking-selector";
@@ -56,6 +59,8 @@ interface MessageInputProps {
56
59
  clearSignal?: number;
57
60
  /** Called on content change for draft auto-save */
58
61
  onContentChange?: (content: string, attachments?: Array<{ name: string; path: string }>) => void;
62
+ /** Returns this session's user messages, oldest first — powers ArrowUp/Down recall */
63
+ getUserHistory?: () => string[];
59
64
  /** Auto-focus textarea on mount */
60
65
  autoFocus?: boolean;
61
66
  /** Current permission mode */
@@ -98,6 +103,7 @@ export const MessageInput = memo(function MessageInput({
98
103
  initialValue,
99
104
  clearSignal,
100
105
  onContentChange,
106
+ getUserHistory,
101
107
  autoFocus,
102
108
  permissionMode,
103
109
  onModeChange,
@@ -156,6 +162,34 @@ export const MessageInput = memo(function MessageInput({
156
162
  : mobileTextareaRef.current;
157
163
  }, []);
158
164
 
165
+ // Shell-style history recall. Counts back from the newest user message
166
+ // (0 = newest); -1 means not browsing, so the input holds a live draft.
167
+ const historyIdxRef = useRef(-1);
168
+
169
+ /** Step through past user messages. Returns false when the step is out of range. */
170
+ const recallHistory = useCallback(
171
+ (delta: number) => {
172
+ const step = stepHistory(getUserHistory?.() ?? [], historyIdxRef.current, delta);
173
+ if (!step) return false;
174
+ historyIdxRef.current = step.index;
175
+ // Recalled content still carries its send-time wrappers — unwrap it back
176
+ // into what was typed, with the delegated agent restored as a chip.
177
+ const { agent, text } = toComposerDraft(step.text);
178
+ setAgentTag(agent);
179
+ writeTextareas(text);
180
+ const ta = getVisibleTextarea();
181
+ if (!ta) return true;
182
+ ta.focus();
183
+ requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = text.length; });
184
+ if (needsJsResize.current) {
185
+ ta.style.height = "auto";
186
+ ta.style.height = Math.min(ta.scrollHeight, ta === mobileTextareaRef.current ? 80 : 160) + "px";
187
+ }
188
+ return true;
189
+ },
190
+ [getUserHistory, writeTextareas, getVisibleTextarea],
191
+ );
192
+
159
193
  // Voice input (Web Speech API)
160
194
  const voice = useVoiceInput();
161
195
  // Store pre-voice text so voice appends to existing input
@@ -184,12 +218,18 @@ export const MessageInput = memo(function MessageInput({
184
218
  }
185
219
  }, [voice.isListening, voice.start, voice.stop, voiceResultCb]);
186
220
 
187
- // Listen for global keyboard shortcut (Cmd+Shift+V) to toggle voice
221
+ // Listen for global keyboard shortcut (Cmd+Shift+V) to toggle voice.
222
+ // Guarded so that only the focused panel's chat reacts — every chat tab stays
223
+ // mounted, so an unguarded listener toggles the mic in all of them at once.
188
224
  useEffect(() => {
189
- const handler = () => { if (voice.supported) handleVoiceToggle(); };
225
+ const handler = () => {
226
+ if (!voice.supported) return;
227
+ if (!ownsGlobalShortcut(getVisibleTextarea())) return;
228
+ handleVoiceToggle();
229
+ };
190
230
  window.addEventListener("toggle-voice-input", handler);
191
231
  return () => window.removeEventListener("toggle-voice-input", handler);
192
- }, [voice.supported, handleVoiceToggle]);
232
+ }, [voice.supported, handleVoiceToggle, getVisibleTextarea]);
193
233
 
194
234
  // Listen for "Send to Chat" from terminal or other tabs — add as attachment chip
195
235
  useEffect(() => {
@@ -485,6 +525,7 @@ export const MessageInput = memo(function MessageInput({
485
525
  setAgentTag(null);
486
526
  setPendingSend(false);
487
527
  setPriority('next');
528
+ historyIdxRef.current = -1;
488
529
  if (needsJsResize.current) {
489
530
  if (textareaRef.current) textareaRef.current.style.height = "auto";
490
531
  if (mobileTextareaRef.current) mobileTextareaRef.current.style.height = "auto";
@@ -520,6 +561,16 @@ export const MessageInput = memo(function MessageInput({
520
561
  handleSend();
521
562
  return;
522
563
  }
564
+ // History recall. Only starts from an empty input so ArrowUp keeps moving
565
+ // the caret inside a multi-line draft. The slash/@ pickers swallow these
566
+ // keys before they reach here while either one is open.
567
+ // Bare arrows only — Alt+Arrow is the global chat-transcript jump.
568
+ if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
569
+ const browsing = historyIdxRef.current >= 0;
570
+ if (!browsing && (e.key === "ArrowDown" || valueRef.current !== "" || agentTag)) return;
571
+ if (recallHistory(e.key === "ArrowUp" ? 1 : -1)) e.preventDefault();
572
+ return;
573
+ }
523
574
  // Shift+Tab: cycle permission mode
524
575
  if (e.shiftKey && e.key === "Tab") {
525
576
  e.preventDefault();
@@ -529,7 +580,7 @@ export const MessageInput = memo(function MessageInput({
529
580
  onModeChange?.(next);
530
581
  }
531
582
  },
532
- [handleSend, permissionMode, onModeChange],
583
+ [handleSend, permissionMode, onModeChange, recallHistory, agentTag],
533
584
  );
534
585
 
535
586
  const updatePickerState = useCallback(
@@ -582,6 +633,8 @@ export const MessageInput = memo(function MessageInput({
582
633
  const el = e.target;
583
634
  const text = el.value;
584
635
  valueRef.current = text;
636
+ // Typing turns a recalled message back into a live draft.
637
+ historyIdxRef.current = -1;
585
638
  // Sync the other textarea (handles viewport rotation edge case)
586
639
  const other = el === textareaRef.current ? mobileTextareaRef.current : textareaRef.current;
587
640
  if (other) other.value = text;