@hienlh/ppm 0.13.52 → 0.13.54
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 +13 -0
- package/assets/skills/ppm/SKILL.md +2 -2
- package/assets/skills/ppm/references/cli-reference.md +11 -0
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{audio-preview-Cdm0BW8B.js → audio-preview-BF1LU0eY.js} +1 -1
- package/dist/web/assets/chat-tab-CCOkAmh8.js +16 -0
- package/dist/web/assets/{code-editor-C9om9NSF.js → code-editor-BptkAFVa.js} +2 -2
- package/dist/web/assets/{conflict-editor-Cz4sgsvJ.js → conflict-editor-DcVj0Z-q.js} +1 -1
- package/dist/web/assets/{database-viewer-5zpxuJ-9.js → database-viewer-CYrsNjRy.js} +1 -1
- package/dist/web/assets/{diff-viewer-B3KXcUEh.js → diff-viewer-DMBviO6l.js} +1 -1
- package/dist/web/assets/{extension-webview-BfI-xzuC.js → extension-webview-DCmfZH6p.js} +1 -1
- package/dist/web/assets/{glide-data-grid-BIC8i9ar.js → glide-data-grid-DhZjCUqu.js} +1 -1
- package/dist/web/assets/{image-preview-DgdoDSwj.js → image-preview-BIJGvZ5-.js} +1 -1
- package/dist/web/assets/{index-1aHDj9-w.js → index-BA8zQtSN.js} +3 -3
- package/dist/web/assets/keybindings-store-BXumit4n.js +1 -0
- package/dist/web/assets/{markdown-renderer-DATTNCN3.js → markdown-renderer-CwKRCQuc.js} +1 -1
- package/dist/web/assets/notification-store-B3Fgo6Qw.js +1 -0
- package/dist/web/assets/{pdf-preview-BF3SBJZ1.js → pdf-preview-CbUTv4dX.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-BhFtQlbF.js → port-forwarding-tab-Nn3-C-Vu.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CJlHg8oY.js → postgres-viewer-C-A4MMtt.js} +1 -1
- package/dist/web/assets/{settings-tab-CDyVv4ty.js → settings-tab-Bzlcvim9.js} +1 -1
- package/dist/web/assets/{sql-query-editor-DUuxfs0K.js → sql-query-editor-Cu9mYyfb.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-CIHi5lFl.js → sqlite-viewer-D6ngJJgP.js} +1 -1
- package/dist/web/assets/{terminal-tab-DSpc2tiF.js → terminal-tab-CyuBxW2x.js} +2 -2
- package/dist/web/assets/{video-preview-BSRN_qBs.js → video-preview-ChP5ypMo.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/sw.js +1 -1
- package/docs/project-changelog.md +10 -1
- package/docs/system-architecture.md +4 -0
- package/package.json +1 -1
- package/src/cli/commands/db-cmd.ts +30 -4
- package/src/server/routes/chat.ts +49 -0
- package/src/services/db.service.ts +16 -1
- package/src/services/draft.service.ts +49 -0
- package/src/services/ppmbot/cli-reference-default.ts +6 -1
- package/src/web/components/chat/chat-tab.tsx +41 -25
- package/src/web/components/chat/message-input.tsx +6 -1
- package/src/web/components/editor/editor-breadcrumb.tsx +8 -1
- package/src/web/hooks/use-draft.ts +93 -0
- package/src/web/hooks/use-terminal.ts +6 -3
- package/templates/skill/SKILL.md.tmpl +1 -1
- package/dist/web/assets/chat-tab-CGNMb3WW.js +0 -16
- package/dist/web/assets/keybindings-store-BP58sNL0.js +0 -1
- package/dist/web/assets/notification-store-BZScO6BK.js +0 -1
|
@@ -67,14 +67,26 @@ export function registerDbCommands(program: Command): void {
|
|
|
67
67
|
// ── ppm db list ──────────────────────────────────────────────────────
|
|
68
68
|
db.command("list")
|
|
69
69
|
.description("List all saved database connections")
|
|
70
|
-
.
|
|
70
|
+
.option("--json", "Output as JSON")
|
|
71
|
+
.action(async (options: { json?: boolean }) => {
|
|
71
72
|
try {
|
|
72
73
|
const { getConnections } = await import("../../services/db.service.ts");
|
|
73
74
|
const conns = getConnections();
|
|
74
75
|
if (conns.length === 0) {
|
|
76
|
+
if (options.json) { console.log("[]"); return; }
|
|
75
77
|
console.log(`${C.yellow}No connections saved.${C.reset} Run: ppm db add`);
|
|
76
78
|
return;
|
|
77
79
|
}
|
|
80
|
+
if (options.json) {
|
|
81
|
+
const data = conns.map((c) => {
|
|
82
|
+
const cfg = parseConfig(c);
|
|
83
|
+
let target = cfg.connectionString ?? cfg.path ?? null;
|
|
84
|
+
if (cfg.connectionString) target = maskPassword(target!);
|
|
85
|
+
return { id: c.id, name: c.name, type: c.type, group: c.group_name ?? null, readonly: !!c.readonly, connection: target };
|
|
86
|
+
});
|
|
87
|
+
console.log(JSON.stringify(data, null, 2));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
78
90
|
const rows = conns.map((c) => {
|
|
79
91
|
const cfg = parseConfig(c);
|
|
80
92
|
let target = cfg.connectionString ?? cfg.path ?? "-";
|
|
@@ -197,7 +209,8 @@ export function registerDbCommands(program: Command): void {
|
|
|
197
209
|
// ── ppm db tables ────────────────────────────────────────────────────
|
|
198
210
|
db.command("tables <name>")
|
|
199
211
|
.description("List tables in a database connection")
|
|
200
|
-
.
|
|
212
|
+
.option("--json", "Output as JSON")
|
|
213
|
+
.action(async (nameOrId: string, options: { json?: boolean }) => {
|
|
201
214
|
try {
|
|
202
215
|
const { resolveConnection } = await import("../../services/db.service.ts");
|
|
203
216
|
const conn = resolveConnection(nameOrId);
|
|
@@ -212,9 +225,11 @@ export function registerDbCommands(program: Command): void {
|
|
|
212
225
|
const tables = await postgresService.getTables(cfg.connectionString!);
|
|
213
226
|
await postgresService.closeAll();
|
|
214
227
|
if (tables.length === 0) {
|
|
228
|
+
if (options.json) { console.log("[]"); return; }
|
|
215
229
|
console.log(`${C.dim}No tables found.${C.reset}`);
|
|
216
230
|
return;
|
|
217
231
|
}
|
|
232
|
+
if (options.json) { console.log(JSON.stringify(tables, null, 2)); return; }
|
|
218
233
|
printTable(
|
|
219
234
|
["Schema", "Table", "Rows (est.)"],
|
|
220
235
|
tables.map((t) => [t.schema, t.name, String(t.rowCount)]),
|
|
@@ -224,9 +239,11 @@ export function registerDbCommands(program: Command): void {
|
|
|
224
239
|
const tables = sqliteService.getTables(cfg.path!, cfg.path!);
|
|
225
240
|
sqliteService.closeAll();
|
|
226
241
|
if (tables.length === 0) {
|
|
242
|
+
if (options.json) { console.log("[]"); return; }
|
|
227
243
|
console.log(`${C.dim}No tables found.${C.reset}`);
|
|
228
244
|
return;
|
|
229
245
|
}
|
|
246
|
+
if (options.json) { console.log(JSON.stringify(tables, null, 2)); return; }
|
|
230
247
|
printTable(
|
|
231
248
|
["Table", "Rows"],
|
|
232
249
|
tables.map((t) => [t.name, String(t.rowCount)]),
|
|
@@ -242,7 +259,8 @@ export function registerDbCommands(program: Command): void {
|
|
|
242
259
|
db.command("schema <name> <table>")
|
|
243
260
|
.description("Show table schema (columns, types, constraints)")
|
|
244
261
|
.option("-s, --schema <schema>", "PostgreSQL schema name", "public")
|
|
245
|
-
.
|
|
262
|
+
.option("--json", "Output as JSON")
|
|
263
|
+
.action(async (nameOrId: string, table: string, options: { schema: string; json?: boolean }) => {
|
|
246
264
|
try {
|
|
247
265
|
const { resolveConnection } = await import("../../services/db.service.ts");
|
|
248
266
|
const conn = resolveConnection(nameOrId);
|
|
@@ -256,6 +274,7 @@ export function registerDbCommands(program: Command): void {
|
|
|
256
274
|
const { postgresService } = await import("../../services/postgres.service.ts");
|
|
257
275
|
const cols = await postgresService.getTableSchema(cfg.connectionString!, table, options.schema);
|
|
258
276
|
await postgresService.closeAll();
|
|
277
|
+
if (options.json) { console.log(JSON.stringify(cols, null, 2)); return; }
|
|
259
278
|
printTable(
|
|
260
279
|
["Column", "Type", "Nullable", "PK", "Default"],
|
|
261
280
|
cols.map((c) => [c.name, c.type, c.nullable ? "YES" : "NO", c.pk ? "PK" : "", c.defaultValue ?? ""]),
|
|
@@ -264,6 +283,7 @@ export function registerDbCommands(program: Command): void {
|
|
|
264
283
|
const { sqliteService } = await import("../../services/sqlite.service.ts");
|
|
265
284
|
const cols = sqliteService.getTableSchema(cfg.path!, cfg.path!, table);
|
|
266
285
|
sqliteService.closeAll();
|
|
286
|
+
if (options.json) { console.log(JSON.stringify(cols, null, 2)); return; }
|
|
267
287
|
printTable(
|
|
268
288
|
["Column", "Type", "Not Null", "PK", "Default"],
|
|
269
289
|
cols.map((c) => [c.name, c.type, c.notnull ? "YES" : "NO", c.pk ? "PK" : "", c.dflt_value ?? ""]),
|
|
@@ -283,6 +303,7 @@ export function registerDbCommands(program: Command): void {
|
|
|
283
303
|
.option("--order <column>", "Order by column")
|
|
284
304
|
.option("--desc", "Descending order")
|
|
285
305
|
.option("-s, --schema <schema>", "PostgreSQL schema name", "public")
|
|
306
|
+
.option("--json", "Output as JSON")
|
|
286
307
|
.action(async (nameOrId: string, table: string, options) => {
|
|
287
308
|
try {
|
|
288
309
|
const { resolveConnection } = await import("../../services/db.service.ts");
|
|
@@ -302,6 +323,7 @@ export function registerDbCommands(program: Command): void {
|
|
|
302
323
|
cfg.connectionString!, table, options.schema, page, limit, options.order, orderDir,
|
|
303
324
|
);
|
|
304
325
|
await postgresService.closeAll();
|
|
326
|
+
if (options.json) { console.log(JSON.stringify(result, null, 2)); return; }
|
|
305
327
|
console.log(`${C.cyan}${table}${C.reset} — page ${result.page}, ${result.total} total rows\n`);
|
|
306
328
|
formatRows(result.columns, result.rows, limit);
|
|
307
329
|
} else {
|
|
@@ -310,6 +332,7 @@ export function registerDbCommands(program: Command): void {
|
|
|
310
332
|
cfg.path!, cfg.path!, table, page, limit, options.order, orderDir,
|
|
311
333
|
);
|
|
312
334
|
sqliteService.closeAll();
|
|
335
|
+
if (options.json) { console.log(JSON.stringify(result, null, 2)); return; }
|
|
313
336
|
console.log(`${C.cyan}${table}${C.reset} — page ${result.page}, ${result.total} total rows\n`);
|
|
314
337
|
formatRows(result.columns, result.rows, limit);
|
|
315
338
|
}
|
|
@@ -322,7 +345,8 @@ export function registerDbCommands(program: Command): void {
|
|
|
322
345
|
// ── ppm db query ─────────────────────────────────────────────────────
|
|
323
346
|
db.command("query <name> <sql>")
|
|
324
347
|
.description("Execute a SQL query against a saved connection")
|
|
325
|
-
.
|
|
348
|
+
.option("--json", "Output as JSON")
|
|
349
|
+
.action(async (nameOrId: string, sql: string, options: { json?: boolean }) => {
|
|
326
350
|
try {
|
|
327
351
|
const { resolveConnection } = await import("../../services/db.service.ts");
|
|
328
352
|
const conn = resolveConnection(nameOrId);
|
|
@@ -343,6 +367,7 @@ export function registerDbCommands(program: Command): void {
|
|
|
343
367
|
const { postgresService } = await import("../../services/postgres.service.ts");
|
|
344
368
|
const result = await postgresService.executeQuery(cfg.connectionString!, sql);
|
|
345
369
|
await postgresService.closeAll();
|
|
370
|
+
if (options.json) { console.log(JSON.stringify(result, null, 2)); return; }
|
|
346
371
|
if (result.changeType === "select") {
|
|
347
372
|
formatRows(result.columns, result.rows);
|
|
348
373
|
} else {
|
|
@@ -352,6 +377,7 @@ export function registerDbCommands(program: Command): void {
|
|
|
352
377
|
const { sqliteService } = await import("../../services/sqlite.service.ts");
|
|
353
378
|
const result = sqliteService.executeQuery(cfg.path!, cfg.path!, sql);
|
|
354
379
|
sqliteService.closeAll();
|
|
380
|
+
if (options.json) { console.log(JSON.stringify(result, null, 2)); return; }
|
|
355
381
|
if (result.changeType === "select") {
|
|
356
382
|
formatRows(result.columns, result.rows);
|
|
357
383
|
} else {
|
|
@@ -3,6 +3,7 @@ import { resolve, join, basename } from "node:path";
|
|
|
3
3
|
import { mkdirSync, existsSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { chatService } from "../../services/chat.service.ts";
|
|
6
|
+
import { draftService } from "../../services/draft.service.ts";
|
|
6
7
|
import { providerRegistry } from "../../providers/registry.ts";
|
|
7
8
|
import { renameSession as sdkRenameSession } from "@anthropic-ai/claude-agent-sdk";
|
|
8
9
|
import { listSlashItems, searchSlashItems, invalidateCache } from "../../services/slash-items.service.ts";
|
|
@@ -221,9 +222,12 @@ chatRoutes.delete("/sessions", async (c) => {
|
|
|
221
222
|
deleteSessionMetadata(s.id);
|
|
222
223
|
deleteSessionTitle(s.id);
|
|
223
224
|
unpinSession(s.id);
|
|
225
|
+
try { draftService.delete(projectPath, s.id); } catch { /* ignore */ }
|
|
224
226
|
deleted++;
|
|
225
227
|
} catch { /* skip individual failures */ }
|
|
226
228
|
}
|
|
229
|
+
// Clean up any orphaned drafts left behind
|
|
230
|
+
try { draftService.deleteOrphaned(); } catch { /* ignore */ }
|
|
227
231
|
|
|
228
232
|
return c.json(ok({ deleted, total: toDelete.length }));
|
|
229
233
|
} catch (e) {
|
|
@@ -244,6 +248,8 @@ chatRoutes.delete("/sessions/:id", async (c) => {
|
|
|
244
248
|
deleteSessionMetadata(id);
|
|
245
249
|
deleteSessionTitle(id);
|
|
246
250
|
unpinSession(id);
|
|
251
|
+
// Fire-and-forget draft cleanup
|
|
252
|
+
try { draftService.delete(c.get("projectPath"), id); } catch { /* ignore */ }
|
|
247
253
|
return c.json(ok({ deleted: id }));
|
|
248
254
|
} catch (e) {
|
|
249
255
|
return c.json(err((e as Error).message), 404);
|
|
@@ -516,3 +522,46 @@ chatRoutes.get("/uploads/:filename", async (c) => {
|
|
|
516
522
|
return c.json(err((e as Error).message), 500);
|
|
517
523
|
}
|
|
518
524
|
});
|
|
525
|
+
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
// Draft endpoints — auto-save / restore chat input per session
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
/** GET /chat/drafts/:sessionId — load draft for a session (or null) */
|
|
531
|
+
chatRoutes.get("/drafts/:sessionId", (c) => {
|
|
532
|
+
try {
|
|
533
|
+
const projectPath = c.get("projectPath");
|
|
534
|
+
const sessionId = c.req.param("sessionId");
|
|
535
|
+
const draft = draftService.get(projectPath, sessionId);
|
|
536
|
+
return c.json(ok(draft));
|
|
537
|
+
} catch (e) {
|
|
538
|
+
return c.json(err((e as Error).message), 500);
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
/** PUT /chat/drafts/:sessionId — upsert draft content + attachments */
|
|
543
|
+
chatRoutes.put("/drafts/:sessionId", async (c) => {
|
|
544
|
+
try {
|
|
545
|
+
const projectPath = c.get("projectPath");
|
|
546
|
+
const sessionId = c.req.param("sessionId");
|
|
547
|
+
const body = await c.req.json<{ content?: string; attachments?: string }>();
|
|
548
|
+
const content = typeof body.content === "string" ? body.content : "";
|
|
549
|
+
const attachments = typeof body.attachments === "string" ? body.attachments : undefined;
|
|
550
|
+
draftService.upsert(projectPath, sessionId, content, attachments);
|
|
551
|
+
return c.json(ok({ saved: true }));
|
|
552
|
+
} catch (e) {
|
|
553
|
+
return c.json(err((e as Error).message), 500);
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
/** DELETE /chat/drafts/:sessionId — remove draft */
|
|
558
|
+
chatRoutes.delete("/drafts/:sessionId", (c) => {
|
|
559
|
+
try {
|
|
560
|
+
const projectPath = c.get("projectPath");
|
|
561
|
+
const sessionId = c.req.param("sessionId");
|
|
562
|
+
draftService.delete(projectPath, sessionId);
|
|
563
|
+
return c.json(ok({ deleted: true }));
|
|
564
|
+
} catch (e) {
|
|
565
|
+
return c.json(err((e as Error).message), 500);
|
|
566
|
+
}
|
|
567
|
+
});
|
|
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { mkdirSync, existsSync } from "node:fs";
|
|
4
4
|
import { encrypt, decrypt } from "../lib/account-crypto.ts";
|
|
5
5
|
import { getPpmDir } from "./ppm-dir.ts";
|
|
6
|
-
const CURRENT_SCHEMA_VERSION =
|
|
6
|
+
const CURRENT_SCHEMA_VERSION = 26;
|
|
7
7
|
|
|
8
8
|
let db: Database | null = null;
|
|
9
9
|
let dbProfile: string | null = null;
|
|
@@ -638,6 +638,21 @@ function runMigrations(database: Database): void {
|
|
|
638
638
|
PRAGMA user_version = 25;
|
|
639
639
|
`);
|
|
640
640
|
}
|
|
641
|
+
|
|
642
|
+
if (current < 26) {
|
|
643
|
+
database.exec(`
|
|
644
|
+
CREATE TABLE IF NOT EXISTS chat_drafts (
|
|
645
|
+
project_path TEXT NOT NULL,
|
|
646
|
+
session_id TEXT NOT NULL DEFAULT '__new__',
|
|
647
|
+
content TEXT NOT NULL DEFAULT '',
|
|
648
|
+
attachments TEXT DEFAULT '[]',
|
|
649
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
650
|
+
PRIMARY KEY (project_path, session_id)
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
PRAGMA user_version = 26;
|
|
654
|
+
`);
|
|
655
|
+
}
|
|
641
656
|
}
|
|
642
657
|
|
|
643
658
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { getDb } from "./db.service.ts";
|
|
2
|
+
|
|
3
|
+
const MAX_CONTENT_LENGTH = 50 * 1024; // ~50K characters cap
|
|
4
|
+
|
|
5
|
+
export interface DraftData {
|
|
6
|
+
content: string;
|
|
7
|
+
attachments: string; // JSON string
|
|
8
|
+
updatedAt: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class DraftService {
|
|
12
|
+
get(projectPath: string, sessionId: string): DraftData | null {
|
|
13
|
+
const row = getDb()
|
|
14
|
+
.query("SELECT content, attachments, updated_at FROM chat_drafts WHERE project_path = ? AND session_id = ?")
|
|
15
|
+
.get(projectPath, sessionId) as { content: string; attachments: string; updated_at: string } | null;
|
|
16
|
+
if (!row) return null;
|
|
17
|
+
return { content: row.content, attachments: row.attachments, updatedAt: row.updated_at };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
upsert(projectPath: string, sessionId: string, content: string, attachments?: string): void {
|
|
21
|
+
// Silent truncation at 50KB
|
|
22
|
+
const safeContent = content.length > MAX_CONTENT_LENGTH ? content.slice(0, MAX_CONTENT_LENGTH) : content;
|
|
23
|
+
getDb()
|
|
24
|
+
.query(
|
|
25
|
+
"INSERT INTO chat_drafts (project_path, session_id, content, attachments, updated_at) VALUES (?, ?, ?, ?, datetime('now')) ON CONFLICT(project_path, session_id) DO UPDATE SET content = excluded.content, attachments = excluded.attachments, updated_at = excluded.updated_at",
|
|
26
|
+
)
|
|
27
|
+
.run(projectPath, sessionId, safeContent, attachments ?? "[]");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
delete(projectPath: string, sessionId: string): void {
|
|
31
|
+
getDb()
|
|
32
|
+
.query("DELETE FROM chat_drafts WHERE project_path = ? AND session_id = ?")
|
|
33
|
+
.run(projectPath, sessionId);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Delete orphaned drafts whose session_id is not in session_metadata */
|
|
37
|
+
deleteOrphaned(): number {
|
|
38
|
+
const result = getDb()
|
|
39
|
+
.query(
|
|
40
|
+
`DELETE FROM chat_drafts
|
|
41
|
+
WHERE session_id != '__new__'
|
|
42
|
+
AND session_id NOT IN (SELECT session_id FROM session_metadata)`,
|
|
43
|
+
)
|
|
44
|
+
.run();
|
|
45
|
+
return (result as { changes: number }).changes;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const draftService = new DraftService();
|
|
@@ -161,6 +161,7 @@ ppm chat delete <session-id>
|
|
|
161
161
|
\`\`\`
|
|
162
162
|
ppm db list
|
|
163
163
|
List all saved database connections
|
|
164
|
+
--json — Output as JSON
|
|
164
165
|
|
|
165
166
|
ppm db add
|
|
166
167
|
Add a new database connection
|
|
@@ -179,10 +180,12 @@ ppm db test <name>
|
|
|
179
180
|
|
|
180
181
|
ppm db tables <name>
|
|
181
182
|
List tables in a database connection
|
|
183
|
+
--json — Output as JSON
|
|
182
184
|
|
|
183
185
|
ppm db schema <name> <table>
|
|
184
186
|
Show table schema (columns, types, constraints)
|
|
185
187
|
-s, --schema <schema> — PostgreSQL schema name [default: public]
|
|
188
|
+
--json — Output as JSON
|
|
186
189
|
|
|
187
190
|
ppm db data <name> <table>
|
|
188
191
|
View table data (paginated)
|
|
@@ -191,9 +194,11 @@ ppm db data <name> <table>
|
|
|
191
194
|
--order <column> — Order by column
|
|
192
195
|
--desc — Descending order
|
|
193
196
|
-s, --schema <schema> — PostgreSQL schema name [default: public]
|
|
197
|
+
--json — Output as JSON
|
|
194
198
|
|
|
195
199
|
ppm db query <name> <sql>
|
|
196
200
|
Execute a SQL query against a saved connection
|
|
201
|
+
--json — Output as JSON
|
|
197
202
|
\`\`\`
|
|
198
203
|
## ppm autostart — Auto-start on boot
|
|
199
204
|
\`\`\`
|
|
@@ -322,6 +327,6 @@ ppm bot memory forget "<topic>"
|
|
|
322
327
|
\`\`\`
|
|
323
328
|
|
|
324
329
|
## Tips
|
|
325
|
-
- Use \`--json\` flag when parsing command output programmatically
|
|
330
|
+
- Use \`--json\` flag when parsing command output programmatically (available on most list/status commands — check \`--help\`)
|
|
326
331
|
- For git/chat/db operations: always specify \`--project <name>\` or connection name
|
|
327
332
|
`;
|
|
@@ -14,6 +14,7 @@ import { MessageInput, type ChatAttachment, type MessagePriority } from "./messa
|
|
|
14
14
|
import { SlashCommandPicker, type SlashItem } from "./slash-command-picker";
|
|
15
15
|
import { FilePicker } from "./file-picker";
|
|
16
16
|
import { ChatHistoryBar } from "./chat-history-bar";
|
|
17
|
+
import { useDraft, type DraftAttachment } from "@/hooks/use-draft";
|
|
17
18
|
|
|
18
19
|
import type { DragEvent } from "react";
|
|
19
20
|
import type { FileNode } from "../../../types/project";
|
|
@@ -69,6 +70,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
69
70
|
const { usageInfo, usageLoading, lastFetchedAt, refreshUsage } =
|
|
70
71
|
useUsage(projectName, providerId);
|
|
71
72
|
|
|
73
|
+
// Draft auto-save/restore
|
|
74
|
+
const { draft, draftLoading, saveDraft, clearDraft } = useDraft(projectName, sessionId);
|
|
75
|
+
|
|
72
76
|
// Load global default permission mode on mount (if no per-session override)
|
|
73
77
|
useEffect(() => {
|
|
74
78
|
if (permissionMode) return;
|
|
@@ -258,13 +262,22 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
258
262
|
[sessionId, providerId, projectName, sendMessage, buildMessageWithAttachments, permissionMode],
|
|
259
263
|
);
|
|
260
264
|
|
|
261
|
-
/** Stable wrapper for MessageInput onSend — clears forkDraft and delegates to handleSend */
|
|
265
|
+
/** Stable wrapper for MessageInput onSend — clears forkDraft + draft and delegates to handleSend */
|
|
262
266
|
const handleInputSend = useCallback(
|
|
263
267
|
(content: string, attachments: ChatAttachment[], priority?: MessagePriority) => {
|
|
264
268
|
setForkDraft(undefined);
|
|
269
|
+
clearDraft();
|
|
265
270
|
handleSend(content, attachments, priority);
|
|
266
271
|
},
|
|
267
|
-
[handleSend],
|
|
272
|
+
[handleSend, clearDraft],
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
/** Draft auto-save callback — called by MessageInput on content change */
|
|
276
|
+
const handleContentChange = useCallback(
|
|
277
|
+
(content: string, attachments?: DraftAttachment[]) => {
|
|
278
|
+
saveDraft(content, attachments);
|
|
279
|
+
},
|
|
280
|
+
[saveDraft],
|
|
268
281
|
);
|
|
269
282
|
|
|
270
283
|
/** Stable callback for slash items loaded — prevents MessageInput memo break */
|
|
@@ -469,29 +482,32 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
469
482
|
/>
|
|
470
483
|
)}
|
|
471
484
|
|
|
472
|
-
{/* Input */}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
485
|
+
{/* Input — gate on draftLoading to avoid empty→filled flash */}
|
|
486
|
+
{!draftLoading && (
|
|
487
|
+
<MessageInput
|
|
488
|
+
onSend={handleInputSend}
|
|
489
|
+
isStreaming={isStreaming}
|
|
490
|
+
onCancel={cancelStreaming}
|
|
491
|
+
autoFocus={!(metadata?.sessionId) || !!forkDraft}
|
|
492
|
+
initialValue={forkDraft ?? draft?.content}
|
|
493
|
+
projectName={projectName}
|
|
494
|
+
onSlashStateChange={handleSlashStateChange}
|
|
495
|
+
onSlashItemsLoaded={handleSlashItemsLoaded}
|
|
496
|
+
slashSelected={slashSelected}
|
|
497
|
+
onFileStateChange={handleFileStateChange}
|
|
498
|
+
onFileItemsLoaded={setFileItems}
|
|
499
|
+
fileSelected={fileSelected}
|
|
500
|
+
externalFiles={externalFiles}
|
|
501
|
+
externalPaths={externalPaths}
|
|
502
|
+
onExternalPathsConsumed={handleExternalPathsConsumed}
|
|
503
|
+
onDisambiguate={handleDisambiguate}
|
|
504
|
+
onContentChange={handleContentChange}
|
|
505
|
+
permissionMode={permissionMode}
|
|
506
|
+
onModeChange={setPermissionMode}
|
|
507
|
+
providerId={providerId}
|
|
508
|
+
onProviderChange={!sessionId ? setProviderId : undefined}
|
|
509
|
+
/>
|
|
510
|
+
)}
|
|
495
511
|
</div>
|
|
496
512
|
|
|
497
513
|
{/* Bug report popup is now global — see BugReportPopup in app.tsx */}
|
|
@@ -50,6 +50,8 @@ interface MessageInputProps {
|
|
|
50
50
|
onDisambiguate?: (matches: FileNode[]) => void;
|
|
51
51
|
/** Pre-fill input value (e.g. from command palette "Ask AI") */
|
|
52
52
|
initialValue?: string;
|
|
53
|
+
/** Called on content change for draft auto-save */
|
|
54
|
+
onContentChange?: (content: string, attachments?: Array<{ name: string; path: string }>) => void;
|
|
53
55
|
/** Auto-focus textarea on mount */
|
|
54
56
|
autoFocus?: boolean;
|
|
55
57
|
/** Current permission mode */
|
|
@@ -79,6 +81,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
79
81
|
onExternalPathsConsumed,
|
|
80
82
|
onDisambiguate,
|
|
81
83
|
initialValue,
|
|
84
|
+
onContentChange,
|
|
82
85
|
autoFocus,
|
|
83
86
|
permissionMode,
|
|
84
87
|
onModeChange,
|
|
@@ -561,6 +564,8 @@ export const MessageInput = memo(function MessageInput({
|
|
|
561
564
|
setHasText(text.trim().length > 0);
|
|
562
565
|
// Update picker state (slash/file autocomplete)
|
|
563
566
|
updatePickerState(text, el.selectionStart);
|
|
567
|
+
// Notify parent for draft auto-save (debounced in hook)
|
|
568
|
+
onContentChange?.(text, attachments.filter((a) => a.status === "ready" && a.serverPath).map((a) => ({ name: a.name, path: a.serverPath! })));
|
|
564
569
|
// JS auto-resize fallback — only when CSS field-sizing: content is unsupported
|
|
565
570
|
if (needsJsResize.current) {
|
|
566
571
|
if (resizeRafRef.current) cancelAnimationFrame(resizeRafRef.current);
|
|
@@ -571,7 +576,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
571
576
|
});
|
|
572
577
|
}
|
|
573
578
|
},
|
|
574
|
-
[updatePickerState],
|
|
579
|
+
[updatePickerState, onContentChange, attachments],
|
|
575
580
|
);
|
|
576
581
|
|
|
577
582
|
/** Handle paste — intercept images from clipboard */
|
|
@@ -165,7 +165,14 @@ function SegmentDropdown({ segment, isLast, projectName, onFileClick }: SegmentD
|
|
|
165
165
|
|
|
166
166
|
function handleOpenChange(open: boolean) {
|
|
167
167
|
if (open && !isLoaded) {
|
|
168
|
-
|
|
168
|
+
// Load ancestor directories top-down so mergeChildren can traverse
|
|
169
|
+
// the tree to the target (needed when file opened via search/quick-open)
|
|
170
|
+
const parts = segment.parentPath.split("/").filter(Boolean);
|
|
171
|
+
(async () => {
|
|
172
|
+
for (let i = 0; i <= parts.length; i++) {
|
|
173
|
+
await loadChildren(projectName, parts.slice(0, i).join("/"));
|
|
174
|
+
}
|
|
175
|
+
})();
|
|
169
176
|
}
|
|
170
177
|
}
|
|
171
178
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
2
|
+
import { api, projectUrl } from "@/lib/api-client";
|
|
3
|
+
|
|
4
|
+
export interface DraftAttachment {
|
|
5
|
+
name: string;
|
|
6
|
+
path: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface DraftState {
|
|
10
|
+
content: string;
|
|
11
|
+
attachments: DraftAttachment[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface DraftResult {
|
|
15
|
+
content: string;
|
|
16
|
+
attachments: string; // JSON string
|
|
17
|
+
updatedAt: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useDraft(projectName: string, sessionId: string | null) {
|
|
21
|
+
const [draft, setDraft] = useState<DraftState | null>(null);
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
23
|
+
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
24
|
+
const sessionRef = useRef(sessionId);
|
|
25
|
+
sessionRef.current = sessionId;
|
|
26
|
+
|
|
27
|
+
const effectiveId = sessionId ?? "__new__";
|
|
28
|
+
|
|
29
|
+
// Load draft on mount / session change
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!projectName) {
|
|
32
|
+
setLoading(false);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
let cancelled = false;
|
|
36
|
+
setLoading(true);
|
|
37
|
+
api
|
|
38
|
+
.get<DraftResult | null>(
|
|
39
|
+
`${projectUrl(projectName)}/chat/drafts/${encodeURIComponent(effectiveId)}`,
|
|
40
|
+
)
|
|
41
|
+
.then((data) => {
|
|
42
|
+
if (cancelled) return;
|
|
43
|
+
if (data) {
|
|
44
|
+
let attachments: DraftAttachment[] = [];
|
|
45
|
+
try { attachments = JSON.parse(data.attachments); } catch { /* ignore */ }
|
|
46
|
+
setDraft({ content: data.content, attachments });
|
|
47
|
+
} else {
|
|
48
|
+
setDraft(null);
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
.catch(() => { if (!cancelled) setDraft(null); })
|
|
52
|
+
.finally(() => { if (!cancelled) setLoading(false); });
|
|
53
|
+
return () => { cancelled = true; };
|
|
54
|
+
}, [projectName, effectiveId]);
|
|
55
|
+
|
|
56
|
+
// Debounced save (1s)
|
|
57
|
+
const save = useCallback(
|
|
58
|
+
(content: string, attachments?: DraftAttachment[]) => {
|
|
59
|
+
if (!projectName) return;
|
|
60
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
61
|
+
timerRef.current = setTimeout(() => {
|
|
62
|
+
const id = sessionRef.current ?? "__new__";
|
|
63
|
+
api
|
|
64
|
+
.put(
|
|
65
|
+
`${projectUrl(projectName)}/chat/drafts/${encodeURIComponent(id)}`,
|
|
66
|
+
{ content, attachments: JSON.stringify(attachments ?? []) },
|
|
67
|
+
)
|
|
68
|
+
.catch(() => {});
|
|
69
|
+
}, 1000);
|
|
70
|
+
},
|
|
71
|
+
[projectName],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Clear draft (on send)
|
|
75
|
+
const clear = useCallback(() => {
|
|
76
|
+
if (!projectName) return;
|
|
77
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
78
|
+
const id = sessionRef.current ?? "__new__";
|
|
79
|
+
api
|
|
80
|
+
.del(`${projectUrl(projectName)}/chat/drafts/${encodeURIComponent(id)}`)
|
|
81
|
+
.catch(() => {});
|
|
82
|
+
setDraft(null);
|
|
83
|
+
}, [projectName]);
|
|
84
|
+
|
|
85
|
+
// Cleanup timer on unmount
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
return () => {
|
|
88
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
89
|
+
};
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
return { draft, draftLoading: loading, saveDraft: save, clearDraft: clear };
|
|
93
|
+
}
|
|
@@ -195,10 +195,13 @@ export function useTerminal(
|
|
|
195
195
|
if (event.data.startsWith("{")) {
|
|
196
196
|
try {
|
|
197
197
|
const msg = JSON.parse(event.data);
|
|
198
|
-
|
|
198
|
+
// Any valid JSON with a "type" field is a control/system message —
|
|
199
|
+
// real PTY output is raw text/escape sequences, never typed JSON.
|
|
200
|
+
// Handle known terminal control types, silently drop everything else
|
|
201
|
+
// (e.g. chat events that may leak via WS under race conditions).
|
|
202
|
+
if (msg.type) {
|
|
199
203
|
if (msg.type === "session" && msg.id) {
|
|
200
204
|
actualSessionId.current = msg.id;
|
|
201
|
-
// Persist to localStorage so reload reconnects to same PTY
|
|
202
205
|
if (storageKey) {
|
|
203
206
|
try { localStorage.setItem(storageKey, msg.id); } catch { /* */ }
|
|
204
207
|
}
|
|
@@ -209,7 +212,7 @@ export function useTerminal(
|
|
|
209
212
|
if (msg.type === "exited") {
|
|
210
213
|
setExited(true);
|
|
211
214
|
}
|
|
212
|
-
return; //
|
|
215
|
+
return; // Never write typed JSON to terminal
|
|
213
216
|
}
|
|
214
217
|
} catch {
|
|
215
218
|
// Not JSON, write as terminal output
|
|
@@ -46,7 +46,7 @@ Invoke when the user asks to:
|
|
|
46
46
|
|
|
47
47
|
- Always run `ppm status` before assuming the server is up.
|
|
48
48
|
- Commands exit non-zero on failure and print to stderr. Capture both streams.
|
|
49
|
-
-
|
|
49
|
+
- Most listing commands accept `--json` for structured output; prefer JSON when parsing. Check `--help` if unsure.
|
|
50
50
|
- The config DB is **SQLite**. You may open `~/.ppm/ppm.db` read-only for inspection — see [references/db-schema.md](references/db-schema.md).
|
|
51
51
|
- Do NOT edit the config DB directly while the server is running; use `ppm config set` or the HTTP API.
|
|
52
52
|
|