@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/assets/skills/ppm/SKILL.md +2 -2
  3. package/assets/skills/ppm/references/cli-reference.md +11 -0
  4. package/assets/skills/ppm/references/http-api.md +1 -1
  5. package/dist/web/assets/{audio-preview-Cdm0BW8B.js → audio-preview-BF1LU0eY.js} +1 -1
  6. package/dist/web/assets/chat-tab-CCOkAmh8.js +16 -0
  7. package/dist/web/assets/{code-editor-C9om9NSF.js → code-editor-BptkAFVa.js} +2 -2
  8. package/dist/web/assets/{conflict-editor-Cz4sgsvJ.js → conflict-editor-DcVj0Z-q.js} +1 -1
  9. package/dist/web/assets/{database-viewer-5zpxuJ-9.js → database-viewer-CYrsNjRy.js} +1 -1
  10. package/dist/web/assets/{diff-viewer-B3KXcUEh.js → diff-viewer-DMBviO6l.js} +1 -1
  11. package/dist/web/assets/{extension-webview-BfI-xzuC.js → extension-webview-DCmfZH6p.js} +1 -1
  12. package/dist/web/assets/{glide-data-grid-BIC8i9ar.js → glide-data-grid-DhZjCUqu.js} +1 -1
  13. package/dist/web/assets/{image-preview-DgdoDSwj.js → image-preview-BIJGvZ5-.js} +1 -1
  14. package/dist/web/assets/{index-1aHDj9-w.js → index-BA8zQtSN.js} +3 -3
  15. package/dist/web/assets/keybindings-store-BXumit4n.js +1 -0
  16. package/dist/web/assets/{markdown-renderer-DATTNCN3.js → markdown-renderer-CwKRCQuc.js} +1 -1
  17. package/dist/web/assets/notification-store-B3Fgo6Qw.js +1 -0
  18. package/dist/web/assets/{pdf-preview-BF3SBJZ1.js → pdf-preview-CbUTv4dX.js} +1 -1
  19. package/dist/web/assets/{port-forwarding-tab-BhFtQlbF.js → port-forwarding-tab-Nn3-C-Vu.js} +1 -1
  20. package/dist/web/assets/{postgres-viewer-CJlHg8oY.js → postgres-viewer-C-A4MMtt.js} +1 -1
  21. package/dist/web/assets/{settings-tab-CDyVv4ty.js → settings-tab-Bzlcvim9.js} +1 -1
  22. package/dist/web/assets/{sql-query-editor-DUuxfs0K.js → sql-query-editor-Cu9mYyfb.js} +1 -1
  23. package/dist/web/assets/{sqlite-viewer-CIHi5lFl.js → sqlite-viewer-D6ngJJgP.js} +1 -1
  24. package/dist/web/assets/{terminal-tab-DSpc2tiF.js → terminal-tab-CyuBxW2x.js} +2 -2
  25. package/dist/web/assets/{video-preview-BSRN_qBs.js → video-preview-ChP5ypMo.js} +1 -1
  26. package/dist/web/index.html +1 -1
  27. package/dist/web/sw.js +1 -1
  28. package/docs/project-changelog.md +10 -1
  29. package/docs/system-architecture.md +4 -0
  30. package/package.json +1 -1
  31. package/src/cli/commands/db-cmd.ts +30 -4
  32. package/src/server/routes/chat.ts +49 -0
  33. package/src/services/db.service.ts +16 -1
  34. package/src/services/draft.service.ts +49 -0
  35. package/src/services/ppmbot/cli-reference-default.ts +6 -1
  36. package/src/web/components/chat/chat-tab.tsx +41 -25
  37. package/src/web/components/chat/message-input.tsx +6 -1
  38. package/src/web/components/editor/editor-breadcrumb.tsx +8 -1
  39. package/src/web/hooks/use-draft.ts +93 -0
  40. package/src/web/hooks/use-terminal.ts +6 -3
  41. package/templates/skill/SKILL.md.tmpl +1 -1
  42. package/dist/web/assets/chat-tab-CGNMb3WW.js +0 -16
  43. package/dist/web/assets/keybindings-store-BP58sNL0.js +0 -1
  44. 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
- .action(async () => {
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
- .action(async (nameOrId: string) => {
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
- .action(async (nameOrId: string, table: string, options: { schema: string }) => {
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
- .action(async (nameOrId: string, sql: string) => {
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 = 21;
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
- <MessageInput
474
- onSend={handleInputSend}
475
- isStreaming={isStreaming}
476
- onCancel={cancelStreaming}
477
- autoFocus={!(metadata?.sessionId) || !!forkDraft}
478
- initialValue={forkDraft}
479
- projectName={projectName}
480
- onSlashStateChange={handleSlashStateChange}
481
- onSlashItemsLoaded={handleSlashItemsLoaded}
482
- slashSelected={slashSelected}
483
- onFileStateChange={handleFileStateChange}
484
- onFileItemsLoaded={setFileItems}
485
- fileSelected={fileSelected}
486
- externalFiles={externalFiles}
487
- externalPaths={externalPaths}
488
- onExternalPathsConsumed={handleExternalPathsConsumed}
489
- onDisambiguate={handleDisambiguate}
490
- permissionMode={permissionMode}
491
- onModeChange={setPermissionMode}
492
- providerId={providerId}
493
- onProviderChange={!sessionId ? setProviderId : undefined}
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
- loadChildren(projectName, segment.parentPath);
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
- if (msg.type === "session" || msg.type === "error" || msg.type === "exited" || msg.type === "ping") {
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; // Don't write raw JSON to terminal
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
- - Listing commands accept `--json` for structured output; prefer JSON when parsing.
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