@hienlh/ppm 0.2.19 → 0.2.21

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 (85) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CLAUDE.md +18 -1
  3. package/bun.lock +57 -59
  4. package/dist/ppm +0 -0
  5. package/dist/web/assets/api-client-BCjah751.js +1 -0
  6. package/dist/web/assets/chat-tab-C_U7EwM9.js +6 -0
  7. package/dist/web/assets/code-editor-DuarTBEe.js +1 -0
  8. package/dist/web/assets/diff-viewer-sBWBgb7U.js +4 -0
  9. package/dist/web/assets/git-graph-fOKEZiot.js +1 -0
  10. package/dist/web/assets/index-3zt5mBwZ.css +2 -0
  11. package/dist/web/assets/index-CaUQy3Zs.js +21 -0
  12. package/dist/web/assets/input-CTnwfHVN.js +41 -0
  13. package/dist/web/assets/settings-tab-C5aWMqIA.js +1 -0
  14. package/dist/web/assets/{terminal-tab-DlRo-KzS.js → terminal-tab-BEFAYT4S.js} +1 -1
  15. package/dist/web/assets/use-monaco-theme-BxaccPmI.js +11 -0
  16. package/dist/web/index.html +35 -9
  17. package/dist/web/sw.js +1 -1
  18. package/docs/codebase-summary.md +13 -8
  19. package/docs/project-roadmap.md +22 -4
  20. package/docs/system-architecture.md +59 -0
  21. package/package.json +6 -14
  22. package/src/providers/claude-agent-sdk.ts +2 -2
  23. package/src/providers/registry.ts +12 -11
  24. package/src/server/routes/projects.ts +43 -0
  25. package/src/server/routes/settings.ts +42 -8
  26. package/src/server/ws/chat.ts +2 -2
  27. package/src/services/config.service.ts +5 -1
  28. package/src/services/project.service.ts +1 -0
  29. package/src/types/config.ts +37 -0
  30. package/src/types/project.ts +1 -0
  31. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/css.worker.bundle.js +54268 -0
  32. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/editor.worker.bundle.js +14316 -0
  33. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/html.worker.bundle.js +30452 -0
  34. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/json.worker.bundle.js +22095 -0
  35. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/ts.worker.bundle.js +225957 -0
  36. package/src/web/app.tsx +43 -5
  37. package/src/web/components/chat/chat-history-panel.tsx +106 -0
  38. package/src/web/components/chat/chat-tab.tsx +27 -19
  39. package/src/web/components/editor/code-editor.tsx +101 -173
  40. package/src/web/components/editor/diff-viewer.tsx +67 -172
  41. package/src/web/components/git/git-status-panel.tsx +4 -11
  42. package/src/web/components/layout/add-project-form.tsx +151 -0
  43. package/src/web/components/layout/command-palette.tsx +3 -1
  44. package/src/web/components/layout/editor-panel.tsx +6 -4
  45. package/src/web/components/layout/mobile-drawer.tsx +48 -180
  46. package/src/web/components/layout/mobile-nav.tsx +89 -6
  47. package/src/web/components/layout/panel-layout.tsx +16 -10
  48. package/src/web/components/layout/project-bar.tsx +329 -0
  49. package/src/web/components/layout/project-bottom-sheet.tsx +345 -0
  50. package/src/web/components/layout/sidebar.tsx +56 -142
  51. package/src/web/components/layout/tab-bar.tsx +1 -6
  52. package/src/web/components/layout/tab-content.tsx +0 -10
  53. package/src/web/components/ui/dialog.tsx +1 -1
  54. package/src/web/lib/project-avatar.ts +45 -0
  55. package/src/web/lib/project-palette.ts +18 -0
  56. package/src/web/lib/use-monaco-theme.ts +29 -0
  57. package/src/web/stores/panel-store.ts +96 -9
  58. package/src/web/stores/project-store.ts +87 -3
  59. package/src/web/stores/settings-store.ts +52 -5
  60. package/src/web/stores/tab-store.ts +0 -2
  61. package/vite.config.ts +6 -2
  62. package/dist/web/assets/api-client-B_eCZViO.js +0 -1
  63. package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +0 -1
  64. package/dist/web/assets/button-CvHWF07y.js +0 -41
  65. package/dist/web/assets/chat-tab-DJvME48K.js +0 -6
  66. package/dist/web/assets/code-editor-81Tzd5aV.js +0 -2
  67. package/dist/web/assets/dialog-Cn5zGuid.js +0 -5
  68. package/dist/web/assets/diff-viewer-pieRctzs.js +0 -4
  69. package/dist/web/assets/dist-B6sG2GPc.js +0 -1
  70. package/dist/web/assets/dist-CBiGQxfr.js +0 -46
  71. package/dist/web/assets/git-graph-CWI6hxtE.js +0 -1
  72. package/dist/web/assets/git-status-panel-CAjReViM.js +0 -1
  73. package/dist/web/assets/index-BdUoflYx.css +0 -2
  74. package/dist/web/assets/index-CqpLusQd.js +0 -17
  75. package/dist/web/assets/project-list-MAvAY2K3.js +0 -1
  76. package/dist/web/assets/react-C32bf_ch.js +0 -1
  77. package/dist/web/assets/refresh-cw-S6I91MHO.js +0 -1
  78. package/dist/web/assets/settings-tab-zeZrAFld.js +0 -1
  79. package/dist/web/assets/trash-2-Dc17nbCE.js +0 -1
  80. package/dist/web/assets/x-Bpqyw40Y.js +0 -1
  81. /package/dist/web/assets/{columns-2-DsiY76NQ.js → columns-2-DFQ3yid7.js} +0 -0
  82. /package/dist/web/assets/{copy-D_Q54D-v.js → copy-B-kLwqzg.js} +0 -0
  83. /package/dist/web/assets/{external-link-C6Y-D528.js → external-link-Dim3NH6h.js} +0 -0
  84. /package/dist/web/assets/{marked.esm-Cv8mjgnt.js → marked.esm-DhBtkBa8.js} +0 -0
  85. /package/dist/web/assets/{utils-61GRB9Cb.js → utils-B-_GCz7E.js} +0 -0
