opencode-planpilot 0.2.2 → 0.2.4

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.
@@ -0,0 +1,1030 @@
1
+ /// <reference lib="dom" />
2
+
3
+ type PlanStatus = "todo" | "done"
4
+ type StepStatus = "todo" | "done"
5
+ type GoalStatus = "todo" | "done"
6
+
7
+ type PlanRow = {
8
+ id: number
9
+ title: string
10
+ content: string
11
+ status: PlanStatus
12
+ comment: string | null
13
+ updated_at: number
14
+ }
15
+
16
+ type StepRow = {
17
+ id: number
18
+ plan_id: number
19
+ content: string
20
+ status: StepStatus
21
+ executor: "ai" | "human"
22
+ sort_order: number
23
+ comment: string | null
24
+ }
25
+
26
+ type GoalRow = {
27
+ id: number
28
+ step_id: number
29
+ content: string
30
+ status: GoalStatus
31
+ }
32
+
33
+ type ActivePlan = {
34
+ plan_id: number
35
+ }
36
+
37
+ type StepDetail = {
38
+ step: StepRow
39
+ goals: GoalRow[]
40
+ wait?: { until: number; reason?: string } | null
41
+ }
42
+
43
+ type RuntimeSnapshot = {
44
+ paused: boolean
45
+ activePlan: ActivePlan | null
46
+ nextStep: StepDetail | null
47
+ }
48
+
49
+ type PlanDetail = {
50
+ plan: PlanRow
51
+ steps: StepRow[]
52
+ goals: Array<{ stepId: number; goals: GoalRow[] }>
53
+ }
54
+
55
+ type ActionError = {
56
+ code?: string
57
+ message?: string
58
+ }
59
+
60
+ type ActionEnvelope<T> = {
61
+ ok: boolean
62
+ data?: T
63
+ error?: ActionError
64
+ }
65
+
66
+ type AppState = {
67
+ pluginId: string
68
+ context: Record<string, string>
69
+ plans: PlanRow[]
70
+ runtime: RuntimeSnapshot | null
71
+ selectedPlanId: number | null
72
+ selectedPlan: PlanDetail | null
73
+ loading: boolean
74
+ busyAction: string | null
75
+ message: string
76
+ eventStatus: string
77
+ }
78
+
79
+ const FALLBACK_PLUGIN_ID = "opencode-planpilot"
80
+ const REFRESH_DEBOUNCE_MS = 200
81
+
82
+ type UiLocale = "en-US" | "zh-CN"
83
+
84
+ type UiStrings = {
85
+ unknown: string
86
+ waitingUntil: (time: string, reason: string) => string
87
+ stepsDone: (done: number, total: number) => string
88
+ runtimePaused: string
89
+ runtimeActive: string
90
+ none: string
91
+ runtimeStatus: string
92
+ state: string
93
+ activePlan: string
94
+ nextStep: string
95
+ refresh: string
96
+ resume: string
97
+ pause: string
98
+ deactivate: string
99
+ events: string
100
+ noPlansCreateOne: string
101
+ activeBadge: string
102
+ planDetail: string
103
+ selectPlanHint: string
104
+ noGoals: string
105
+ done: string
106
+ executor: string
107
+ addGoalPlaceholder: string
108
+ addGoal: string
109
+ title: string
110
+ status: string
111
+ progress: string
112
+ goals: string
113
+ reactivate: string
114
+ activate: string
115
+ markPlanDone: string
116
+ steps: string
117
+ newStepContentPlaceholder: string
118
+ optionalGoalsPlaceholder: string
119
+ addStep: string
120
+ noStepsYet: string
121
+ createPlanTree: string
122
+ planTitlePlaceholder: string
123
+ planSummaryPlaceholder: string
124
+ inlineStepsPlaceholder: string
125
+ createTree: string
126
+ loading: string
127
+ sidebarTitle: string
128
+ planList: string
129
+ connecting: string
130
+ live: string
131
+ reconnecting: string
132
+ eventError: string
133
+ invalidActionResponse: (action: string) => string
134
+ actionFailed: (action: string, detail: string) => string
135
+ runtimePausedMessage: string
136
+ runtimeResumedMessage: string
137
+ planDeactivatedMessage: string
138
+ planActivatedMessage: (id: number) => string
139
+ planDoneMessage: (id: number) => string
140
+ stepDoneMessage: (id: number) => string
141
+ goalDoneMessage: (id: number) => string
142
+ planTreeRequiredError: string
143
+ planTreeCreatedMessage: string
144
+ stepContentRequiredError: string
145
+ stepAddedMessage: string
146
+ goalContentRequiredError: string
147
+ goalAddedMessage: string
148
+ }
149
+
150
+ const UI_STRINGS: Record<UiLocale, UiStrings> = {
151
+ "en-US": {
152
+ unknown: "unknown",
153
+ waitingUntil: (time, reason) => `Waiting until ${time}${reason}`,
154
+ stepsDone: (done, total) => `${done}/${total} steps done`,
155
+ runtimePaused: "Paused",
156
+ runtimeActive: "Active",
157
+ none: "None",
158
+ runtimeStatus: "Runtime status",
159
+ state: "State",
160
+ activePlan: "Active plan",
161
+ nextStep: "Next step",
162
+ refresh: "Refresh",
163
+ resume: "Resume",
164
+ pause: "Pause",
165
+ deactivate: "Deactivate",
166
+ events: "Events",
167
+ noPlansCreateOne: "No plans yet. Create one below.",
168
+ activeBadge: "active",
169
+ planDetail: "Plan detail",
170
+ selectPlanHint: "Select a plan to inspect details.",
171
+ noGoals: "No goals",
172
+ done: "Done",
173
+ executor: "executor",
174
+ addGoalPlaceholder: "Add goal",
175
+ addGoal: "Add goal",
176
+ title: "Title",
177
+ status: "Status",
178
+ progress: "Progress",
179
+ goals: "Goals",
180
+ reactivate: "Re-activate",
181
+ activate: "Activate",
182
+ markPlanDone: "Mark plan done",
183
+ steps: "Steps",
184
+ newStepContentPlaceholder: "New step content",
185
+ optionalGoalsPlaceholder: "Optional goals, one per line",
186
+ addStep: "Add step",
187
+ noStepsYet: "No steps yet.",
188
+ createPlanTree: "Create plan tree",
189
+ planTitlePlaceholder: "Plan title",
190
+ planSummaryPlaceholder: "Plan summary",
191
+ inlineStepsPlaceholder: "One step per line. Use :: goal A | goal B for inline goals",
192
+ createTree: "Create tree",
193
+ loading: "Loading...",
194
+ sidebarTitle: "Planpilot sidebar",
195
+ planList: "Plan list",
196
+ connecting: "connecting",
197
+ live: "live",
198
+ reconnecting: "reconnecting",
199
+ eventError: "Plugin event error",
200
+ invalidActionResponse: (action) => `Invalid action response for '${action}'`,
201
+ actionFailed: (action, detail) => `Action '${action}' failed: ${detail}`,
202
+ runtimePausedMessage: "Runtime paused",
203
+ runtimeResumedMessage: "Runtime resumed",
204
+ planDeactivatedMessage: "Plan deactivated",
205
+ planActivatedMessage: (id) => `Plan ${id} activated`,
206
+ planDoneMessage: (id) => `Plan ${id} marked done`,
207
+ stepDoneMessage: (id) => `Step ${id} marked done`,
208
+ goalDoneMessage: (id) => `Goal ${id} marked done`,
209
+ planTreeRequiredError: "Plan title, content, and at least one step are required",
210
+ planTreeCreatedMessage: "Plan tree created",
211
+ stepContentRequiredError: "Step content is required",
212
+ stepAddedMessage: "Step added",
213
+ goalContentRequiredError: "Goal content is required",
214
+ goalAddedMessage: "Goal added",
215
+ },
216
+ "zh-CN": {
217
+ unknown: "未知",
218
+ waitingUntil: (time, reason) => `等待至 ${time}${reason}`,
219
+ stepsDone: (done, total) => `已完成步骤 ${done}/${total}`,
220
+ runtimePaused: "已暂停",
221
+ runtimeActive: "运行中",
222
+ none: "无",
223
+ runtimeStatus: "运行时状态",
224
+ state: "状态",
225
+ activePlan: "当前计划",
226
+ nextStep: "下一步",
227
+ refresh: "刷新",
228
+ resume: "继续",
229
+ pause: "暂停",
230
+ deactivate: "取消激活",
231
+ events: "事件",
232
+ noPlansCreateOne: "还没有计划。请在下方创建。",
233
+ activeBadge: "进行中",
234
+ planDetail: "计划详情",
235
+ selectPlanHint: "选择一个计划以查看详情。",
236
+ noGoals: "无目标",
237
+ done: "完成",
238
+ executor: "执行者",
239
+ addGoalPlaceholder: "添加目标",
240
+ addGoal: "添加目标",
241
+ title: "标题",
242
+ status: "状态",
243
+ progress: "进度",
244
+ goals: "目标数",
245
+ reactivate: "重新激活",
246
+ activate: "激活",
247
+ markPlanDone: "标记计划完成",
248
+ steps: "步骤",
249
+ newStepContentPlaceholder: "新步骤内容",
250
+ optionalGoalsPlaceholder: "可选目标,每行一个",
251
+ addStep: "添加步骤",
252
+ noStepsYet: "暂无步骤。",
253
+ createPlanTree: "创建计划树",
254
+ planTitlePlaceholder: "计划标题",
255
+ planSummaryPlaceholder: "计划摘要",
256
+ inlineStepsPlaceholder: "每行一个步骤。使用 :: 目标A | 目标B 添加内联目标",
257
+ createTree: "创建计划树",
258
+ loading: "加载中...",
259
+ sidebarTitle: "Planpilot 侧边栏",
260
+ planList: "计划列表",
261
+ connecting: "连接中",
262
+ live: "已连接",
263
+ reconnecting: "重连中",
264
+ eventError: "插件事件错误",
265
+ invalidActionResponse: (action) => `动作 '${action}' 的响应无效`,
266
+ actionFailed: (action, detail) => `动作 '${action}' 失败: ${detail}`,
267
+ runtimePausedMessage: "运行时已暂停",
268
+ runtimeResumedMessage: "运行时已继续",
269
+ planDeactivatedMessage: "计划已取消激活",
270
+ planActivatedMessage: (id) => `计划 ${id} 已激活`,
271
+ planDoneMessage: (id) => `计划 ${id} 已标记完成`,
272
+ stepDoneMessage: (id) => `步骤 ${id} 已标记完成`,
273
+ goalDoneMessage: (id) => `目标 ${id} 已标记完成`,
274
+ planTreeRequiredError: "计划标题、内容以及至少一个步骤为必填项",
275
+ planTreeCreatedMessage: "计划树已创建",
276
+ stepContentRequiredError: "步骤内容为必填项",
277
+ stepAddedMessage: "步骤已添加",
278
+ goalContentRequiredError: "目标内容为必填项",
279
+ goalAddedMessage: "目标已添加",
280
+ },
281
+ }
282
+
283
+ function normalizeLocale(value: string | null | undefined): UiLocale {
284
+ const normalized = String(value || "").trim().toLowerCase()
285
+ if (normalized.startsWith("zh")) return "zh-CN"
286
+ return "en-US"
287
+ }
288
+
289
+ function detectLocale(): UiLocale {
290
+ const params = new URLSearchParams(window.location.search)
291
+ return normalizeLocale(params.get("locale") || params.get("lang"))
292
+ }
293
+
294
+ const locale = detectLocale()
295
+ const t = UI_STRINGS[locale]
296
+
297
+ const state: AppState = {
298
+ pluginId: detectPluginId(),
299
+ context: detectContext(),
300
+ plans: [],
301
+ runtime: null,
302
+ selectedPlanId: null,
303
+ selectedPlan: null,
304
+ loading: true,
305
+ busyAction: null,
306
+ message: "",
307
+ eventStatus: t.connecting,
308
+ }
309
+
310
+ let refreshTimer = 0
311
+
312
+ function detectPluginId(): string {
313
+ const fromSearch = new URLSearchParams(window.location.search).get("pluginId")
314
+ if (fromSearch && fromSearch.trim()) return fromSearch.trim()
315
+
316
+ const match = window.location.pathname.match(/\/api\/plugins\/([^/]+)\/assets\//)
317
+ if (match && match[1]) {
318
+ try {
319
+ return decodeURIComponent(match[1])
320
+ } catch {
321
+ return match[1]
322
+ }
323
+ }
324
+ return FALLBACK_PLUGIN_ID
325
+ }
326
+
327
+ function detectContext(): Record<string, string> {
328
+ const params = new URLSearchParams(window.location.search)
329
+ const context: Record<string, string> = {}
330
+ const sessionId = params.get("sessionId") || params.get("sessionID")
331
+ const cwd = params.get("cwd") || params.get("directory")
332
+ if (sessionId && sessionId.trim()) context.sessionId = sessionId.trim()
333
+ if (cwd && cwd.trim()) context.cwd = cwd.trim()
334
+ return context
335
+ }
336
+
337
+ function goalsForStep(detail: PlanDetail, stepId: number): GoalRow[] {
338
+ const found = detail.goals.find((entry) => entry.stepId === stepId)
339
+ return found ? found.goals : []
340
+ }
341
+
342
+ function statusTag(status: PlanStatus | StepStatus | GoalStatus): string {
343
+ return status === "done" ? "done" : "todo"
344
+ }
345
+
346
+ function statusLabel(status: PlanStatus | StepStatus | GoalStatus): string {
347
+ if (status === "done") return t.done
348
+ return locale === "zh-CN" ? "待办" : "todo"
349
+ }
350
+
351
+ function activePlanId(): number | null {
352
+ return state.runtime?.activePlan?.plan_id ?? null
353
+ }
354
+
355
+ function planById(id: number | null): PlanRow | undefined {
356
+ if (id === null) return undefined
357
+ return state.plans.find((plan) => plan.id === id)
358
+ }
359
+
360
+ function formatWait(wait: StepDetail["wait"]): string {
361
+ if (!wait || typeof wait.until !== "number") return ""
362
+ const date = new Date(wait.until)
363
+ const time = Number.isNaN(date.getTime()) ? t.unknown : date.toLocaleString(locale)
364
+ const reason = wait.reason ? ` - ${escapeHtml(wait.reason)}` : ""
365
+ return t.waitingUntil(escapeHtml(time), reason)
366
+ }
367
+
368
+ function escapeHtml(value: string): string {
369
+ return value
370
+ .replaceAll("&", "&amp;")
371
+ .replaceAll("<", "&lt;")
372
+ .replaceAll(">", "&gt;")
373
+ .replaceAll('"', "&quot;")
374
+ .replaceAll("'", "&#39;")
375
+ }
376
+
377
+ function selectedPlanGoalCount(detail: PlanDetail): number {
378
+ return detail.goals.reduce((count, entry) => count + entry.goals.length, 0)
379
+ }
380
+
381
+ function planProgressLabel(detail: PlanDetail): string {
382
+ const doneSteps = detail.steps.filter((step) => step.status === "done").length
383
+ return t.stepsDone(doneSteps, detail.steps.length)
384
+ }
385
+
386
+ function parseTreeSteps(input: string): Array<{ content: string; executor: "ai" | "human"; goals: string[] }> {
387
+ const lines = input
388
+ .split("\n")
389
+ .map((line) => line.trim())
390
+ .filter((line) => line.length > 0)
391
+
392
+ const steps: Array<{ content: string; executor: "ai" | "human"; goals: string[] }> = []
393
+ for (const line of lines) {
394
+ const parts = line.split("::")
395
+ const content = parts[0]?.trim() ?? ""
396
+ if (!content) continue
397
+ const rawGoals = (parts[1] ?? "")
398
+ .split("|")
399
+ .map((goal) => goal.trim())
400
+ .filter((goal) => goal.length > 0)
401
+ steps.push({ content, executor: "ai", goals: rawGoals })
402
+ }
403
+ return steps
404
+ }
405
+
406
+ async function invokeAction<T>(action: string, payload: unknown = null): Promise<T> {
407
+ const response = await fetch(`/api/plugins/${encodeURIComponent(state.pluginId)}/action`, {
408
+ method: "POST",
409
+ headers: {
410
+ "content-type": "application/json",
411
+ },
412
+ body: JSON.stringify({
413
+ action,
414
+ payload,
415
+ context: state.context,
416
+ }),
417
+ })
418
+
419
+ let envelope: ActionEnvelope<T>
420
+ try {
421
+ envelope = (await response.json()) as ActionEnvelope<T>
422
+ } catch {
423
+ throw new Error(t.invalidActionResponse(action))
424
+ }
425
+
426
+ if (!response.ok || !envelope.ok) {
427
+ const detail = envelope.error?.message || envelope.error?.code || `HTTP ${response.status}`
428
+ throw new Error(t.actionFailed(action, detail))
429
+ }
430
+
431
+ return envelope.data as T
432
+ }
433
+
434
+ async function loadBaseState(): Promise<void> {
435
+ const [runtime, plans] = await Promise.all([
436
+ invokeAction<RuntimeSnapshot>("runtime.snapshot"),
437
+ invokeAction<PlanRow[]>("plan.list"),
438
+ ])
439
+ state.runtime = runtime
440
+ state.plans = plans
441
+
442
+ const activeId = runtime.activePlan?.plan_id ?? null
443
+ if (state.selectedPlanId === null) {
444
+ state.selectedPlanId = activeId ?? (plans[0]?.id ?? null)
445
+ } else if (!plans.some((plan) => plan.id === state.selectedPlanId)) {
446
+ state.selectedPlanId = activeId ?? (plans[0]?.id ?? null)
447
+ }
448
+ }
449
+
450
+ async function loadSelectedPlan(): Promise<void> {
451
+ if (state.selectedPlanId === null) {
452
+ state.selectedPlan = null
453
+ return
454
+ }
455
+ state.selectedPlan = await invokeAction<PlanDetail>("plan.get", { id: state.selectedPlanId })
456
+ }
457
+
458
+ async function refreshAll(): Promise<void> {
459
+ state.loading = true
460
+ render()
461
+ try {
462
+ await loadBaseState()
463
+ await loadSelectedPlan()
464
+ state.message = ""
465
+ } catch (error) {
466
+ state.message = error instanceof Error ? error.message : String(error)
467
+ } finally {
468
+ state.loading = false
469
+ render()
470
+ }
471
+ }
472
+
473
+ function scheduleRefresh(): void {
474
+ if (refreshTimer) window.clearTimeout(refreshTimer)
475
+ refreshTimer = window.setTimeout(() => {
476
+ refreshTimer = 0
477
+ void refreshAll()
478
+ }, REFRESH_DEBOUNCE_MS)
479
+ }
480
+
481
+ async function runAction(action: string, payload: unknown, successMessage: string): Promise<void> {
482
+ state.busyAction = action
483
+ state.message = ""
484
+ render()
485
+ try {
486
+ await invokeAction<unknown>(action, payload)
487
+ state.message = successMessage
488
+ await refreshAll()
489
+ } catch (error) {
490
+ state.message = error instanceof Error ? error.message : String(error)
491
+ render()
492
+ } finally {
493
+ state.busyAction = null
494
+ render()
495
+ }
496
+ }
497
+
498
+ function bindUiHandlers(root: HTMLElement): void {
499
+ root.querySelectorAll<HTMLButtonElement>("[data-plan-select]").forEach((button) => {
500
+ button.addEventListener("click", () => {
501
+ const planId = Number(button.dataset.planSelect)
502
+ if (!Number.isFinite(planId)) return
503
+ state.selectedPlanId = planId
504
+ void loadSelectedPlan().then(render).catch((error) => {
505
+ state.message = error instanceof Error ? error.message : String(error)
506
+ render()
507
+ })
508
+ })
509
+ })
510
+
511
+ const refreshBtn = root.querySelector<HTMLButtonElement>("[data-action='refresh']")
512
+ if (refreshBtn) {
513
+ refreshBtn.addEventListener("click", () => {
514
+ void refreshAll()
515
+ })
516
+ }
517
+
518
+ const pauseBtn = root.querySelector<HTMLButtonElement>("[data-action='pause']")
519
+ if (pauseBtn) {
520
+ pauseBtn.addEventListener("click", () => {
521
+ void runAction("runtime.pause", null, t.runtimePausedMessage)
522
+ })
523
+ }
524
+
525
+ const resumeBtn = root.querySelector<HTMLButtonElement>("[data-action='resume']")
526
+ if (resumeBtn) {
527
+ resumeBtn.addEventListener("click", () => {
528
+ void runAction("runtime.resume", null, t.runtimeResumedMessage)
529
+ })
530
+ }
531
+
532
+ const deactivateBtn = root.querySelector<HTMLButtonElement>("[data-action='deactivate']")
533
+ if (deactivateBtn) {
534
+ deactivateBtn.addEventListener("click", () => {
535
+ void runAction("plan.deactivate", null, t.planDeactivatedMessage)
536
+ })
537
+ }
538
+
539
+ root.querySelectorAll<HTMLButtonElement>("[data-plan-activate]").forEach((button) => {
540
+ button.addEventListener("click", () => {
541
+ const id = Number(button.dataset.planActivate)
542
+ if (!Number.isFinite(id)) return
543
+ void runAction("plan.activate", { id, force: true }, t.planActivatedMessage(id))
544
+ })
545
+ })
546
+
547
+ root.querySelectorAll<HTMLButtonElement>("[data-plan-done]").forEach((button) => {
548
+ button.addEventListener("click", () => {
549
+ const id = Number(button.dataset.planDone)
550
+ if (!Number.isFinite(id)) return
551
+ void runAction("plan.done", { id }, t.planDoneMessage(id))
552
+ })
553
+ })
554
+
555
+ root.querySelectorAll<HTMLButtonElement>("[data-step-done]").forEach((button) => {
556
+ button.addEventListener("click", () => {
557
+ const id = Number(button.dataset.stepDone)
558
+ if (!Number.isFinite(id)) return
559
+ void runAction("step.done", { id }, t.stepDoneMessage(id))
560
+ })
561
+ })
562
+
563
+ root.querySelectorAll<HTMLButtonElement>("[data-goal-done]").forEach((button) => {
564
+ button.addEventListener("click", () => {
565
+ const id = Number(button.dataset.goalDone)
566
+ if (!Number.isFinite(id)) return
567
+ void runAction("goal.done", { id }, t.goalDoneMessage(id))
568
+ })
569
+ })
570
+
571
+ const createForm = root.querySelector<HTMLFormElement>("[data-form='create-plan-tree']")
572
+ if (createForm) {
573
+ createForm.addEventListener("submit", (event) => {
574
+ event.preventDefault()
575
+ const titleInput = createForm.querySelector<HTMLInputElement>("[name='title']")
576
+ const contentInput = createForm.querySelector<HTMLTextAreaElement>("[name='content']")
577
+ const stepsInput = createForm.querySelector<HTMLTextAreaElement>("[name='steps']")
578
+ const title = titleInput?.value.trim() ?? ""
579
+ const content = contentInput?.value.trim() ?? ""
580
+ const stepsText = stepsInput?.value ?? ""
581
+ const steps = parseTreeSteps(stepsText)
582
+ if (!title || !content || steps.length === 0) {
583
+ state.message = t.planTreeRequiredError
584
+ render()
585
+ return
586
+ }
587
+
588
+ void runAction(
589
+ "plan.createTree",
590
+ { title, content, steps },
591
+ t.planTreeCreatedMessage,
592
+ ).then(() => {
593
+ createForm.reset()
594
+ })
595
+ })
596
+ }
597
+
598
+ const addStepForm = root.querySelector<HTMLFormElement>("[data-form='add-step']")
599
+ if (addStepForm) {
600
+ addStepForm.addEventListener("submit", (event) => {
601
+ event.preventDefault()
602
+ const planId = Number(addStepForm.dataset.planId)
603
+ const contentInput = addStepForm.querySelector<HTMLInputElement>("[name='content']")
604
+ const goalsInput = addStepForm.querySelector<HTMLTextAreaElement>("[name='goals']")
605
+ const content = contentInput?.value.trim() ?? ""
606
+ const goals = (goalsInput?.value ?? "")
607
+ .split("\n")
608
+ .map((goal) => goal.trim())
609
+ .filter((goal) => goal.length > 0)
610
+ if (!Number.isFinite(planId) || !content) {
611
+ state.message = t.stepContentRequiredError
612
+ render()
613
+ return
614
+ }
615
+ void runAction("step.addTree", { planId, content, executor: "ai", goals }, t.stepAddedMessage).then(() => {
616
+ addStepForm.reset()
617
+ })
618
+ })
619
+ }
620
+
621
+ root.querySelectorAll<HTMLFormElement>("[data-form='add-goal']").forEach((form) => {
622
+ form.addEventListener("submit", (event) => {
623
+ event.preventDefault()
624
+ const stepId = Number(form.dataset.stepId)
625
+ const contentInput = form.querySelector<HTMLInputElement>("[name='goalContent']")
626
+ const content = contentInput?.value.trim() ?? ""
627
+ if (!Number.isFinite(stepId) || !content) {
628
+ state.message = t.goalContentRequiredError
629
+ render()
630
+ return
631
+ }
632
+ void runAction("goal.add", { stepId, content }, t.goalAddedMessage).then(() => {
633
+ form.reset()
634
+ })
635
+ })
636
+ })
637
+ }
638
+
639
+ function renderRuntimeCard(): string {
640
+ const runtime = state.runtime
641
+ const isPaused = runtime?.paused ? t.runtimePaused : t.runtimeActive
642
+ const activeId = runtime?.activePlan?.plan_id ?? null
643
+ const activePlan = planById(activeId)
644
+ const nextStep = runtime?.nextStep?.step
645
+ const waitText = runtime?.nextStep ? formatWait(runtime.nextStep.wait ?? null) : ""
646
+ const actionBusy = state.busyAction !== null
647
+
648
+ return `
649
+ <section class="card">
650
+ <h2>${t.runtimeStatus}</h2>
651
+ <div class="runtime-line"><span class="label">${t.state}</span><strong>${escapeHtml(isPaused)}</strong></div>
652
+ <div class="runtime-line"><span class="label">${t.activePlan}</span><span>${activePlan ? `#${activePlan.id} ${escapeHtml(activePlan.title)}` : t.none}</span></div>
653
+ <div class="runtime-line"><span class="label">${t.nextStep}</span><span>${nextStep ? `#${nextStep.id} ${escapeHtml(nextStep.content)}` : t.none}</span></div>
654
+ ${waitText ? `<div class="note">${waitText}</div>` : ""}
655
+ <div class="row-actions">
656
+ <button data-action="refresh" ${actionBusy ? "disabled" : ""}>${t.refresh}</button>
657
+ ${runtime?.paused ? `<button data-action="resume" ${actionBusy ? "disabled" : ""}>${t.resume}</button>` : `<button data-action="pause" ${actionBusy ? "disabled" : ""}>${t.pause}</button>`}
658
+ <button data-action="deactivate" ${actionBusy ? "disabled" : ""}>${t.deactivate}</button>
659
+ </div>
660
+ <div class="small">${t.events}: ${escapeHtml(state.eventStatus)}</div>
661
+ </section>
662
+ `
663
+ }
664
+
665
+ function renderPlanList(): string {
666
+ if (state.plans.length === 0) {
667
+ return `<div class="empty">${t.noPlansCreateOne}</div>`
668
+ }
669
+ return state.plans
670
+ .map((plan) => {
671
+ const isSelected = state.selectedPlanId === plan.id
672
+ const isActive = activePlanId() === plan.id
673
+ return `
674
+ <button class="plan-item ${isSelected ? "selected" : ""}" data-plan-select="${plan.id}">
675
+ <span class="plan-title">#${plan.id} ${escapeHtml(plan.title)}</span>
676
+ <span class="plan-meta">
677
+ <span class="pill ${statusTag(plan.status)}">${statusLabel(plan.status)}</span>
678
+ ${isActive ? `<span class="pill active">${t.activeBadge}</span>` : ""}
679
+ </span>
680
+ </button>
681
+ `
682
+ })
683
+ .join("")
684
+ }
685
+
686
+ function renderPlanDetail(): string {
687
+ const detail = state.selectedPlan
688
+ if (!detail) {
689
+ return `<section class="card"><h2>${t.planDetail}</h2><div class="empty">${t.selectPlanHint}</div></section>`
690
+ }
691
+
692
+ const stepRows = detail.steps
693
+ .map((step) => {
694
+ const goals = goalsForStep(detail, step.id)
695
+ const goalItems = goals.length
696
+ ? goals
697
+ .map(
698
+ (goal) => `
699
+ <li>
700
+ <span class="goal-text">#${goal.id} ${escapeHtml(goal.content)}</span>
701
+ <span class="pill ${statusTag(goal.status)}">${statusLabel(goal.status)}</span>
702
+ ${goal.status === "todo" ? `<button data-goal-done="${goal.id}" ${state.busyAction ? "disabled" : ""}>${t.done}</button>` : ""}
703
+ </li>
704
+ `,
705
+ )
706
+ .join("")
707
+ : `<li class="empty">${t.noGoals}</li>`
708
+
709
+ return `
710
+ <article class="step-card">
711
+ <div class="step-head">
712
+ <div>
713
+ <strong>#${step.id}</strong> ${escapeHtml(step.content)}
714
+ <div class="small">${t.executor}: ${escapeHtml(step.executor)}</div>
715
+ </div>
716
+ <div class="row-actions">
717
+ <span class="pill ${statusTag(step.status)}">${statusLabel(step.status)}</span>
718
+ ${step.status === "todo" ? `<button data-step-done="${step.id}" ${state.busyAction ? "disabled" : ""}>${t.done}</button>` : ""}
719
+ </div>
720
+ </div>
721
+ <ul class="goal-list">${goalItems}</ul>
722
+ <form class="inline-form" data-form="add-goal" data-step-id="${step.id}">
723
+ <input name="goalContent" type="text" placeholder="${t.addGoalPlaceholder}" />
724
+ <button type="submit" ${state.busyAction ? "disabled" : ""}>${t.addGoal}</button>
725
+ </form>
726
+ </article>
727
+ `
728
+ })
729
+ .join("")
730
+
731
+ const isActive = activePlanId() === detail.plan.id
732
+ return `
733
+ <section class="card">
734
+ <h2>${t.planDetail}</h2>
735
+ <div class="runtime-line"><span class="label">${t.title}</span><strong>${escapeHtml(detail.plan.title)}</strong></div>
736
+ <div class="runtime-line"><span class="label">${t.status}</span><span class="pill ${statusTag(detail.plan.status)}">${statusLabel(detail.plan.status)}</span></div>
737
+ <div class="runtime-line"><span class="label">${t.progress}</span><span>${escapeHtml(planProgressLabel(detail))}</span></div>
738
+ <div class="runtime-line"><span class="label">${t.goals}</span><span>${selectedPlanGoalCount(detail)}</span></div>
739
+ <p class="content">${escapeHtml(detail.plan.content)}</p>
740
+ <div class="row-actions">
741
+ <button data-plan-activate="${detail.plan.id}" ${state.busyAction ? "disabled" : ""}>${isActive ? t.reactivate : t.activate}</button>
742
+ <button data-plan-done="${detail.plan.id}" ${state.busyAction ? "disabled" : ""}>${t.markPlanDone}</button>
743
+ </div>
744
+ </section>
745
+ <section class="card">
746
+ <h2>${t.steps}</h2>
747
+ <form class="stack-form" data-form="add-step" data-plan-id="${detail.plan.id}">
748
+ <input name="content" type="text" placeholder="${t.newStepContentPlaceholder}" />
749
+ <textarea name="goals" rows="3" placeholder="${t.optionalGoalsPlaceholder}"></textarea>
750
+ <button type="submit" ${state.busyAction ? "disabled" : ""}>${t.addStep}</button>
751
+ </form>
752
+ ${stepRows || `<div class="empty">${t.noStepsYet}</div>`}
753
+ </section>
754
+ `
755
+ }
756
+
757
+ function renderCreateForm(): string {
758
+ return `
759
+ <section class="card">
760
+ <h2>${t.createPlanTree}</h2>
761
+ <form class="stack-form" data-form="create-plan-tree">
762
+ <input name="title" type="text" placeholder="${t.planTitlePlaceholder}" />
763
+ <textarea name="content" rows="3" placeholder="${t.planSummaryPlaceholder}"></textarea>
764
+ <textarea name="steps" rows="4" placeholder="${t.inlineStepsPlaceholder}"></textarea>
765
+ <button type="submit" ${state.busyAction ? "disabled" : ""}>${t.createTree}</button>
766
+ </form>
767
+ </section>
768
+ `
769
+ }
770
+
771
+ function renderStyles(): string {
772
+ return `
773
+ <style>
774
+ :root {
775
+ color-scheme: light;
776
+ --bg: #f4f6ef;
777
+ --card: #ffffff;
778
+ --ink: #1f2a2a;
779
+ --muted: #5c6767;
780
+ --line: #d5d9d3;
781
+ --ok: #1f7a52;
782
+ --todo: #b55f2d;
783
+ --active: #125ea9;
784
+ }
785
+ * { box-sizing: border-box; }
786
+ body {
787
+ margin: 0;
788
+ font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
789
+ color: var(--ink);
790
+ background: radial-gradient(circle at top left, #fafcf5, var(--bg));
791
+ }
792
+ #app {
793
+ padding: 10px;
794
+ display: grid;
795
+ gap: 10px;
796
+ }
797
+ .card {
798
+ background: var(--card);
799
+ border: 1px solid var(--line);
800
+ border-radius: 12px;
801
+ padding: 10px;
802
+ }
803
+ h1, h2 {
804
+ margin: 0 0 8px;
805
+ font-size: 14px;
806
+ }
807
+ .small {
808
+ color: var(--muted);
809
+ font-size: 11px;
810
+ }
811
+ .note {
812
+ margin: 6px 0;
813
+ color: var(--muted);
814
+ font-size: 12px;
815
+ }
816
+ .runtime-line {
817
+ display: flex;
818
+ gap: 8px;
819
+ justify-content: space-between;
820
+ margin-bottom: 6px;
821
+ font-size: 12px;
822
+ }
823
+ .label {
824
+ color: var(--muted);
825
+ }
826
+ .row-actions {
827
+ display: flex;
828
+ gap: 6px;
829
+ flex-wrap: wrap;
830
+ }
831
+ button {
832
+ border: 1px solid var(--line);
833
+ border-radius: 8px;
834
+ background: #f9faf7;
835
+ color: var(--ink);
836
+ padding: 5px 8px;
837
+ font-size: 12px;
838
+ cursor: pointer;
839
+ }
840
+ button:disabled {
841
+ opacity: 0.6;
842
+ cursor: default;
843
+ }
844
+ .pill {
845
+ display: inline-flex;
846
+ align-items: center;
847
+ border-radius: 999px;
848
+ padding: 2px 7px;
849
+ font-size: 11px;
850
+ border: 1px solid var(--line);
851
+ }
852
+ .pill.done {
853
+ color: var(--ok);
854
+ border-color: color-mix(in srgb, var(--ok), white 65%);
855
+ }
856
+ .pill.todo {
857
+ color: var(--todo);
858
+ border-color: color-mix(in srgb, var(--todo), white 65%);
859
+ }
860
+ .pill.active {
861
+ color: var(--active);
862
+ border-color: color-mix(in srgb, var(--active), white 65%);
863
+ }
864
+ .plan-item {
865
+ width: 100%;
866
+ text-align: left;
867
+ display: flex;
868
+ justify-content: space-between;
869
+ align-items: center;
870
+ margin-bottom: 6px;
871
+ }
872
+ .plan-item.selected {
873
+ border-color: var(--active);
874
+ background: #f1f7ff;
875
+ }
876
+ .plan-title {
877
+ overflow: hidden;
878
+ text-overflow: ellipsis;
879
+ white-space: nowrap;
880
+ max-width: 72%;
881
+ }
882
+ .plan-meta {
883
+ display: inline-flex;
884
+ gap: 4px;
885
+ }
886
+ .content {
887
+ white-space: pre-wrap;
888
+ font-size: 12px;
889
+ margin: 6px 0 8px;
890
+ }
891
+ .stack-form,
892
+ .inline-form {
893
+ display: grid;
894
+ gap: 6px;
895
+ }
896
+ .inline-form {
897
+ grid-template-columns: 1fr auto;
898
+ }
899
+ input,
900
+ textarea {
901
+ width: 100%;
902
+ border: 1px solid var(--line);
903
+ border-radius: 8px;
904
+ padding: 6px;
905
+ font: inherit;
906
+ font-size: 12px;
907
+ }
908
+ .step-card {
909
+ border: 1px solid var(--line);
910
+ border-radius: 10px;
911
+ padding: 8px;
912
+ margin-top: 8px;
913
+ }
914
+ .step-head {
915
+ display: flex;
916
+ justify-content: space-between;
917
+ gap: 8px;
918
+ }
919
+ .goal-list {
920
+ margin: 8px 0;
921
+ padding-left: 16px;
922
+ display: grid;
923
+ gap: 4px;
924
+ }
925
+ .goal-list li {
926
+ display: grid;
927
+ grid-template-columns: 1fr auto auto;
928
+ gap: 6px;
929
+ align-items: center;
930
+ }
931
+ .goal-text {
932
+ overflow: hidden;
933
+ text-overflow: ellipsis;
934
+ white-space: nowrap;
935
+ }
936
+ .empty {
937
+ color: var(--muted);
938
+ font-size: 12px;
939
+ }
940
+ .message {
941
+ font-size: 12px;
942
+ color: #8a2f19;
943
+ }
944
+ @media (min-width: 900px) {
945
+ #app {
946
+ grid-template-columns: 300px 1fr;
947
+ align-items: start;
948
+ }
949
+ }
950
+ </style>
951
+ `
952
+ }
953
+
954
+ function render(): void {
955
+ const root = document.getElementById("app")
956
+ if (!root) return
957
+
958
+ const loadingText = state.loading ? `<div class="small">${t.loading}</div>` : ""
959
+ const messageText = state.message ? `<div class="message">${escapeHtml(state.message)}</div>` : ""
960
+
961
+ root.innerHTML = `
962
+ ${renderStyles()}
963
+ <section>
964
+ <div class="card">
965
+ <h1>${t.sidebarTitle}</h1>
966
+ ${loadingText}
967
+ ${messageText}
968
+ </div>
969
+ ${renderRuntimeCard()}
970
+ <section class="card">
971
+ <h2>${t.planList}</h2>
972
+ ${renderPlanList()}
973
+ </section>
974
+ ${renderCreateForm()}
975
+ </section>
976
+ <section>
977
+ ${renderPlanDetail()}
978
+ </section>
979
+ `
980
+
981
+ bindUiHandlers(root)
982
+ }
983
+
984
+ function subscribeEvents(): () => void {
985
+ const source = new EventSource(`/api/plugins/${encodeURIComponent(state.pluginId)}/events`)
986
+
987
+ const onChange = () => {
988
+ state.eventStatus = t.live
989
+ scheduleRefresh()
990
+ render()
991
+ }
992
+
993
+ source.onopen = () => {
994
+ state.eventStatus = t.live
995
+ render()
996
+ }
997
+
998
+ source.addEventListener("plugin.event", onChange)
999
+ source.addEventListener("planpilot.runtime.changed", onChange)
1000
+ source.onmessage = onChange
1001
+ source.addEventListener("heartbeat", () => {
1002
+ state.eventStatus = t.live
1003
+ render()
1004
+ })
1005
+
1006
+ source.addEventListener("plugin.error", (event) => {
1007
+ const data = event instanceof MessageEvent ? String(event.data || "") : t.eventError
1008
+ state.message = data.length > 300 ? `${data.slice(0, 300)}...` : data
1009
+ state.eventStatus = t.eventError
1010
+ render()
1011
+ })
1012
+
1013
+ source.onerror = () => {
1014
+ state.eventStatus = t.reconnecting
1015
+ render()
1016
+ }
1017
+
1018
+ return () => {
1019
+ source.close()
1020
+ }
1021
+ }
1022
+
1023
+ void (async () => {
1024
+ render()
1025
+ await refreshAll()
1026
+ const stop = subscribeEvents()
1027
+ window.addEventListener("beforeunload", () => {
1028
+ stop()
1029
+ })
1030
+ })()