@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.
- package/CHANGELOG.md +19 -0
- package/dist/web/assets/chat-tab-LTwYS5_e.js +7 -0
- package/dist/web/assets/{code-editor-CaKnPjkU.js → code-editor-BakDn6rL.js} +1 -1
- package/dist/web/assets/{database-viewer-DUAq3r2M.js → database-viewer-COaZMlpv.js} +1 -1
- package/dist/web/assets/{diff-viewer-C6w7tDMN.js → diff-viewer-COSbmidI.js} +1 -1
- package/dist/web/assets/git-graph-CKoW0Ky-.js +1 -0
- package/dist/web/assets/index-BGTzm7B1.js +28 -0
- package/dist/web/assets/index-CeNox-VV.css +2 -0
- package/dist/web/assets/input-CE3bFwLk.js +41 -0
- package/dist/web/assets/keybindings-store-FQhxQ72s.js +1 -0
- package/dist/web/assets/{markdown-renderer-Ckj0mfYc.js → markdown-renderer-BKgH2iGf.js} +1 -1
- package/dist/web/assets/{postgres-viewer-m6qNfnAF.js → postgres-viewer-DBOv2ha2.js} +1 -1
- package/dist/web/assets/settings-tab-BZqkWI4u.js +1 -0
- package/dist/web/assets/{sqlite-viewer-6d233-2k.js → sqlite-viewer-BY242odW.js} +1 -1
- package/dist/web/assets/switch-BEmt1alu.js +1 -0
- package/dist/web/assets/{terminal-tab-BaHGzGJ6.js → terminal-tab-BiUqECPk.js} +1 -1
- package/dist/web/index.html +4 -4
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +108 -64
- package/src/providers/mock-provider.ts +1 -0
- package/src/providers/provider.interface.ts +1 -0
- package/src/server/routes/git.ts +16 -2
- package/src/server/ws/chat.ts +43 -26
- package/src/services/chat.service.ts +3 -1
- package/src/services/git.service.ts +45 -8
- package/src/types/api.ts +1 -1
- package/src/types/chat.ts +5 -0
- package/src/types/config.ts +21 -0
- package/src/types/git.ts +4 -0
- package/src/web/components/chat/chat-tab.tsx +26 -8
- package/src/web/components/chat/message-input.tsx +61 -1
- package/src/web/components/chat/message-list.tsx +9 -1
- package/src/web/components/chat/mode-selector.tsx +117 -0
- package/src/web/components/git/git-graph-branch-label.tsx +124 -0
- package/src/web/components/git/git-graph-constants.ts +185 -0
- package/src/web/components/git/git-graph-detail.tsx +107 -0
- package/src/web/components/git/git-graph-dialog.tsx +72 -0
- package/src/web/components/git/git-graph-row.tsx +167 -0
- package/src/web/components/git/git-graph-settings-dialog.tsx +104 -0
- package/src/web/components/git/git-graph-svg.tsx +54 -0
- package/src/web/components/git/git-graph-toolbar.tsx +195 -0
- package/src/web/components/git/git-graph.tsx +143 -681
- package/src/web/components/git/use-column-resize.ts +33 -0
- package/src/web/components/git/use-git-graph.ts +201 -0
- package/src/web/components/settings/ai-settings-section.tsx +42 -0
- package/src/web/hooks/use-chat.ts +3 -3
- package/src/web/lib/api-settings.ts +2 -0
- package/dist/web/assets/chat-tab-BoeC0a0w.js +0 -7
- package/dist/web/assets/git-graph-9GFTfA5p.js +0 -1
- package/dist/web/assets/index-CSS8Cy7l.css +0 -2
- package/dist/web/assets/index-CetGEOKq.js +0 -28
- package/dist/web/assets/input-CVIzrYsH.js +0 -41
- package/dist/web/assets/keybindings-store-DiEM7YZ4.js +0 -1
- package/dist/web/assets/settings-tab-Di-E48kC.js +0 -1
- package/dist/web/assets/switch-UODDpwuO.js +0 -1
package/src/server/ws/chat.ts
CHANGED
|
@@ -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
|
-
|
|
373
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
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 —
|
|
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.
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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;
|
package/src/types/config.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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(() =>
|
|
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">
|