@@ -1,10 +1,10 @@
1
1
  # PPM Project Roadmap & Status
2
2
 
3
- **Last Updated:** March 15, 2026
3
+ **Last Updated:** March 17, 2026
4
4
 
5
5
  ## Current Version: v2.0 (In Progress)
6
6
 
7
- ### Overall Progress: 85%
7
+ ### Overall Progress: 90%
8
8
 
9
9
  Multi-project, project-scoped API refactor with improved UX.
10
10
 
@@ -49,13 +49,21 @@ Multi-project, project-scoped API refactor with improved UX.
49
49
  - URL sync for project/tab/file state
50
50
  - Tab metadata persistence in localStorage
51
51
 
52
+ **Latest Work (260317):**
53
+ - Project Switcher Bar: 52px left sidebar with project avatars, drag-to-reorder
54
+ - Project color customization (12-color palette or custom hex)
55
+ - Mobile ProjectBottomSheet for project selection
56
+ - Keep-alive workspace switching (hide/show instead of unmount, preserves xterm DOM)
57
+ - Sidebar tab system: Explorer / Git / History tabs (removed projects/git-status tab types)
58
+ - Smart project initials with collision detection (1-char, 2-char, or index fallback)
59
+
52
60
  ---
53
61
 
54
62
  ### Phase 4: File Explorer & Editor ✅ Complete
55
63
  - FileTree component with directory expansion
56
- - CodeMirror 6 integration with syntax highlighting
64
+ - Monaco Editor integration with syntax highlighting
57
65
  - File read/write operations
58
- - Diff viewer (Diff2HTML)
66
+ - Monaco diff viewer for git diffs
59
67
 
