@hienlh/ppm 0.7.41 → 0.8.0

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 (56) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/web/assets/chat-tab-LTwYS5_e.js +7 -0
  3. package/dist/web/assets/{code-editor-CaKnPjkU.js → code-editor-BakDn6rL.js} +1 -1
  4. package/dist/web/assets/{database-viewer-DUAq3r2M.js → database-viewer-COaZMlpv.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-C6w7tDMN.js → diff-viewer-COSbmidI.js} +1 -1
  6. package/dist/web/assets/git-graph-CKoW0Ky-.js +1 -0
  7. package/dist/web/assets/index-BGTzm7B1.js +28 -0
  8. package/dist/web/assets/index-CeNox-VV.css +2 -0
  9. package/dist/web/assets/input-CE3bFwLk.js +41 -0
  10. package/dist/web/assets/keybindings-store-FQhxQ72s.js +1 -0
  11. package/dist/web/assets/{markdown-renderer-Ckj0mfYc.js → markdown-renderer-BKgH2iGf.js} +1 -1
  12. package/dist/web/assets/{postgres-viewer-m6qNfnAF.js → postgres-viewer-DBOv2ha2.js} +1 -1
  13. package/dist/web/assets/settings-tab-BZqkWI4u.js +1 -0
  14. package/dist/web/assets/{sqlite-viewer-6d233-2k.js → sqlite-viewer-BY242odW.js} +1 -1
  15. package/dist/web/assets/switch-BEmt1alu.js +1 -0
  16. package/dist/web/assets/{terminal-tab-BaHGzGJ6.js → terminal-tab-BiUqECPk.js} +1 -1
  17. package/dist/web/index.html +4 -4
  18. package/dist/web/sw.js +1 -1
  19. package/package.json +1 -1
  20. package/src/providers/claude-agent-sdk.ts +108 -64
  21. package/src/providers/mock-provider.ts +1 -0
  22. package/src/providers/provider.interface.ts +1 -0
  23. package/src/server/routes/git.ts +16 -2
  24. package/src/server/ws/chat.ts +43 -26
  25. package/src/services/chat.service.ts +3 -1
  26. package/src/services/git.service.ts +45 -8
  27. package/src/types/api.ts +1 -1
  28. package/src/types/chat.ts +5 -0
  29. package/src/types/config.ts +21 -0
  30. package/src/types/git.ts +4 -0
  31. package/src/web/components/chat/chat-tab.tsx +26 -8
  32. package/src/web/components/chat/message-input.tsx +61 -1
  33. package/src/web/components/chat/message-list.tsx +9 -1
  34. package/src/web/components/chat/mode-selector.tsx +117 -0
  35. package/src/web/components/git/git-graph-branch-label.tsx +124 -0
  36. package/src/web/components/git/git-graph-constants.ts +185 -0
  37. package/src/web/components/git/git-graph-detail.tsx +107 -0
  38. package/src/web/components/git/git-graph-dialog.tsx +72 -0
  39. package/src/web/components/git/git-graph-row.tsx +167 -0
  40. package/src/web/components/git/git-graph-settings-dialog.tsx +104 -0
  41. package/src/web/components/git/git-graph-svg.tsx +54 -0
  42. package/src/web/components/git/git-graph-toolbar.tsx +195 -0
  43. package/src/web/components/git/git-graph.tsx +143 -681
  44. package/src/web/components/git/use-column-resize.ts +33 -0
  45. package/src/web/components/git/use-git-graph.ts +201 -0
  46. package/src/web/components/settings/ai-settings-section.tsx +42 -0
  47. package/src/web/hooks/use-chat.ts +3 -3
  48. package/src/web/lib/api-settings.ts +2 -0
  49. package/dist/web/assets/chat-tab-BoeC0a0w.js +0 -7
  50. package/dist/web/assets/git-graph-9GFTfA5p.js +0 -1
  51. package/dist/web/assets/index-CSS8Cy7l.css +0 -2
  52. package/dist/web/assets/index-CetGEOKq.js +0 -28
  53. package/dist/web/assets/input-CVIzrYsH.js +0 -41
  54. package/dist/web/assets/keybindings-store-DiEM7YZ4.js +0 -1
  55. package/dist/web/assets/settings-tab-Di-E48kC.js +0 -1
  56. package/dist/web/assets/switch-UODDpwuO.js +0 -1
