From 401793e35f69058bb0fc3bcb046f38ae5c56e090 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 26 Mar 2026 11:54:22 -0700 Subject: [PATCH] feat: custom display name + config file foundation (#399) Adds user-editable display names and persistent config via ~/.plannotator/config.json. - ConfigStore singleton with precedence: server config file > cookie > default - Editable identity input in Settings with "Use git name" and regenerate buttons - POST /api/config endpoint for write-back across all 6 servers - getServerConfig() reads config fresh per request (no stale cache) - Eager constructor hydration so the store is safe to read before init() - vendor.sh as single source of truth for Pi extension vendoring - Vendor parity test to prevent missing generated modules Closes #396 --- .github/workflows/release.yml | 16 +-- .github/workflows/test.yml | 16 +-- apps/pi-extension/package.json | 2 +- apps/pi-extension/server/serverAnnotate.ts | 13 +++ apps/pi-extension/server/serverPlan.ts | 14 +++ apps/pi-extension/server/serverReview.ts | 13 +++ apps/pi-extension/vendor.sh | 22 ++++ bun.lock | 3 +- packages/editor/App.tsx | 10 +- packages/review-editor/App.tsx | 11 +- packages/server/annotate.ts | 16 +++ packages/server/config.ts | 7 ++ packages/server/index.ts | 18 +++- packages/server/review.ts | 16 +++ packages/shared/config.ts | 70 +++++++++++++ packages/shared/package.json | 3 +- packages/ui/components/Settings.tsx | 53 ++++++++-- packages/ui/config/configStore.ts | 114 +++++++++++++++++++++ packages/ui/config/index.ts | 2 + packages/ui/config/settings.ts | 38 +++++++ packages/ui/config/useConfig.ts | 20 ++++ packages/ui/icons/GitUser.tsx | 17 +++ packages/ui/utils/generateIdentity.ts | 29 ++++++ packages/ui/utils/identity.ts | 62 ++++------- tests/parity/vendor-parity.test.ts | 89 ++++++++++++++++ 25 files changed, 589 insertions(+), 85 deletions(-) create mode 100755 apps/pi-extension/vendor.sh create mode 100644 packages/server/config.ts create mode 100644 packages/shared/config.ts create mode 100644 packages/ui/config/configStore.ts create mode 100644 packages/ui/config/index.ts create mode 100644 packages/ui/config/settings.ts create mode 100644 packages/ui/config/useConfig.ts create mode 100644 packages/ui/icons/GitUser.tsx create mode 100644 packages/ui/utils/generateIdentity.ts create mode 100644 tests/parity/vendor-parity.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 240efc4e..e722713d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,21 +36,7 @@ jobs: run: bun install - name: Generate Pi extension shared copies - run: | - cd apps/pi-extension - mkdir -p generated generated/ai/providers - for f in feedback-templates review-core storage draft project pr-provider pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file; do - src="../../packages/shared/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" - done - for f in index types provider session-manager endpoints context base-session; do - src="../../packages/ai/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\n' "$f" | cat - "$src" > "generated/ai/$f.ts" - done - for f in claude-agent-sdk codex-sdk opencode-sdk pi-sdk pi-sdk-node pi-events; do - src="../../packages/ai/providers/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/ai/providers/%s.ts\n' "$f" | cat - "$src" > "generated/ai/providers/$f.ts" - done + run: bash apps/pi-extension/vendor.sh - name: Type check run: bun run typecheck diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b6a6745d..e27092a3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,21 +23,7 @@ jobs: run: bun install - name: Generate Pi extension shared copies - run: | - cd apps/pi-extension - mkdir -p generated generated/ai/providers - for f in feedback-templates review-core storage draft project pr-provider pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file; do - src="../../packages/shared/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" - done - for f in index types provider session-manager endpoints context base-session; do - src="../../packages/ai/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\n' "$f" | cat - "$src" > "generated/ai/$f.ts" - done - for f in claude-agent-sdk codex-sdk opencode-sdk pi-sdk pi-sdk-node pi-events; do - src="../../packages/ai/providers/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/ai/providers/%s.ts\n' "$f" | cat - "$src" > "generated/ai/providers/$f.ts" - done + run: bash apps/pi-extension/vendor.sh - name: Type check run: bun run typecheck diff --git a/apps/pi-extension/package.json b/apps/pi-extension/package.json index c1140dfe..6de12656 100644 --- a/apps/pi-extension/package.json +++ b/apps/pi-extension/package.json @@ -31,7 +31,7 @@ "skills/" ], "scripts": { - "build": "cp ../hook/dist/index.html plannotator.html && cp ../hook/dist/review.html review-editor.html && rm -rf skills && cp -r ../skills skills && mkdir -p generated && for f in feedback-templates review-core storage draft project pr-provider pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file; do src=\"../../packages/shared/$f.ts\"; printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\\n' \"$f\" | cat - \"$src\" > \"generated/$f.ts\"; done && mkdir -p generated/ai/providers && for f in index types provider session-manager endpoints context base-session; do src=\"../../packages/ai/$f.ts\"; printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\\n' \"$f\" | cat - \"$src\" > \"generated/ai/$f.ts\"; done && for f in claude-agent-sdk codex-sdk opencode-sdk pi-sdk pi-sdk-node pi-events; do src=\"../../packages/ai/providers/$f.ts\"; printf '// @generated — DO NOT EDIT. Source: packages/ai/providers/%s.ts\\n' \"$f\" | cat - \"$src\" > \"generated/ai/providers/$f.ts\"; done", + "build": "cp ../hook/dist/index.html plannotator.html && cp ../hook/dist/review.html review-editor.html && rm -rf skills && cp -r ../skills skills && bash vendor.sh", "prepublishOnly": "cd ../.. && bun run build:pi" }, "peerDependencies": { diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index d7b1aa30..9a11d0f7 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -2,6 +2,7 @@ import { createServer } from "node:http"; import { dirname, resolve as resolvePath } from "node:path"; import { contentHash, deleteDraft } from "../generated/draft.js"; +import { saveConfig, detectGitUser, getServerConfig } from "../generated/config.js"; import { handleDraftRequest, @@ -35,6 +36,7 @@ export async function startAnnotateServer(options: { shareBaseUrl?: string; pasteApiUrl?: string; }): Promise { + const gitUser = detectGitUser(); const sharingEnabled = options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled"; const shareBaseUrl = @@ -73,7 +75,18 @@ export async function startAnnotateServer(options: { pasteApiUrl, repoInfo, projectRoot: options.folderPath || process.cwd(), + serverConfig: getServerConfig(gitUser), }); + } else if (url.pathname === "/api/config" && req.method === "POST") { + try { + const body = (await parseBody(req)) as { displayName?: string }; + if (body.displayName !== undefined) { + saveConfig({ displayName: body.displayName }); + } + json(res, { ok: true }); + } catch { + json(res, { error: "Invalid request" }, 400); + } } else if (url.pathname === "/api/image") { handleImageRequest(res, url); } else if (url.pathname === "/api/upload" && req.method === "POST") { diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index e3051538..89154a29 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -34,6 +34,7 @@ import { } from "./integrations.js"; import { listenOnPort } from "./network.js"; +import { saveConfig, detectGitUser, getServerConfig } from "../generated/config.js"; import { detectProjectName, getRepoInfo } from "./project.js"; import { handleDocRequest, @@ -63,6 +64,7 @@ export async function startPlanReviewServer(options: { mode?: "archive"; customPlanPath?: string | null; }): Promise { + const gitUser = detectGitUser(); const sharingEnabled = options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled"; const shareBaseUrl = @@ -189,6 +191,7 @@ export async function startPlanReviewServer(options: { archivePlans, sharingEnabled, shareBaseUrl, + serverConfig: getServerConfig(gitUser), }); } else { json(res, { @@ -202,8 +205,19 @@ export async function startPlanReviewServer(options: { pasteApiUrl, repoInfo, projectRoot: process.cwd(), + serverConfig: getServerConfig(gitUser), }); } + } else if (url.pathname === "/api/config" && req.method === "POST") { + try { + const body = (await parseBody(req)) as { displayName?: string }; + if (body.displayName !== undefined) { + saveConfig({ displayName: body.displayName }); + } + json(res, { ok: true }); + } catch { + json(res, { error: "Invalid request" }, 400); + } } else if (url.pathname === "/api/image") { handleImageRequest(res, url); } else if (url.pathname === "/api/upload" && req.method === "POST") { diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index c0a51db8..a665e763 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -5,6 +5,7 @@ import { createServer } from "node:http"; import { Readable } from "node:stream"; import { contentHash, deleteDraft } from "../generated/draft.js"; +import { saveConfig, detectGitUser, getServerConfig } from "../generated/config.js"; export type { DiffOption, @@ -116,6 +117,7 @@ export async function startReviewServer(options: { shareBaseUrl?: string; prMetadata?: PRMetadata; }): Promise { + const gitUser = detectGitUser(); const draftKey = contentHash(options.rawPatch); const prMeta = options.prMetadata; const isPRMode = !!prMeta; @@ -297,6 +299,7 @@ export async function startReviewServer(options: { ...(isPRMode && { prMetadata: prMeta, platformUser }), ...(isPRMode && initialViewedFiles.length > 0 && { viewedFiles: initialViewedFiles }), ...(currentError ? { error: currentError } : {}), + serverConfig: getServerConfig(gitUser), }); } else if (url.pathname === "/api/diff/switch" && req.method === "POST") { if (isPRMode) { @@ -458,6 +461,16 @@ export async function startReviewServer(options: { defaultCwd, ); json(res, result); + } else if (url.pathname === "/api/config" && req.method === "POST") { + try { + const body = (await parseBody(req)) as { displayName?: string }; + if (body.displayName !== undefined) { + saveConfig({ displayName: body.displayName }); + } + json(res, { ok: true }); + } catch { + json(res, { error: "Invalid request" }, 400); + } } else if (url.pathname === "/api/image") { handleImageRequest(res, url); } else if (url.pathname === "/api/upload" && req.method === "POST") { diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh new file mode 100755 index 00000000..727b0749 --- /dev/null +++ b/apps/pi-extension/vendor.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Vendor shared modules into generated/ for Pi extension. +# Single source of truth — used by both `npm run build` and CI test workflow. +set -euo pipefail +cd "$(dirname "$0")" + +mkdir -p generated generated/ai/providers + +for f in feedback-templates review-core storage draft project pr-provider pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config; do + src="../../packages/shared/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" +done + +for f in index types provider session-manager endpoints context base-session; do + src="../../packages/ai/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\n' "$f" | cat - "$src" > "generated/ai/$f.ts" +done + +for f in claude-agent-sdk codex-sdk opencode-sdk pi-sdk pi-sdk-node pi-events; do + src="../../packages/ai/providers/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/ai/providers/%s.ts\n' "$f" | cat - "$src" > "generated/ai/providers/$f.ts" +done diff --git a/bun.lock b/bun.lock index f8f8486e..6a441dab 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "plannotator", @@ -84,7 +83,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.15.2", + "version": "0.15.4", "peerDependencies": { "@mariozechner/pi-coding-agent": ">=0.53.0", }, diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 0a9424d9..db0ab30f 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -16,6 +16,7 @@ import { useSharing } from '@plannotator/ui/hooks/useSharing'; import { useAgents } from '@plannotator/ui/hooks/useAgents'; import { useActiveSection } from '@plannotator/ui/hooks/useActiveSection'; import { storage } from '@plannotator/ui/utils/storage'; +import { configStore } from '@plannotator/ui/config'; import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner'; import { getObsidianSettings, getEffectiveVaultPath, isObsidianConfigured, CUSTOM_PATH_SENTINEL } from '@plannotator/ui/utils/obsidian'; @@ -85,6 +86,7 @@ const App: React.FC = () => { const [uiPrefs, setUiPrefs] = useState(() => getUIPreferences()); const [isApiMode, setIsApiMode] = useState(false); const [origin, setOrigin] = useState<'claude-code' | 'opencode' | 'pi' | 'codex' | null>(null); + const [gitUser, setGitUser] = useState(); const [isWSL, setIsWSL] = useState(false); const [globalAttachments, setGlobalAttachments] = useState([]); const [annotateMode, setAnnotateMode] = useState(false); @@ -386,7 +388,11 @@ const App: React.FC = () => { if (!res.ok) throw new Error('Not in API mode'); return res.json(); }) - .then((data: { plan: string; origin?: 'claude-code' | 'opencode' | 'pi' | 'codex'; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'archive'; filePath?: string; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean }) => { + .then((data: { plan: string; origin?: 'claude-code' | 'opencode' | 'pi' | 'codex'; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'archive'; filePath?: string; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string } }) => { + // Initialize config store with server-provided values (config file > cookie > default) + configStore.init(data.serverConfig); + // gitUser drives the "Use git name" button in Settings; stays undefined (button hidden) when unavailable + setGitUser(data.serverConfig?.gitUser); if (data.mode === 'archive') { // Archive mode: show first archived plan or clear demo content setMarkdown(data.plan || ''); @@ -1133,7 +1139,7 @@ const App: React.FC = () => { {/* Desktop buttons — hidden on mobile */}
- {!linkedDocHook.isActive && setMobileSettingsOpen(false)} />} + {!linkedDocHook.isActive && setMobileSettingsOpen(false)} gitUser={gitUser} />}
-
- {identity} -
+ handleIdentitySave(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleIdentitySave((e.target as HTMLInputElement).value); + (e.target as HTMLInputElement).blur(); + } + }} + className="flex-1 px-3 py-2 bg-muted rounded-lg text-xs font-mono truncate border border-transparent focus:border-primary/50 focus:outline-none transition-colors" + placeholder="Enter your name..." + /> + {gitUser && ( + + )}