60
68
  **Status:** Done
61
69
 
@@ -64,6 +72,11 @@ Multi-project, project-scoped API refactor with improved UX.
64
72
  - File viewer for images/PDFs/markdown
65
73
  - Better error messages
66
74
 
75
+ **Latest Work (260317):**
76
+ - Migrated CodeMirror → Monaco Editor (@monaco-editor/react) for better syntax highlighting and diff viewer
77
+ - Alt+Z keyboard shortcut for word wrap toggle in editor and diff viewer
78
+ - Improved code completion and IntelliSense
79
+
67
80
  ---
68
81
 
69
82
  ### Phase 5: Web Terminal ✅ Complete
@@ -206,6 +219,11 @@ Multi-project, project-scoped API refactor with improved UX.
206
219
  - [x] File attachments in chat
207
220
  - [x] Mobile responsive improvements
208
221
  - [x] URL sync for bookmarking/sharing
222
+ - [x] Project Switcher Bar (52px sidebar, avatars, colors, reordering) (260317)
223
+ - [x] Keep-alive workspace switching (preserve xterm DOM) (260317)
224
+ - [x] Sidebar tab system (Explorer/Git/History) (260317)
225
+ - [x] Monaco Editor migration (CodeMirror → Monaco) (260317)
226
+ - [x] Project color customization (12-color palette + custom hex) (260317)
209
227
  - [ ] Complete test coverage (60% complete)
210
228
  - [ ] Documentation (in progress)
211
229
  - [ ] Security audit (planned)
@@ -65,12 +65,17 @@
65
65
 
66
66
  **Responsibilities:**
67
67
  - Render UI for file explorer, editor, terminal, chat
68
+ - Project switching with visual indicators (avatars, colors, keep-alive workspaces)
68
69
  - Capture user input (text, file uploads, terminal commands)
69
70
  - Display streaming responses, terminal output
70
71
  - Handle authentication (token in localStorage)
71
72
 
72
73
  **Key Files:**
73
74
  - `src/web/app.tsx` — Root React component
75
+ - `src/web/components/layout/project-bar.tsx` — Narrow left sidebar with project avatars (52px width)
76
+ - `src/web/components/layout/project-bottom-sheet.tsx` — Mobile project switcher (bottom sheet)
77
+ - `src/web/components/layout/sidebar.tsx` — Main sidebar with Explorer/Git/History tabs
78
+ - `src/web/components/chat/chat-history-panel.tsx` — History tab content (chat sessions)
74
79
  - `src/web/components/` — UI components
75
80
  - `src/cli/commands/` — CLI command handlers
76
81
 
@@ -100,6 +105,8 @@ PUT /api/settings/ai → Update AI provider settings
100
105
  POST /api/projects → Create project
101
106
  GET /api/projects → List projects
102
107
  DELETE /api/projects/:name → Delete project
108
+ PATCH /api/projects/reorder → Reorder projects by name order
109
+ PATCH /api/projects/:name/color → Set project color (hex string)
103
110
  GET /api/project/:name/chat/sessions → List sessions
104
111
  POST /api/project/:name/chat/sessions → Create session
105
112
  GET /api/project/:name/chat/sessions/:id/messages → Get history
@@ -255,6 +262,58 @@ HTTP/1.1 200 OK
255
262
 
256
263
  ---
257
264
 