@@ -30,6 +30,8 @@ interface SessionEntry {
30
30
  catchUpText: string;
31
31
  /** Reference to the running stream promise — prevents GC */
32
32
  streamPromise?: Promise<void>;
33
+ /** Sticky permission mode for this session */
34
+ permissionMode?: string;
33
35
  }
34
36
 
35
37
  /** Tracks active sessions — persists even when FE disconnects */
@@ -78,7 +80,7 @@ function startCleanupTimer(sessionId: string): void {
78
80
  * Standalone streaming loop — decoupled from WS message handler.
79
81
  * Runs independently so WS close does NOT kill the Claude query.
80
82
  */
81
- async function runStreamLoop(sessionId: string, providerId: string, content: string): Promise<void> {
83
+ async function runStreamLoop(sessionId: string, providerId: string, content: string, permissionMode?: string): Promise<void> {
82
84
  const entry = activeSessions.get(sessionId);
83
85
  if (!entry) {
84
86
  console.error(`[chat] session=${sessionId} runStreamLoop: no entry — aborting`);
@@ -138,7 +140,7 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
138
140
  safeSend(sessionId, { type: "streaming_status", status: "connecting", elapsed });
139
141
  }, 5_000);
140
142
 
141
- for await (const event of chatService.sendMessage(providerId, sessionId, content)) {
143
+ for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode })) {
142
144
  if (abortController.signal.aborted) break;
143
145
  eventCount++;
144
146
  const ev = event as any;
@@ -369,27 +371,47 @@ export const chatWebSocket = {
369
371
  entry0.ws = ws;
370
372
  }
371
373
 
372
- const entry = activeSessions.get(sessionId);
373
- const providerId = entry?.providerId ?? providerRegistry.getDefault().id;
374
+ let entry = activeSessions.get(sessionId);
375
+
376
+ // Auto-create entry if missing — handles: message before open (Bun race), or session cleaned up
377
+ if (!entry) {
378
+ const { projectName: pn } = ws.data;
379
+ const session = chatService.getSession(sessionId);
380
+ const pid = session?.providerId ?? providerRegistry.getDefault().id;
381
+ let pp: string | undefined;
382
+ if (pn) { try { pp = resolveProjectPath(pn); } catch { /* ignore */ } }
383
+ const pi = setInterval(() => {
384
+ try { ws.send(JSON.stringify({ type: "ping" })); } catch { /* ws may be closed */ }
385
+ }, PING_INTERVAL_MS);
386
+ activeSessions.set(sessionId, {
387
+ providerId: pid, ws, projectPath: pp, projectName: pn,
388
+ pingInterval: pi, isStreaming: false, needsCatchUp: false, catchUpText: "",
389
+ });
390
+ entry = activeSessions.get(sessionId)!;
391
+ console.log(`[chat] session=${sessionId} auto-created entry in message handler`);
392
+ }
393
+
394
+ const providerId = entry.providerId ?? providerRegistry.getDefault().id;
374
395
 
375
396
  // Client-initiated handshake — FE sends "ready" after onopen.
376
397
  // Re-send status so tunnel connections (Cloudflare) that missed the
377
398
  // open-handler message still get connected/status confirmation.
378
399
  if (parsed.type === "ready") {
379
- if (entry) {
380
- ws.send(JSON.stringify({
381
- type: "status",
382
- sessionId,
383
- isStreaming: entry.isStreaming,
384
- pendingApproval: entry.pendingApprovalEvent ?? null,
385
- }));
386
- } else {
387
- ws.send(JSON.stringify({ type: "connected", sessionId }));
388
- }
400
+ ws.send(JSON.stringify({
401
+ type: "status",
402
+ sessionId,
403
+ isStreaming: entry.isStreaming,
404
+ pendingApproval: entry.pendingApprovalEvent ?? null,
405
+ }));
389
406
  return;
390
407
  }
391
408
 
392
409
  if (parsed.type === "message") {
410
+ // Store permission mode — sticky for this session
411
+ if (parsed.permissionMode) {
412
+ entry.permissionMode = parsed.permissionMode;
413
+ }
414
+
393
415
  // Send immediate feedback BEFORE any async work — prevents "stuck thinking"
394
416
  // when resumeSession is slow (e.g. sdkListSessions spawns subprocess on first call)
395
417
  safeSend(sessionId, { type: "streaming_status", status: "connecting", elapsed: 0 });
@@ -405,12 +427,12 @@ export const chatWebSocket = {
405
427
  logSessionEvent(sessionId, "PERF", `resumeSession took ${elapsed}ms`);
406
428
  }
407
429
  }
408
- if (entry?.projectPath && provider && "ensureProjectPath" in provider) {
430
+ if (entry.projectPath && provider && "ensureProjectPath" in provider) {
409
431
  (provider as any).ensureProjectPath(sessionId, entry.projectPath);
410
432
  }
411
433
 
412
434
  // If already streaming, abort current query first and wait for cleanup
413
- if (entry?.isStreaming && entry.abort) {
435
+ if (entry.isStreaming && entry.abort) {
414
436
  console.log(`[chat] session=${sessionId} aborting current query for new message`);
415
437
  entry.abort.abort();
416
438
  // Wait for stream loop to finish cleanup
@@ -421,16 +443,11 @@ export const chatWebSocket = {
421
443
 
422
444
  // Store promise reference on entry to prevent GC from collecting the async operation.
423
445
  // Use setTimeout(0) to detach from WS handler's async scope.
424
- if (entry) {
425
- entry.streamPromise = new Promise<void>((resolve) => {
426
- setTimeout(() => {
427
- runStreamLoop(sessionId, providerId, parsed.content).then(resolve, resolve);
428
- }, 0);
429
- });
430
- } else {
431
- console.warn(`[chat] session=${sessionId} no entry when starting stream loop — message may have arrived before open()`);
432
- setTimeout(() => runStreamLoop(sessionId, providerId, parsed.content), 0);
433
- }
446
+ entry.streamPromise = new Promise<void>((resolve) => {
447
+ setTimeout(() => {
448
+ runStreamLoop(sessionId, providerId, parsed.content, entry.permissionMode).then(resolve, resolve);
449
+ }, 0);
450
+ });
434
451
  } else if (parsed.type === "cancel") {
435
452
  const provider = providerRegistry.get(providerId);
436
453
  if (provider && "abortQuery" in provider && typeof (provider as any).abortQuery === "function") {
@@ -5,6 +5,7 @@ import type {
5
5
  SessionInfo,
6
6
  ChatEvent,
7
7
  ChatMessage,
8
+ SendMessageOpts,
8
9
  } from "../providers/provider.interface.ts";
9
10
  import { MockProvider } from "../providers/mock-provider.ts";
10
11
 
@@ -70,13 +71,14 @@ class ChatService {
70
71
  providerId: string,
71
72
  sessionId: string,
72
73
  message: string,
74
+ opts?: SendMessageOpts,
73
75
  ): AsyncIterable<ChatEvent> {
74
76
  const provider = providerRegistry.get(providerId);
75
77
  if (!provider) {
76
78
  yield { type: "error", message: `Provider "${providerId}" not found` };
77
79
  return;
78
80
  }
79
- yield* provider.sendMessage(sessionId, message);
81
+ yield* provider.sendMessage(sessionId, message, opts);
80
82
  }
81
83
 
82
84
  /** Look up a session across all providers (for WS handler) */
@@ -199,6 +199,7 @@ class GitService {
199
199
  commitHash: info.commit,
200
200
  ahead: 0,
201
201
  behind: 0,
202
+ remotes: [],
202
203
  }));
203
204
  }
204
205
 
@@ -232,11 +233,14 @@ class GitService {
232
233
  async graphData(
233
234
  projectPath: string,
234
235
  maxCount = 200,
236
+ skip = 0,
235
237
  ): Promise<GitGraphData> {
236
238
  const git = this.git(projectPath);
237
239
 
238
- // Use simple-git built-in log
239
- const log = await git.log({ "--all": null, maxCount });
240
+ // Use simple-git built-in log with skip support
241
+ const logOpts: Record<string, unknown> = { "--all": null, maxCount };
242
+ if (skip > 0) (logOpts as Record<string, unknown>)["--skip"] = skip;
243
+ const log = await git.log(logOpts);
240
244
 
241
245
  const commits: GitCommit[] = log.all.map((c) => ({
242
246
  hash: c.hash,
@@ -252,10 +256,12 @@ class GitService {
252
256
 
253
257
  // Get parent hashes via raw format
254
258
  try {
259
+ const skipArgs = skip > 0 ? [`--skip=${skip}`] : [];
255
260
  const parentLog = await git.raw([
256
261
  "log",
257
262
  "--all",
258
263
  `--max-count=${maxCount}`,
264
+ ...skipArgs,
259
265
  "--format=%H %P",
260
266
  ]);
261
267
  const parentMap = new Map<string, string[]>();
@@ -273,13 +279,13 @@ class GitService {
273
279
 
274
280
  const branchSummary = await git.branch(["-a", "--no-color"]);
275
281
 
276
- // simple-git branch().commit returns abbreviated hash — map to full hash
282
+ // simple-git branch().commit returns abbreviated hash of varying lengths index all common lengths
277
283
  const abbrToFull = new Map<string, string>();
278
284
  for (const c of commits) {
279
- abbrToFull.set(c.abbreviatedHash, c.hash);
280
- // Also index with longer prefixes for safety
281
- abbrToFull.set(c.hash.slice(0, 8), c.hash);
282
- abbrToFull.set(c.hash.slice(0, 10), c.hash);
285
+ abbrToFull.set(c.hash, c.hash);
286
+ for (const len of [7, 8, 9, 10, 11, 12]) {
287
+ abbrToFull.set(c.hash.slice(0, len), c.hash);
288
+ }
283
289
  }
284
290
 
285
291
  const branches: GitBranch[] = Object.entries(branchSummary.branches).map(
@@ -290,10 +296,41 @@ class GitService {
290
296
  commitHash: abbrToFull.get(info.commit) ?? info.commit,
291
297
  ahead: 0,
292
298
  behind: 0,
299
+ remotes: [] as string[],
293
300
  }),
294
301
  );
295
302
 
296
- return { commits, branches };
303
+ // Compute remote tracking: for each local branch, find remotes that have it
304
+ const localBranches = branches.filter((b) => !b.remote);
305
+ const remoteBranches = branches.filter((b) => b.remote);
306
+ for (const local of localBranches) {
307
+ for (const remote of remoteBranches) {
308
+ // remotes/origin/main → remote="origin", branch="main"
309
+ const stripped = remote.name.replace(/^remotes\//, "");
310
+ const slashIdx = stripped.indexOf("/");
311
+ if (slashIdx < 0) continue;
312
+ const remoteName = stripped.slice(0, slashIdx);
313
+ const remoteBranchName = stripped.slice(slashIdx + 1);
314
+ if (remoteBranchName === local.name) {
315
+ local.remotes.push(remoteName);
316
+ }
317
+ }
318
+ }
319
+
320
+ // Get HEAD commit hash
321
+ let head = "";
322
+ try {
323
+ head = (await git.revparse(["HEAD"])).trim();
324
+ } catch {
325
+ // empty repo
326
+ }
327
+
328
+ return { commits, branches, head };
329
+ }
330
+
331
+ async fetch(projectPath: string, remote?: string): Promise<void> {
332
+ const args = remote ? [remote] : ["--all"];
333
+ await this.git(projectPath).fetch(args);
297
334
  }
298
335
 
299
336
  async discardChanges(projectPath: string, files: string[]): Promise<void> {
package/src/types/api.ts CHANGED
@@ -23,7 +23,7 @@ export type TerminalWsMessage =
23
23
 
24
24
  /** WebSocket message types (chat) */
25
25
  export type ChatWsClientMessage =
26
- | { type: "message"; content: string }
26
+ | { type: "message"; content: string; permissionMode?: string }
27
27
  | { type: "cancel" }
28
28
  | { type: "approval_response"; requestId: string; approved: boolean; reason?: string; data?: unknown };
29
29
 
package/src/types/chat.ts CHANGED
@@ -1,3 +1,7 @@
1
+ export interface SendMessageOpts {
2
+ permissionMode?: import("./config").PermissionMode | string;
3
+ }
4
+
1
5
  export interface AIProvider {
2
6
  id: string;
3
7
  name: string;
@@ -8,6 +12,7 @@ export interface AIProvider {
8
12
  sendMessage(
9
13
  sessionId: string,
10
14
  message: string,
15
+ opts?: SendMessageOpts,
11
16
  ): AsyncIterable<ChatEvent>;
12
17
  /** Resolve a pending tool/question approval by requestId */
13
18
  resolveApproval?(requestId: string, approved: boolean, data?: unknown): void;
@@ -40,6 +40,9 @@ export interface AIConfig {
40
40
  providers: Record<string, AIProviderConfig>;
41
41
  }
42
42
 
43
+ const VALID_PERMISSION_MODES = ["default", "acceptEdits", "plan", "bypassPermissions"] as const;
44
+ export type PermissionMode = typeof VALID_PERMISSION_MODES[number];
45
+
43
46
  export interface AIProviderConfig {
44
47
  type: "agent-sdk" | "mock";
45
48
  api_key_env?: string;
@@ -49,6 +52,8 @@ export interface AIProviderConfig {
49
52
  max_turns?: number;
50
53
  max_budget_usd?: number;
51
54
  thinking_budget_tokens?: number;
55
+ permission_mode?: PermissionMode;
56
+ system_prompt?: string;
52
57
  }
53
58
 
54
59
  export const DEFAULT_CONFIG: PpmConfig = {
@@ -67,6 +72,7 @@ export const DEFAULT_CONFIG: PpmConfig = {
67
72
  model: "claude-sonnet-4-6",
68
73
  effort: "high",
69
74
  max_turns: 100,
75
+ permission_mode: "bypassPermissions",
70
76
  },
71
77
  },
72
78
  },
@@ -100,6 +106,16 @@ export function validateAIProviderConfig(config: Partial<AIProviderConfig>): str
100
106
  if (config.thinking_budget_tokens != null && (!Number.isInteger(config.thinking_budget_tokens) || config.thinking_budget_tokens < 0)) {
101
107
  errors.push("thinking_budget_tokens must be integer >= 0");
102
108
  }
109
+ if (config.permission_mode != null && !VALID_PERMISSION_MODES.includes(config.permission_mode as any)) {
110
+ errors.push(`permission_mode must be one of: ${VALID_PERMISSION_MODES.join(", ")}`);
111
+ }
112
+ if (config.system_prompt != null) {
113
+ if (typeof config.system_prompt !== "string") {
114
+ errors.push("system_prompt must be a string");
115
+ } else if (config.system_prompt.length > 10000) {
116
+ errors.push("system_prompt must be 10000 characters or less");
117
+ }
118
+ }
103
119
  return errors;
104
120
  }
105
121
 
@@ -143,6 +159,11 @@ export function sanitizeConfig(config: PpmConfig): boolean {
143
159
  provider.effort = "high";
144
160
  dirty = true;
145
161
  }
162
+ // Fix invalid permission_mode
163
+ if (provider.permission_mode != null && !["default", "acceptEdits", "plan", "bypassPermissions"].includes(provider.permission_mode)) {
164
+ provider.permission_mode = "bypassPermissions";
165
+ dirty = true;
166
+ }
146
167
  }
147
168
 
148
169
  return dirty;
package/src/types/git.ts CHANGED
@@ -17,6 +17,8 @@ export interface GitBranch {
17
17
  commitHash: string;
18
18
  ahead: number;
19
19
  behind: number;
20
+ /** Remote names that track this local branch (e.g. ["origin", "upstream"]) */
21
+ remotes: string[];
20
22
  }
21
23
 
22
24
  export interface GitStatus {
@@ -35,6 +37,8 @@ export interface GitFileChange {
35
37
  export interface GitGraphData {
36
38
  commits: GitCommit[];
37
39
  branches: GitBranch[];
40
+ /** Full hash of the currently checked-out commit (HEAD) */
41
+ head: string;
38
42
  }
39
43
 
40
44
  export interface GitDiffResult {
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useRef, useEffect, type DragEvent } from "react";
1
+ import { useState, useCallback, useRef, useEffect } from "react";
2
2
  import { Upload, X } from "lucide-react";
3
3
  import { api, projectUrl } from "@/lib/api-client";
4
4
  import { useChat } from "@/hooks/use-chat";
@@ -8,11 +8,13 @@ import { useSettingsStore } from "@/stores/settings-store";
8
8
  import { usePanelStore } from "@/stores/panel-store";
9
9
  import { useNotificationStore } from "@/stores/notification-store";
10
10
  import { openBugReportPopup } from "@/lib/report-bug";
11
+ import { getAISettings } from "@/lib/api-settings";
11
12
  import { MessageList } from "./message-list";
12
13
  import { MessageInput, type ChatAttachment } from "./message-input";
13
14
  import { SlashCommandPicker, type SlashItem } from "./slash-command-picker";
14
15
  import { FilePicker } from "./file-picker";
15
16
  import { ChatHistoryBar } from "./chat-history-bar";
17
+ import type { DragEvent } from "react";
16
18
  import type { FileNode } from "../../../types/project";
17
19
  import type { Session, SessionInfo } from "../../../types/chat";
18
20
 
@@ -41,6 +43,11 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
41
43
  const [fileFilter, setFileFilter] = useState("");
42
44
  const [fileSelected, setFileSelected] = useState<FileNode | null>(null);
43
45
 
46
+ // Permission mode — per-session sticky, falls back to global default
47
+ const [permissionMode, setPermissionMode] = useState<string | undefined>(
48
+ (metadata?.permissionMode as string) ?? undefined,
49
+ );
50
+
44
51
  // Drag-and-drop state
45
52
  const [isDragging, setIsDragging] = useState(false);
46
53
  const [externalFiles, setExternalFiles] = useState<File[] | null>(null);
@@ -55,13 +62,22 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
55
62
  const { usageInfo, usageLoading, lastFetchedAt, refreshUsage } =
56
63
  useUsage(projectName, providerId);
57
64
 
58
- // Persist sessionId and providerId to tab metadata so reload restores the session
65
+ // Load global default permission mode on mount (if no per-session override)
66
+ useEffect(() => {
67
+ if (permissionMode) return;
68
+ getAISettings().then((s) => {
69
+ const provider = s.providers[s.default_provider ?? "claude"];
70
+ setPermissionMode(provider?.permission_mode ?? "bypassPermissions");
71
+ }).catch(() => {});
72
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
73
+
74
+ // Persist sessionId, providerId, and permissionMode to tab metadata
59
75
  useEffect(() => {
60
76
  if (!tabId || !sessionId) return;
61
77
  updateTab(tabId, {
62
- metadata: { ...metadata, sessionId, providerId },
78
+ metadata: { ...metadata, sessionId, providerId, permissionMode },
63
79
  });
64
- }, [sessionId, providerId]); // eslint-disable-line react-hooks/exhaustive-deps
80
+ }, [sessionId, providerId, permissionMode]); // eslint-disable-line react-hooks/exhaustive-deps
65
81
 
66
82
  const {
67
83
  messages,
@@ -116,7 +132,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
116
132
  const msg = pendingForkMsgRef.current;
117
133
  pendingForkMsgRef.current = undefined;
118
134
  if (tabId) updateTab(tabId, { metadata: { ...metadata, pendingMessage: undefined } });
119
- setTimeout(() => sendMessage(msg), 100);
135
+ setTimeout(() => sendMessage(msg, { permissionMode }), 100);
120
136
  }
121
137
  }, [isConnected, sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
122
138
 
@@ -194,7 +210,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
194
210
  setSessionId(session.id);
195
211
  setProviderId(session.providerId);
196
212
  setTimeout(() => {
197
- sendMessage(fullContent);
213
+ sendMessage(fullContent, { permissionMode });
198
214
  }, 500);
199
215
  return;
200
216
  } catch (e) {
@@ -202,9 +218,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
202
218
  return;
203
219
  }
204
220
  }
205
- sendMessage(fullContent);
221
+ sendMessage(fullContent, { permissionMode });
206
222
  },
207
- [sessionId, providerId, projectName, sendMessage, buildMessageWithAttachments],
223
+ [sessionId, providerId, projectName, sendMessage, buildMessageWithAttachments, permissionMode],
208
224
  );
209
225
 
210
226
  // --- Slash picker handlers ---
@@ -358,6 +374,8 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
358
374
  onFileItemsLoaded={setFileItems}
359
375
  fileSelected={fileSelected}
360
376
  externalFiles={externalFiles}
377
+ permissionMode={permissionMode}
378
+ onModeChange={setPermissionMode}
361
379
  />
362
380
  </div>
363
381
 
@@ -4,6 +4,7 @@ import { api, projectUrl, getAuthToken } from "@/lib/api-client";
4
4
  import { randomId } from "@/lib/utils";
5
5
  import { isSupportedFile, isImageFile } from "@/lib/file-support";
6
6
  import { AttachmentChips } from "./attachment-chips";
7
+ import { ModeSelector, getModeLabel, getModeIcon } from "./mode-selector";
7
8
  import type { SlashItem } from "./slash-command-picker";
8
9
  import type { FileNode } from "../../../types/project";
9
10
  import { flattenFileTree } from "./file-picker";
@@ -37,6 +38,10 @@ interface MessageInputProps {
37
38
  externalFiles?: File[] | null;
38
39
  /** Pre-fill input value (e.g. from command palette "Ask AI") */
39
40
  initialValue?: string;
41
+ /** Current permission mode */
42
+ permissionMode?: string;
43
+ /** Permission mode change handler */
44
+ onModeChange?: (mode: string) => void;
40
45
  }
41
46
 
42
47
  export function MessageInput({
@@ -53,9 +58,12 @@ export function MessageInput({
53
58
  fileSelected,
54
59
  externalFiles,
55
60
  initialValue,
61
+ permissionMode,
62
+ onModeChange,
56
63
  }: MessageInputProps) {
57
64
  const [value, setValue] = useState(initialValue ?? "");
58
65
  const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
66
+ const [modeSelectorOpen, setModeSelectorOpen] = useState(false);
59
67
  const textareaRef = useRef<HTMLTextAreaElement>(null);
60
68
  const mobileTextareaRef = useRef<HTMLTextAreaElement>(null);
61
69
  const fileInputRef = useRef<HTMLInputElement>(null);
@@ -276,9 +284,18 @@ export function MessageInput({
276
284
  if (e.key === "Enter" && !e.shiftKey) {
277
285
  e.preventDefault();
278
286
  handleSend();
287
+ return;
288
+ }
289
+ // Shift+Tab: cycle permission mode
290
+ if (e.shiftKey && e.key === "Tab") {
291
+ e.preventDefault();
292
+ const modeIds = ["default", "acceptEdits", "plan", "bypassPermissions"];
293
+ const idx = modeIds.indexOf(permissionMode ?? "bypassPermissions");
294
+ const next = modeIds[(idx + 1) % modeIds.length]!;
295
+ onModeChange?.(next);
279
296
  }
280
297
  },
281
- [handleSend],
298
+ [handleSend, permissionMode, onModeChange],
282
299
  );
283
300
 
284
301
  const updatePickerState = useCallback(
@@ -389,6 +406,19 @@ export function MessageInput({
389
406
  >
390
407
  {/* Attachment chips (inside container, aligned with input) */}
391
408
  <AttachmentChips attachments={attachments} onRemove={removeAttachment} />
409
+ {/* Mobile: mode chip row */}
410
+ <div className="flex items-center gap-1 px-2 pt-2 md:hidden relative">
411
+ <ModeChip
412
+ mode={permissionMode ?? "bypassPermissions"}
413
+ onClick={() => setModeSelectorOpen((v) => !v)}
414
+ />
415
+ <ModeSelector
416
+ value={permissionMode ?? "bypassPermissions"}
417
+ onChange={(m) => onModeChange?.(m)}
418
+ open={modeSelectorOpen}
419
+ onOpenChange={setModeSelectorOpen}
420
+ />
421
+ </div>
392
422
  {/* Mobile: single row — attach + textarea + send */}
393
423
  <div className="flex items-end gap-1 md:hidden px-2 py-2">
394
424
  <button
@@ -459,6 +489,19 @@ export function MessageInput({
459
489
  >
460
490
  <Paperclip className="size-4" />
461
491
  </button>
492
+ {/* Mode indicator chip */}
493
+ <div className="relative">
494
+ <ModeChip
495
+ mode={permissionMode ?? "bypassPermissions"}
496
+ onClick={() => setModeSelectorOpen((v) => !v)}
497
+ />
498
+ <ModeSelector
499
+ value={permissionMode ?? "bypassPermissions"}
500
+ onChange={(m) => onModeChange?.(m)}
501
+ open={modeSelectorOpen}
502
+ onOpenChange={setModeSelectorOpen}
503
+ />
504
+ </div>
462
505
  </div>
463
506
  <div className="flex items-center gap-1">
464
507
  {showCancel ? (
@@ -488,3 +531,20 @@ export function MessageInput({
488
531
  </div>
489
532
  );
490
533
  }
534
+
535
+ /** Small chip showing current permission mode */
536
+ function ModeChip({ mode, onClick }: { mode: string; onClick: () => void }) {
537
+ const Icon = getModeIcon(mode);
538
+ const label = getModeLabel(mode);
539
+ return (
540
+ <button
541
+ type="button"
542
+ onClick={(e) => { e.stopPropagation(); onClick(); }}
543
+ className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] text-text-subtle hover:text-text-primary hover:bg-surface-elevated transition-colors border border-transparent hover:border-border"
544
+ aria-label={`Permission mode: ${label}`}
545
+ >
546
+ <Icon className="size-3" />
547
+ <span className="max-w-[100px] truncate">{label}</span>
548
+ </button>
549
+ );
550
+ }
@@ -153,6 +153,11 @@ function MessageBubble({ message, isStreaming, projectName, onFork }: { message:
153
153
  /** Image extensions that can be previewed inline */
154
154
  const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
155
155
 
156
+ /** Strip system-injected XML tags (e.g. <system-reminder>, <available-deferred-tools>) from message content */
157
+ function stripSystemTags(text: string): string {
158
+ return text.replace(/<(system-reminder|available-deferred-tools|antml:[\w-]+|fast_mode_info|claudeMd|gitStatus|currentDate)[\s\S]*?<\/\1>/g, "").trim();
159
+ }
160
+
156
161
  /** Parse user message content, extracting attached file paths and the actual text */
157
162
  function parseUserAttachments(content: string): { files: string[]; text: string } {
158
163
  // Match: [Attached file: /path] or [Attached files:\n/path1\n/path2\n]
@@ -190,7 +195,10 @@ function isPdfPath(path: string): boolean {
190
195
 
191
196
  /** User message bubble with attachment rendering */
192
197
  function UserBubble({ content, projectName, onFork }: { content: string; projectName?: string; onFork?: () => void }) {
193
- const { files, text } = useMemo(() => parseUserAttachments(content), [content]);
198
+ const { files, text } = useMemo(() => {
199
+ const parsed = parseUserAttachments(content);
200
+ return { files: parsed.files, text: stripSystemTags(parsed.text) };
201
+ }, [content]);
194
202
 
195
203
  return (
196
204
  <div className="flex justify-end group/user">