265
+ ## Project Workspace Management
266
+
267
+ ### Keep-Alive Pattern (v2.0+)
268
+ When switching projects, workspaces are preserved instead of destroyed:
269
+ 1. **Workspace Mount State**: Each project's UI (tabs, terminal xterm DOM, file selections) remains mounted in the DOM
270
+ 2. **Visibility Toggle**: CSS `display: none/block` hides/shows workspaces instead of React unmounting
271
+ 3. **Terminal DOM Persistence**: xterm.js terminal instances retain their DOM structure across switches (prevents re-render flicker)
272
+ 4. **Cache Efficiency**: Zustand stores persist open tabs, selections, and scroll positions per project
273
+
274
+ **Benefits:**
275
+ - Instant project switching (no DOM reconstruction)
276
+ - Terminal history preserved across switches
277
+ - Smooth UX without flashing/re-rendering
278
+ - Reduced network requests (cached UI state)
279
+
280
+ ### Project Color & Ordering (v2.0+)
281
+ **Storage**: Colors stored as optional `color` field in `Project` interface (hex string or undefined)
282
+
283
+ **Endpoints:**
284
+ - `PATCH /api/projects/:name/color` — Update project color
285
+ - `PATCH /api/projects/reorder` — Reorder projects array in config
286
+
287
+ **UI Components:**
288
+ - `ProjectBar` (52px sidebar) — Shows project avatars with color backgrounds, context menu for reorder/rename/delete/color-picker
289
+ - `ProjectBottomSheet` (mobile) — Bottom sheet switcher with scrollable project list
290
+ - `ProjectAvatar` utility — Generates smart initials with collision resolution (prefer 1-char, fallback to 2-char or index)
291
+ - `PROJECT_PALETTE` — 12-color palette for default colors when not customized
292
+
293
+ ---
294
+
295
+ ## Code Editor Migration (v2.0+)
296
+
297
+ **Migration**: CodeMirror 6 → Monaco Editor (@monaco-editor/react)
298
+
299
+ **Reasons:**
300
+ - Better syntax highlighting for complex languages
301
+ - Superior IntelliSense and code completion
302
+ - Performance improvements on large files
303
+ - More polished diff viewer experience
304
+
305
+ **Components Updated:**
306
+ - `src/web/components/editor/code-editor.tsx` — Monaco Editor with language detection
307
+ - `src/web/components/editor/diff-viewer.tsx` — Monaco diff viewer for git diffs
308
+
309
+ **Features:**
310
+ - Alt+Z toggle for word wrap
311
+ - Automatic language detection from file extension
312
+ - Theme sync with app dark/light mode
313
+ - Responsive layout with proper scrolling
314
+
315
+ ---
316
+
258
317
  ## Authentication Flow
259
318
 
260
319
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -8,8 +8,8 @@
8
8
  "ppm": "src/index.ts"
9
9
  },
10
10
  "scripts": {
11
- "dev": "bun run --hot src/index.ts start & bun run vite --config vite.config.ts & wait",
12
- "dev:server": "bun run --hot src/index.ts start -f",
11
+ "dev": "bun run --hot src/index.ts start -c ~/.ppm/config.dev.yaml & bun run vite --config vite.config.ts & wait",
12
+ "dev:server": "bun run --hot src/index.ts start -c ~/.ppm/config.dev.yaml",
13
13
  "dev:web": "bun run vite --config vite.config.ts",
14
14
  "build:web": "bun run vite build --config vite.config.ts",
15
15
  "build": "bun run build:web && bun build src/index.ts --compile --outfile dist/ppm",
@@ -27,6 +27,7 @@
27
27
  "@types/react-dom": "^19.2.3",
28
28
  "@types/web-push": "^3.6.4",
29
29
  "@vitejs/plugin-react": "^6.0.1",
30
+ "esbuild": "^0.27.4",
30
31
  "tailwindcss": "^4.2.1",
31
32
  "vite": "^8.0.0",
32
33
  "vite-plugin-pwa": "^1.2.0"
@@ -36,23 +37,13 @@
36
37
  },
37
38
  "dependencies": {
38
39
  "@anthropic-ai/claude-agent-sdk": "^0.2.76",
39
- "@codemirror/autocomplete": "^6.20.1",
40
- "@codemirror/lang-css": "^6.3.1",
41
- "@codemirror/lang-html": "^6.4.11",
42
- "@codemirror/lang-javascript": "^6.2.5",
43
- "@codemirror/lang-json": "^6.0.2",
44
- "@codemirror/lang-markdown": "^6.5.0",
45
- "@codemirror/lang-python": "^6.2.1",
46
- "@codemirror/merge": "^6.12.1",
47
- "@codemirror/theme-one-dark": "^6.1.3",
48
40
  "@inquirer/prompts": "^8.3.0",
49
- "@uiw/react-codemirror": "^4.25.8",
41
+ "@monaco-editor/react": "^4.7.0",
50
42
  "@xterm/addon-fit": "^0.11.0",
51
43
  "@xterm/addon-web-links": "^0.12.0",
52
44
  "@xterm/xterm": "^6.0.0",
53
45
  "class-variance-authority": "^0.7.1",
54
46
  "clsx": "^2.1.1",
55
- "codemirror": "^6.0.2",
56
47
  "commander": "^14.0.3",
57
48
  "diff2html": "^3.4.56",
58
49
  "hono": "^4.12.8",
@@ -68,6 +59,7 @@
68
59
  "simple-git": "^3.33.0",
69
60
  "sonner": "^2.0.7",
70
61
  "tailwind-merge": "^3.5.0",
62
+ "vite-plugin-monaco-editor": "^1.1.0",
71
63
  "web-push": "^3.6.7",
72
64
  "zustand": "^5.0.11"
73
65
  }
@@ -56,8 +56,8 @@ interface PendingApproval {
56
56
  * Uses canUseTool callback for tool approvals and AskUserQuestion.
57
57
  */
58
58
  export class ClaudeAgentSdkProvider implements AIProvider {
59
- id = "claude-sdk";
60
- name = "Claude Agent SDK";
59
+ id = "claude";
60
+ name = "Claude";
61
61
 
62
62
  private activeSessions = new Map<string, Session>();
63
63
  private messageCount = new Map<string, number>();
@@ -1,6 +1,7 @@
1
1
  import type { AIProvider } from "./provider.interface.ts";
2
2
  import { MockProvider } from "./mock-provider.ts";
3
3
  import { ClaudeAgentSdkProvider } from "./claude-agent-sdk.ts";
4
+ import { configService } from "../services/config.service.ts";
4
5
 
5
6
  export interface ProviderInfo {
6
7
  id: string;
@@ -9,13 +10,9 @@ export interface ProviderInfo {
9
10
 
10
11
  class ProviderRegistry {
11
12
  private providers = new Map<string, AIProvider>();
12
- private defaultId: string | null = null;
13
13
 
14
14
  register(provider: AIProvider): void {
15
15
  this.providers.set(provider.id, provider);
16
- if (!this.defaultId) {
17
- this.defaultId = provider.id;
18
- }
19
16
  }
20
17
 
21
18
  get(id: string): AIProvider | undefined {
@@ -29,15 +26,19 @@ class ProviderRegistry {
29
26
  }));
30
27
  }
31
28
 
29
+ /** Get the default provider based on config's default_provider */
32
30
  getDefault(): AIProvider {
33
- if (!this.defaultId) throw new Error("No providers registered");
34
- const provider = this.providers.get(this.defaultId);
35
- if (!provider) throw new Error("Default provider not found");
36
- return provider;
31
+ const defaultId = configService.get("ai").default_provider;
32
+ const provider = this.providers.get(defaultId);
33
+ if (provider) return provider;
34
+ // Fallback to "claude" if config value doesn't match any registered provider
35
+ const fallback = this.providers.get("claude");
36
+ if (fallback) return fallback;
37
+ throw new Error(`Default provider "${defaultId}" not found in registry`);
37
38
  }
38
39
  }
39
40
 
40
- /** Singleton registry — first registered = default */
41
+ /** Singleton registry */
41
42
  export const providerRegistry = new ProviderRegistry();
42
- providerRegistry.register(new ClaudeAgentSdkProvider()); // default — real streaming, multi-turn
43
- providerRegistry.register(new MockProvider()); // testing only
43
+ providerRegistry.register(new ClaudeAgentSdkProvider());
44
+ providerRegistry.register(new MockProvider()); // testing only
@@ -1,5 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import { projectService } from "../../services/project.service.ts";
3
+ import { configService } from "../../services/config.service.ts";
3
4
  import { searchGitDirs } from "../../services/git-dirs.service.ts";
4
5
  import { ok, err } from "../../types/api.ts";
5
6
 
@@ -45,6 +46,48 @@ projectRoutes.get("/suggest-dirs", (c) => {
45
46
  }
46
47
  });
47
48
 
49
+ /** PATCH /api/projects/reorder — reorder projects array */
50
+ projectRoutes.patch("/reorder", async (c) => {
51
+ try {
52
+ const body = await c.req.json<{ order: string[] }>();
53
+ if (!Array.isArray(body.order)) {
54
+ return c.json(err("Missing required field: order (string[])"), 400);
55
+ }
56
+ const projects = configService.get("projects");
57
+ const orderMap = new Map(body.order.map((name, i) => [name, i]));
58
+ const reordered = [...projects].sort((a, b) => {
59
+ const ai = orderMap.get(a.name) ?? Infinity;
60
+ const bi = orderMap.get(b.name) ?? Infinity;
61
+ return ai - bi;
62
+ });
63
+ configService.set("projects", reordered);
64
+ configService.save();
65
+ return c.json(ok({ reordered: reordered.length }));
66
+ } catch (e) {
67
+ return c.json(err((e as Error).message), 400);
68
+ }
69
+ });
70
+
71
+ /** PATCH /api/projects/:name/color — set project color */
72
+ projectRoutes.patch("/:name/color", async (c) => {
73
+ try {
74
+ const name = c.req.param("name");
75
+ const body = await c.req.json<{ color: string | null }>();
76
+ const projects = configService.get("projects");
77
+ const idx = projects.findIndex((p) => p.name === name);
78
+ if (idx === -1) return c.json(err(`Project not found: ${name}`), 404);
79
+ const updated = { ...projects[idx]! };
80
+ if (body.color) updated.color = body.color;
81
+ else delete updated.color;
82
+ projects[idx] = updated;
83
+ configService.set("projects", projects);
84
+ configService.save();
85
+ return c.json(ok(updated));
86
+ } catch (e) {
87
+ return c.json(err((e as Error).message), 400);
88
+ }
89
+ });
90
+
48
91
  /** PATCH /api/projects/:name — update a project's name/path */
49
92
  projectRoutes.patch("/:name", async (c) => {
50
93
  try {
@@ -3,20 +3,51 @@ import { configService } from "../../services/config.service.ts";
3
3
  import {
4
4
  validateAIProviderConfig,
5
5
  validateDefaultProvider,
6
+ VALID_PROVIDERS,
6
7
  type AIProviderConfig,
8
+ type ThemeConfig,
7
9
  } from "../../types/config.ts";
8
10
  import { ok, err } from "../../types/api.ts";
9
11
 
10
12
  export const settingsRoutes = new Hono();
11
13
 
14
+ /** Strip api_key_env from all providers in an AI config object */
15
+ function stripSensitiveFields(ai: { providers: Record<string, unknown> }) {
16
+ const clone = structuredClone(ai);
17
+ for (const provider of Object.values(clone.providers)) {
18
+ delete (provider as Record<string, unknown>).api_key_env;
19
+ }
20
+ return clone;
21
+ }
22
+
23
+ // ── Theme ─────────────────────────────────────────────────────────────
24
+
25
+ /** GET /settings/theme */
26
+ settingsRoutes.get("/theme", (c) => {
27
+ return c.json(ok({ theme: configService.get("theme") ?? "system" }));
28
+ });
29
+
30
+ /** PUT /settings/theme */
31
+ settingsRoutes.put("/theme", async (c) => {
32
+ try {
33
+ const { theme } = await c.req.json<{ theme: ThemeConfig }>();
34
+ if (!["light", "dark", "system"].includes(theme)) {
35
+ return c.json(err("theme must be light, dark, or system"), 400);
36
+ }
37
+ configService.set("theme", theme);
38
+ configService.save();
39
+ return c.json(ok({ theme }));
40
+ } catch (e) {
41
+ return c.json(err((e as Error).message), 400);
42
+ }
43
+ });
44
+
45
+ // ── AI ────────────────────────────────────────────────────────────────
46
+
12
47
  /** GET /settings/ai — return current AI config (strips api_key_env) */
13
48
  settingsRoutes.get("/ai", (c) => {
14
- const ai = structuredClone(configService.get("ai"));
15
- // Strip sensitive env var names from response
16
- for (const provider of Object.values(ai.providers)) {
17
- delete (provider as unknown as Record<string, unknown>).api_key_env;
18
- }
19
- return c.json(ok(ai));
49
+ const ai = configService.get("ai");
50
+ return c.json(ok(stripSensitiveFields(ai)));
20
51
  });
21
52
 
22
53
  /** PUT /settings/ai — update AI provider settings, writes to yaml */
@@ -54,8 +85,11 @@ settingsRoutes.put("/ai", async (c) => {
54
85
  }
55
86
  }
56
87
 
57
- // Validate default_provider references existing provider
88
+ // Validate default_provider is in allowed list and references existing provider
58
89
  if (body.default_provider) {
90
+ if (!VALID_PROVIDERS.includes(body.default_provider as any)) {
91
+ return c.json(err(`default_provider must be one of: ${VALID_PROVIDERS.join(", ")}`), 400);
92
+ }
59
93
  const dpErr = validateDefaultProvider(updated.default_provider, updated.providers);
60
94
  if (dpErr) return c.json(err(dpErr), 400);
61
95
  }
@@ -63,7 +97,7 @@ settingsRoutes.put("/ai", async (c) => {
63
97
  configService.set("ai", updated);
64
98
  configService.save();
65
99
 
66
- return c.json(ok(updated));
100
+ return c.json(ok(stripSensitiveFields(updated)));
67
101
  } catch (e) {
68
102
  return c.json(err((e as Error).message), 400);
69
103
  }
@@ -159,7 +159,7 @@ export const chatWebSocket = {
159
159
  open(ws: ChatWsSocket) {
160
160
  const { sessionId, projectName } = ws.data;
161
161
  const session = chatService.getSession(sessionId);
162
- const providerId = session?.providerId ?? "claude-sdk";
162
+ const providerId = session?.providerId ?? providerRegistry.getDefault().id;
163
163
 
164
164
  let projectPath: string | undefined;
165
165
  if (projectName) {
@@ -238,7 +238,7 @@ export const chatWebSocket = {
238
238
  }
239
239
 
240
240
  const entry = activeSessions.get(sessionId);
241
- const providerId = entry?.providerId ?? "mock";
241
+ const providerId = entry?.providerId ?? providerRegistry.getDefault().id;
242
242
 
243
243
  if (parsed.type === "message") {
244
244
  // Resume session in provider first
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { randomBytes } from "node:crypto";
5
5
  import yaml from "js-yaml";
6
6
  import type { PpmConfig } from "../types/config.ts";
7
- import { DEFAULT_CONFIG } from "../types/config.ts";
7
+ import { DEFAULT_CONFIG, sanitizeConfig } from "../types/config.ts";
8
8
 
9
9
  const PPM_DIR = resolve(homedir(), ".ppm");
10
10
  const GLOBAL_CONFIG_PATH = resolve(PPM_DIR, "config.yaml");
@@ -34,6 +34,10 @@ class ConfigService {
34
34
  this.config.auth.token = randomBytes(16).toString("hex");
35
35
  this.save();
36
36
  }
37
+ // Fix invalid config values and persist corrections
38
+ if (sanitizeConfig(this.config)) {
39
+ this.save();
40
+ }
37
41
  return this.config;
38
42
  }
39
43
  }
@@ -13,6 +13,7 @@ class ProjectService {
13
13
  return projects.map((p) => ({
14
14
  name: p.name,
15
15
  path: p.path,
16
+ ...(p.color ? { color: p.color } : {}),
16
17
  }));
17
18
  }
18
19
 
@@ -4,10 +4,13 @@ export interface PushConfig {
4
4
  vapid_subject: string;
5
5
  }
6
6
 
7
+ export type ThemeConfig = "light" | "dark" | "system";
8
+
7
9
  export interface PpmConfig {
8
10
  device_name: string;
9
11
  port: number;
10
12
  host: string;
13
+ theme: ThemeConfig;
11
14
  auth: AuthConfig;
12
15
  projects: ProjectConfig[];
13
16
  ai: AIConfig;
@@ -22,6 +25,7 @@ export interface AuthConfig {
22
25
  export interface ProjectConfig {
23
26
  path: string;
24
27
  name: string;
28
+ color?: string;
25
29
  }
26
30
 
27
31
  export interface AIConfig {
@@ -44,6 +48,7 @@ export const DEFAULT_CONFIG: PpmConfig = {
44
48
  device_name: "",
45
49
  port: 8080,
46
50
  host: "0.0.0.0",
51
+ theme: "system",
47
52
  auth: { enabled: true, token: "" },
48
53
  projects: [],
49
54
  ai: {
@@ -63,6 +68,9 @@ export const DEFAULT_CONFIG: PpmConfig = {
63
68
  const VALID_TYPES = ["agent-sdk", "mock"] as const;
64
69
  const VALID_EFFORTS = ["low", "medium", "high", "max"] as const;
65
70
  const VALID_MODELS = ["claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5"] as const;
71
+ /** Only these values are allowed for default_provider in config */
72
+ export const VALID_PROVIDERS = ["claude"] as const;
73
+ const VALID_THEMES: ThemeConfig[] = ["light", "dark", "system"];
66
74
 
67
75
  /** Validate AI provider config fields. Returns array of error messages (empty = valid). */
68
76
  export function validateAIProviderConfig(config: Partial<AIProviderConfig>): string[] {
@@ -95,3 +103,32 @@ export function validateDefaultProvider(defaultProvider: string, providers: Reco
95
103
  }
96
104
  return null;
97
105
  }
106
+
107
+ /**
108
+ * Sanitize a loaded config — fix invalid values to defaults.
109
+ * Returns true if any field was corrected (caller should save).
110
+ */
111
+ export function sanitizeConfig(config: PpmConfig): boolean {
112
+ let dirty = false;
113
+
114
+ // Fix invalid theme
115
+ if (!VALID_THEMES.includes(config.theme)) {
116
+ config.theme = DEFAULT_CONFIG.theme;
117
+ dirty = true;
118
+ }
119
+
120
+ // Fix invalid default_provider — must be in VALID_PROVIDERS
121
+ if (!VALID_PROVIDERS.includes(config.ai.default_provider as any)) {
122
+ config.ai.default_provider = DEFAULT_CONFIG.ai.default_provider;
123
+ dirty = true;
124
+ }
125
+
126
+ // Ensure the default provider has a config entry
127
+ if (!config.ai.providers[config.ai.default_provider]) {
128
+ config.ai.providers[config.ai.default_provider] =
129
+ structuredClone(DEFAULT_CONFIG.ai.providers[DEFAULT_CONFIG.ai.default_provider]!);
130
+ dirty = true;
131
+ }
132
+
133
+ return dirty;
134
+ }
@@ -1,6 +1,7 @@
1
1
  export interface Project {
2
2
  name: string;
3
3
  path: string;
4
+ color?: string;
4
5
  }
5
6
 
6
7
  export interface ProjectInfo extends Project {