@firstpick/pi-package-webui 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/index.html CHANGED
@@ -12,7 +12,7 @@
12
12
  <link rel="manifest" href="/manifest.webmanifest" />
13
13
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
14
14
  <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
15
- <link rel="stylesheet" href="/styles.css?v=56" />
15
+ <link rel="stylesheet" href="/styles.css?v=59" />
16
16
  </head>
17
17
  <body>
18
18
  <button id="sidePanelExpandButton" class="side-panel-expand-button" type="button" aria-controls="sidePanel" aria-expanded="false" aria-label="Expand side panel" title="Expand side panel">
@@ -740,6 +740,6 @@
740
740
  </form>
741
741
  </dialog>
742
742
 
743
- <script type="module" src="/app.js?v=55"></script>
743
+ <script type="module" src="/app.js?v=57"></script>
744
744
  </body>
745
745
  </html>
package/public/styles.css CHANGED
@@ -74,7 +74,10 @@
74
74
 
75
75
  * { box-sizing: border-box; }
76
76
  html, body { height: 100%; min-height: 100%; }
77
- html { overflow-x: hidden; }
77
+ html {
78
+ overflow: hidden;
79
+ scrollbar-gutter: auto;
80
+ }
78
81
  body {
79
82
  margin: 0;
80
83
  min-height: 0;
@@ -2187,6 +2190,10 @@ button.footer-meta {
2187
2190
  max-height: none;
2188
2191
  overflow: visible;
2189
2192
  }
2193
+ .footer-model-picker.footer-branch-picker {
2194
+ overflow: visible;
2195
+ max-height: none;
2196
+ }
2190
2197
  .footer-branch-picker {
2191
2198
  width: min(28rem, calc(100vw - 2rem));
2192
2199
  border-color: rgba(245, 194, 231, 0.30);
@@ -2210,6 +2217,35 @@ button.footer-meta {
2210
2217
  border-color: rgba(166, 227, 161, 0.46);
2211
2218
  box-shadow: inset 3px 0 0 var(--ctp-green), 0 0 1rem rgba(166, 227, 161, 0.14);
2212
2219
  }
2220
+ .footer-model-option[data-footer-model-key] {
2221
+ position: relative;
2222
+ cursor: grab;
2223
+ touch-action: none;
2224
+ user-select: none;
2225
+ }
2226
+ .footer-model-option.dragging {
2227
+ cursor: grabbing;
2228
+ border-color: rgba(148, 226, 213, 0.72);
2229
+ box-shadow: inset 0 0 0 1px rgba(148, 226, 213, 0.46), inset 4px 0 0 var(--ctp-teal), 0 0 1.1rem rgba(148, 226, 213, 0.24);
2230
+ }
2231
+ .footer-model-option.drag-over-before::before,
2232
+ .footer-model-option.drag-over-after::after {
2233
+ content: "";
2234
+ position: absolute;
2235
+ left: 0.62rem;
2236
+ right: 0.62rem;
2237
+ height: 2px;
2238
+ border-radius: 999px;
2239
+ background: var(--ctp-teal);
2240
+ box-shadow: 0 0 0.7rem rgba(148, 226, 213, 0.72);
2241
+ pointer-events: none;
2242
+ }
2243
+ .footer-model-option.drag-over-before::before {
2244
+ top: -0.28rem;
2245
+ }
2246
+ .footer-model-option.drag-over-after::after {
2247
+ bottom: -0.28rem;
2248
+ }
2213
2249
  .footer-branch-option.active {
2214
2250
  border-color: rgba(245, 194, 231, 0.36);
2215
2251
  box-shadow: inset 3px 0 0 var(--ctp-pink), 0 0 1rem rgba(245, 194, 231, 0.12);
@@ -2225,6 +2261,192 @@ button.footer-meta {
2225
2261
  .footer-branch-create-option .footer-model-option-main {
2226
2262
  color: var(--ctp-green);
2227
2263
  }
2264
+ .footer-branch-create-form {
2265
+ display: grid;
2266
+ gap: 0.44rem;
2267
+ padding: 0.6rem 0.64rem;
2268
+ border: 1px solid rgba(166, 227, 161, 0.28);
2269
+ border-radius: 0.78rem;
2270
+ background: rgba(166, 227, 161, 0.06);
2271
+ box-shadow: inset 3px 0 0 rgba(166, 227, 161, 0.72);
2272
+ }
2273
+ .footer-branch-create-header {
2274
+ display: flex;
2275
+ align-items: center;
2276
+ justify-content: space-between;
2277
+ gap: 0.6rem;
2278
+ }
2279
+ .footer-branch-create-title {
2280
+ color: var(--ctp-green);
2281
+ font-size: 0.78rem;
2282
+ font-weight: 900;
2283
+ }
2284
+ .footer-branch-create-preview {
2285
+ color: rgba(var(--ctp-subtext-rgb), 0.72);
2286
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
2287
+ font-size: 0.7rem;
2288
+ }
2289
+ .footer-branch-create-fields {
2290
+ display: grid;
2291
+ grid-template-columns: minmax(5.4rem, 0.34fr) auto minmax(0, 1fr) auto;
2292
+ align-items: center;
2293
+ gap: 0.4rem;
2294
+ }
2295
+ .footer-branch-create-type-field {
2296
+ position: relative;
2297
+ min-width: 0;
2298
+ }
2299
+ .footer-branch-create-type-field::after {
2300
+ content: "▾";
2301
+ position: absolute;
2302
+ top: 50%;
2303
+ right: 0.5rem;
2304
+ transform: translateY(-50%);
2305
+ color: var(--ctp-green);
2306
+ font-size: 0.72rem;
2307
+ pointer-events: none;
2308
+ opacity: 0.86;
2309
+ }
2310
+ .footer-branch-create-dropdown-inputfield,
2311
+ .footer-branch-create-input-field {
2312
+ min-width: 0;
2313
+ min-height: 2.28rem;
2314
+ border: 1px solid rgba(var(--ctp-overlay-rgb), 0.38);
2315
+ border-radius: 0.62rem;
2316
+ background: rgba(var(--ctp-crust-rgb), 0.56);
2317
+ color: var(--ctp-text);
2318
+ font: inherit;
2319
+ }
2320
+ .footer-branch-create-dropdown-inputfield {
2321
+ width: 100%;
2322
+ padding: 0 1.35rem 0 0.48rem;
2323
+ }
2324
+ .footer-branch-create-slash {
2325
+ color: var(--ctp-green);
2326
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
2327
+ font-size: 1rem;
2328
+ font-weight: 900;
2329
+ opacity: 0.9;
2330
+ }
2331
+ .footer-branch-type-suggestions {
2332
+ position: absolute;
2333
+ left: 0;
2334
+ top: calc(100% + 0.26rem);
2335
+ z-index: 60;
2336
+ display: grid;
2337
+ grid-template-columns: repeat(3, minmax(0, 1fr));
2338
+ gap: 0.18rem;
2339
+ width: min(20rem, calc(100vw - 2rem));
2340
+ max-height: none;
2341
+ overflow: visible;
2342
+ padding: 0.32rem;
2343
+ border: 1px solid rgba(166, 227, 161, 0.32);
2344
+ border-radius: 0.66rem;
2345
+ background: linear-gradient(145deg, rgba(var(--ctp-crust-rgb), 0.99), rgba(var(--ctp-base-rgb), 0.98));
2346
+ box-shadow: 0 0.9rem 1.8rem rgba(var(--ctp-crust-rgb), 0.58), 0 0 1rem rgba(166, 227, 161, 0.12);
2347
+ }
2348
+ .footer-branch-type-suggestions[hidden] {
2349
+ display: none !important;
2350
+ }
2351
+ .footer-branch-type-suggestion {
2352
+ min-height: 1.85rem;
2353
+ padding: 0.28rem 0.48rem;
2354
+ border-color: rgba(var(--ctp-overlay-rgb), 0.22);
2355
+ border-radius: 0.48rem;
2356
+ color: var(--ctp-text);
2357
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
2358
+ font-size: 0.76rem;
2359
+ text-align: left;
2360
+ }
2361
+ .footer-branch-type-suggestion:hover,
2362
+ .footer-branch-type-suggestion:focus,
2363
+ .footer-branch-type-suggestion.active {
2364
+ border-color: rgba(166, 227, 161, 0.48);
2365
+ color: var(--ctp-green);
2366
+ background: rgba(166, 227, 161, 0.10);
2367
+ }
2368
+ .footer-branch-create-input-field {
2369
+ padding: 0 0.68rem;
2370
+ }
2371
+ .footer-branch-create-submit {
2372
+ position: relative;
2373
+ min-height: 2.28rem;
2374
+ padding: 0 0.72rem;
2375
+ border-color: rgba(166, 227, 161, 0.42);
2376
+ color: var(--ctp-green);
2377
+ white-space: nowrap;
2378
+ }
2379
+ .footer-branch-create-submit-disabled {
2380
+ cursor: not-allowed;
2381
+ border-color: rgba(166, 227, 161, 0.24);
2382
+ color: rgba(166, 227, 161, 0.58);
2383
+ background: rgba(var(--ctp-crust-rgb), 0.42);
2384
+ }
2385
+ .footer-branch-create-submit[data-tooltip]::before,
2386
+ .footer-branch-create-submit[data-tooltip]::after {
2387
+ position: absolute;
2388
+ z-index: 80;
2389
+ opacity: 0;
2390
+ pointer-events: none;
2391
+ transition: opacity 0.14s ease, transform 0.14s ease;
2392
+ }
2393
+ .footer-branch-create-submit[data-tooltip]::before {
2394
+ content: "";
2395
+ right: 1.2rem;
2396
+ bottom: calc(100% + 0.38rem);
2397
+ width: 0.72rem;
2398
+ height: 0.72rem;
2399
+ border: 1px solid rgba(166, 227, 161, 0.26);
2400
+ border-top: 0;
2401
+ border-left: 0;
2402
+ background: rgba(var(--ctp-crust-rgb), 0.99);
2403
+ transform: translateY(0.24rem) rotate(45deg);
2404
+ }
2405
+ .footer-branch-create-submit[data-tooltip]::after {
2406
+ content: attr(data-tooltip);
2407
+ right: 0;
2408
+ bottom: calc(100% + 0.7rem);
2409
+ width: min(24rem, calc(100vw - 2.4rem));
2410
+ padding: 0.72rem 0.78rem;
2411
+ border: 1px solid rgba(166, 227, 161, 0.30);
2412
+ border-radius: 0.78rem;
2413
+ background:
2414
+ radial-gradient(circle at 10% 0%, rgba(166, 227, 161, 0.13), transparent 10rem),
2415
+ linear-gradient(145deg, rgba(var(--ctp-crust-rgb), 0.99), rgba(var(--ctp-base-rgb), 0.98));
2416
+ box-shadow: 0 1rem 2rem rgba(var(--ctp-crust-rgb), 0.62), 0 0 1.2rem rgba(166, 227, 161, 0.14);
2417
+ color: var(--ctp-text);
2418
+ font-size: 0.76rem;
2419
+ font-weight: 650;
2420
+ line-height: 1.42;
2421
+ text-align: left;
2422
+ white-space: pre-wrap;
2423
+ overflow-wrap: anywhere;
2424
+ transform: translateY(0.24rem);
2425
+ }
2426
+ .footer-branch-create-submit[data-tooltip]:hover::before,
2427
+ .footer-branch-create-submit[data-tooltip]:hover::after,
2428
+ .footer-branch-create-submit[data-tooltip]:focus-visible::before,
2429
+ .footer-branch-create-submit[data-tooltip]:focus-visible::after {
2430
+ opacity: 1;
2431
+ transform: translateY(0);
2432
+ }
2433
+ .footer-branch-create-submit[data-tooltip]:hover::before,
2434
+ .footer-branch-create-submit[data-tooltip]:focus-visible::before {
2435
+ transform: translateY(0) rotate(45deg);
2436
+ }
2437
+ @media (max-width: 520px) {
2438
+ .footer-branch-create-fields {
2439
+ grid-template-columns: 1fr auto 1fr;
2440
+ }
2441
+ .footer-branch-type-suggestions {
2442
+ width: calc(100vw - 3rem);
2443
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2444
+ }
2445
+ .footer-branch-create-submit {
2446
+ grid-column: 1 / -1;
2447
+ width: 100%;
2448
+ }
2449
+ }
2228
2450
  .footer-model-option-main,
2229
2451
  .footer-model-option-name {
2230
2452
  min-width: 0;
@@ -3410,6 +3632,14 @@ button.git-workflow-step:hover:not(:disabled) {
3410
3632
  list-style: none;
3411
3633
  }
3412
3634
  .todo-widget-summary::-webkit-details-marker { display: none; }
3635
+ .todo-widget-goal {
3636
+ min-width: 0;
3637
+ color: rgba(var(--ctp-text-rgb), 0.92);
3638
+ font-size: 0.78rem;
3639
+ font-weight: 800;
3640
+ line-height: 1.3;
3641
+ overflow-wrap: anywhere;
3642
+ }
3413
3643
  .todo-widget-header {
3414
3644
  display: flex;
3415
3645
  align-items: center;
@@ -4126,6 +4356,9 @@ button.git-workflow-step:hover:not(:disabled) {
4126
4356
  position: relative;
4127
4357
  margin: 0.55rem 0;
4128
4358
  }
4359
+ .markdown-code-block.has-code-copy-action > .code-block {
4360
+ padding-top: 2.05rem;
4361
+ }
4129
4362
  .markdown-code-language {
4130
4363
  position: absolute;
4131
4364
  top: 0.42rem;
@@ -4138,10 +4371,82 @@ button.git-workflow-step:hover:not(:disabled) {
4138
4371
  letter-spacing: 0.08em;
4139
4372
  text-transform: uppercase;
4140
4373
  }
4374
+ .markdown-code-block.has-code-copy-action > .markdown-code-language {
4375
+ right: 4.65rem;
4376
+ }
4377
+ .markdown-code-copy-button {
4378
+ position: absolute;
4379
+ top: 0.36rem;
4380
+ right: 0.42rem;
4381
+ z-index: 2;
4382
+ min-width: 3.45rem;
4383
+ min-height: 1.52rem;
4384
+ padding: 0.16rem 0.45rem;
4385
+ border: 1px solid rgba(148, 226, 213, 0.28);
4386
+ border-radius: 999px;
4387
+ color: rgba(var(--ctp-text-rgb), 0.82);
4388
+ background: rgba(var(--ctp-crust-rgb), 0.78);
4389
+ box-shadow: 0 0 0.7rem rgba(var(--ctp-crust-rgb), 0.28);
4390
+ font-size: 0.68rem;
4391
+ font-weight: 900;
4392
+ letter-spacing: 0.05em;
4393
+ text-transform: uppercase;
4394
+ opacity: 0.78;
4395
+ }
4396
+ .markdown-code-copy-button:hover,
4397
+ .markdown-code-copy-button:focus-visible,
4398
+ .markdown-code-copy-button.copied {
4399
+ color: #11111b;
4400
+ border-color: transparent;
4401
+ background: linear-gradient(120deg, var(--ctp-teal), var(--ctp-blue));
4402
+ opacity: 1;
4403
+ }
4404
+ .markdown-code-copy-button.copied {
4405
+ background: linear-gradient(120deg, var(--ctp-green), var(--ctp-teal));
4406
+ }
4141
4407
  .markdown-code code {
4142
4408
  display: block;
4143
4409
  min-width: max-content;
4144
4410
  }
4411
+ .markdown-mermaid-block {
4412
+ overflow-x: auto;
4413
+ padding: 0.72rem;
4414
+ border: 1px solid rgba(148, 226, 213, 0.22);
4415
+ border-radius: 0.9rem;
4416
+ background:
4417
+ radial-gradient(circle at 0 0, rgba(148, 226, 213, 0.10), transparent 18rem),
4418
+ rgba(var(--ctp-crust-rgb), 0.58);
4419
+ box-shadow: inset 0 0 1.3rem rgba(var(--ctp-crust-rgb), 0.24), 0 0 1rem rgba(148, 226, 213, 0.06);
4420
+ }
4421
+ .markdown-mermaid-diagram {
4422
+ display: flex;
4423
+ align-items: center;
4424
+ justify-content: center;
4425
+ min-width: min-content;
4426
+ min-height: 3.5rem;
4427
+ padding: 0.95rem 0.35rem 0.35rem;
4428
+ }
4429
+ .markdown-mermaid-diagram svg {
4430
+ display: block;
4431
+ max-width: 100%;
4432
+ height: auto;
4433
+ }
4434
+ .markdown-mermaid-status {
4435
+ margin: 0.35rem 0 0;
4436
+ font-size: 0.82rem;
4437
+ }
4438
+ .markdown-mermaid-status.error {
4439
+ color: var(--danger);
4440
+ }
4441
+ .markdown-mermaid-source {
4442
+ margin: 0.55rem 0 0;
4443
+ padding: 0.5rem;
4444
+ border-color: rgba(249, 226, 175, 0.18);
4445
+ background: rgba(var(--ctp-crust-rgb), 0.50);
4446
+ }
4447
+ .markdown-mermaid-source .code-block {
4448
+ margin-bottom: 0;
4449
+ }
4145
4450
  .markdown-list {
4146
4451
  margin: 0.45rem 0 0.55rem 1.2rem;
4147
4452
  padding-left: 1rem;
@@ -7070,6 +7375,7 @@ button.composer-skill-tag:focus-visible {
7070
7375
  padding-bottom: 0.42rem;
7071
7376
  }
7072
7377
  .todo-widget-summary { gap: 0.24rem; }
7378
+ .todo-widget-goal { font-size: 0.72rem; }
7073
7379
  .todo-widget-header { gap: 0.26rem; }
7074
7380
  .todo-widget-toggle {
7075
7381
  width: 0.9rem;
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { spawn, spawnSync } from "node:child_process";
3
- import { chmod, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
3
+ import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { networkInterfaces, tmpdir } from "node:os";
5
5
  import path from "node:path";
6
6
  import { setTimeout as delay } from "node:timers/promises";
@@ -125,6 +125,20 @@ try {
125
125
  assert.equal(gzipResponse.headers.get("content-encoding"), "gzip", "styles.css should fall back to gzip");
126
126
  await gzipResponse.arrayBuffer();
127
127
 
128
+ const mermaidModuleResponse = await fetch(`http://127.0.0.1:${port}/vendor/mermaid/mermaid.esm.min.mjs`, {
129
+ signal: AbortSignal.timeout(5_000),
130
+ });
131
+ assert.equal(mermaidModuleResponse.status, 200, "Mermaid ESM module should be served from the vendored dependency path");
132
+ assert.match(mermaidModuleResponse.headers.get("content-type") || "", /text\/javascript/, "Mermaid ESM module should use a JavaScript MIME type");
133
+ const mermaidModuleText = await mermaidModuleResponse.text();
134
+ const mermaidChunkPath = mermaidModuleText.match(/\.\/(chunks\/mermaid\.esm\.min\/[A-Za-z0-9._-]+\.mjs)/)?.[1];
135
+ assert.ok(mermaidChunkPath, "Mermaid ESM module should reference same-directory chunks");
136
+ const mermaidChunkResponse = await fetch(`http://127.0.0.1:${port}/vendor/mermaid/${mermaidChunkPath}`, {
137
+ signal: AbortSignal.timeout(5_000),
138
+ });
139
+ assert.equal(mermaidChunkResponse.status, 200, "Mermaid ESM chunks should be served for dynamic imports");
140
+ assert.equal(await mermaidChunkResponse.text(), await readFile(join(root, "node_modules", "mermaid", "dist", mermaidChunkPath), "utf8"), "served Mermaid chunks should match the dependency files");
141
+
128
142
  const tabsResponse = await request("127.0.0.1", "/api/tabs");
129
143
  assert.equal(tabsResponse.status, 200);
130
144
  const tabList = tabsResponse.body?.data?.tabs || tabsResponse.body?.tabs || [];
@@ -31,7 +31,7 @@ const companionDependencies = {
31
31
  "@firstpick/pi-extension-safety-guard": "^0.2.3",
32
32
  "@firstpick/pi-extension-setup-skills": "^0.1.8",
33
33
  "@firstpick/pi-extension-stats": "^0.2.6",
34
- "@firstpick/pi-extension-todo-progress": "^0.2.4",
34
+ "@firstpick/pi-extension-todo-progress": "^0.2.5",
35
35
  "@firstpick/pi-extension-tools": "^0.1.6",
36
36
  "@firstpick/pi-prompts-git-pr": "^0.1.2",
37
37
  "@firstpick/pi-themes-bundle": "^0.1.4",
@@ -214,7 +214,7 @@ assert.match(app, /function handleRemoteWebuiStatus\(statusText\)[\s\S]*?opening
214
214
  assert.match(app, /case "confirm":[\s\S]*?if \(isRemoteWebuiQrPopupLoading\(\)\) closeRemoteWebuiQrPopup\(\)/, "blocking extension dialogs should close the QR loading popup before opening");
215
215
  assert.match(app, /function showRemoteWebuiQrPopup\(widgetKey, lines = \[\], request = \{\}\)[\s\S]*?widgetKey !== "pi-remote-webui"[\s\S]*?openRemoteWebuiQrPopup\(lines\)/, "remote WebUI QR widget events should open the QR popup");
216
216
  assert.match(app, /function mirrorRemoteWebuiWidgetToTranscript\(widgetKey, lines = \[\], request = \{\}\)[\s\S]*?widgetKey !== "pi-remote-webui"[\s\S]*?addTransientMessage\(\{ role: "extension", title: "\/remote"/, "remote WebUI QR widget events should still mirror into the active tab transcript");
217
- assert.match(app, /if \(widgetKey === "pi-remote-webui"\) \{[\s\S]*?widgets\.delete\(widgetKey\);[\s\S]*?showRemoteWebuiQrPopup\(widgetKey, request\.widgetLines, request\)/, "remote WebUI QR widget events should not render in the generic widget area");
217
+ assert.match(app, /if \(widgetKey === "pi-remote-webui"\) \{[\s\S]*?setWidgetForTab\(requestTabId, widgetKey, \{ \.\.\.request, widgetLines: undefined \}\);[\s\S]*?showRemoteWebuiQrPopup\(widgetKey, request\.widgetLines, request\)/, "remote WebUI QR widget events should not render in the generic widget area");
218
218
  assert.doesNotMatch(app, /function renderRemoteWebuiWidget/, "remote WebUI QR should not render through the generic widget renderer");
219
219
  assert.match(css, /\.message\.run-indicator-message \{[\s\S]*?border-color/, "active agent runs should render a visible transcript indicator card");
220
220
  assert.match(css, /\.message-copy-button \{[\s\S]*?position:\s*absolute/, "transcript messages should expose a top-right copy button");
@@ -250,6 +250,7 @@ assert.match(css, /\.side-panel-section:not\(\.collapsed\) \.side-panel-section-
250
250
  assert.match(css, /\.optional-feature-pill\.enabled/, "optional features should visually distinguish enabled state");
251
251
  assert.match(css, /\.todo-widget \{[\s\S]*?display:\s*grid/, "todo-progress widget should render as a styled checklist card");
252
252
  assert.match(css, /\.todo-widget-summary \{[\s\S]*?cursor:\s*pointer/, "todo-progress widget should expose a compact expandable summary");
253
+ assert.match(css, /\.todo-widget-goal \{[\s\S]*?overflow-wrap:\s*anywhere/, "todo-progress widget should show long goals above progress without layout overflow");
253
254
  assert.match(css, /\.todo-widget-body \{[\s\S]*?max-height:/, "expanded todo-progress details should be height-limited");
254
255
  assert.match(css, /\.todo-widget-item\.partial \.todo-widget-marker/, "todo-progress partial items should have distinct styling");
255
256
  assert.match(css, /\.todo-widget-item\.done \.todo-widget-text[\s\S]*?text-decoration:\s*line-through/, "todo-progress completed items should be visually crossed out");
@@ -519,6 +520,12 @@ assert.match(app, /function messageCopyText\(message, body = null\)/, "frontend
519
520
  assert.match(app, /function attachMessageCopyButton\(bubble, message, body\)/, "frontend should add copy controls to rendered transcript cards");
520
521
  assert.match(app, /button\.append\(make\("span", "message-copy-icon", "⧉"\)\)/, "message copy buttons should render as icon-only controls");
521
522
  assert.match(app, /copyMessageBubble\(button\)/, "message copy buttons should copy through the shared clipboard helper");
523
+ assert.match(app, /function attachMarkdownCodeCopyButton\(wrapper, label = "Copy"\)/, "frontend should add copy controls to markdown code blocks");
524
+ assert.match(app, /function copyMarkdownCodeBlock\(button\)[\s\S]*?await copyText\(text\)/, "markdown code block copy controls should use the shared clipboard helper");
525
+ assert.match(app, /attachMarkdownCodeCopyButton\(wrapper\);/, "normal fenced code blocks should get copy buttons");
526
+ assert.match(app, /attachMarkdownCodeCopyButton\(wrapper, "Copy source"\);/, "rendered Mermaid blocks should expose source copy buttons");
527
+ assert.match(css, /\.markdown-code-copy-button \{[\s\S]*?position:\s*absolute/, "markdown code blocks should expose positioned copy buttons");
528
+ assert.match(css, /\.markdown-code-block\.has-code-copy-action > \.code-block \{[\s\S]*?padding-top:/, "code block copy buttons should reserve vertical space above source text");
522
529
  assert.match(app, /retryServerConnectionButton.*retryServerConnection/s, "backend-offline recovery panel should wire a retry action");
523
530
  assert.match(app, /function isChatNearBottom\(/, "chat should detect whether the user is reading above the bottom");
524
531
  assert.match(app, /function scheduleChatFollowScroll\(/, "chat auto-follow should retry after layout settles during fast streaming");
@@ -545,7 +552,12 @@ assert.match(app, /case "webui_extension_ui_cancelled":/, "frontend should close
545
552
  assert.match(app, /case "webui_extension_ui_resolved":[\s\S]*?removeQueuedDialogRequests\(\[event\.id\]\)/, "frontend should close dialogs resolved by another connected browser");
546
553
  assert.match(app, /if \(responseId && activeDialog && String\(activeDialog\.id \|\| ""\) !== responseId\) return;/, "dialog response cleanup should not close the next queued dialog after a resolve-event race");
547
554
  assert.match(app, /function parseTodoProgressWidget\(lines\)/, "todo-progress widgets should be parsed from extension widget lines");
555
+ assert.ok(app.includes("const goalLine = cleanLines.find((line) => /^Goal\\s*[::]/i.test(line));"), "todo-progress parser should preserve an optional Goal line from extension widget lines");
556
+ assert.ok(app.includes("if (todo.goal) summary.append(make(\"div\", \"todo-widget-goal\", `Goal: ${todo.goal}`));"), "todo-progress widget should display the goal above the progress header");
548
557
  assert.match(app, /const todoProgressWidgetExpandedByTab = new Map\(\)/, "todo-progress expansion state should survive widget re-renders per tab");
558
+ assert.match(app, /const todoProgressSignatureByTab = new Map\(\)/, "todo-progress should track per-tab signatures to avoid unchanged re-renders");
559
+ assert.match(app, /function widgetRequestEquivalent\(a, b\)[\s\S]*?return a\.widgetLines\.every/, "todo-progress and generic widgets should no-op identical widget payloads");
560
+ assert.match(app, /todoProgressSignatureByTab\.get\(tabId\) === signature\) return false/, "live todo-progress sync should skip unchanged checklist signatures");
549
561
  assert.match(app, /const node = make\("details", "widget todo-widget"\)/, "todo-progress widget should render collapsed by default as expandable details");
550
562
  assert.match(app, /Optional feature detection intentionally checks loaded Pi capabilities/, "optional Web UI features should be detected through loaded capabilities, not package folders");
551
563
  assert.match(app, /function resetOptionalFeatureAvailability\(\)/, "optional feature state should reset across active-tab and reload boundaries");
@@ -599,8 +611,22 @@ assert.match(app, /function insertChangedFilePathReference\(path\)[\s\S]*formatP
599
611
  assert.match(app, /function renderGitFooterPayloadMeta\(chip, tab\)[\s\S]*options\.title = gitFooterPayloadTooltip\(chip, \{ action \}\)[\s\S]*footerMeta\(chip\.label, chip\.value, footerMetaClassForPayload\(chip\), options\)[\s\S]*applyFooterChangedFilesDropdown\(node, chip\)/, "git footer meta payload chips should render as styled metadata with explanatory tooltips and changes popovers");
600
612
  assert.match(app, /chip\.key === "git"[\s\S]*setFooterBranchPickerOpen\(!footerBranchPickerOpen\)[\s\S]*Click to switch to another local branch/, "git branch footer chip should open the branch picker");
601
613
  assert.match(app, /function renderFooterBranchPicker\(\)[\s\S]*Git branches[\s\S]*applyFooterGitBranch\(branch\.name\)/, "git branch picker should render available branches and switch on selection");
602
- assert.match(app, /Create new branch[\s\S]*createFooterGitBranch\(\)/, "git branch picker should offer branch creation when no other branches are available");
603
- assert.match(app, /function promptFooterGitBranchName\(\)[\s\S]*window\.prompt\("New git branch name:"[\s\S]*function createFooterGitBranch\(\)[\s\S]*confirmFooterGitBranchAction\(branchName, \{ create: true, requireConfirm: true/, "new branch creation should prompt for a branch name and require confirmation before creating it");
614
+ assert.match(app, /function renderFooterBranchPicker\(\)[\s\S]*renderFooterBranchCreateForm\(state\)[\s\S]*for \(const branch of state\.branches\)/, "git branch picker should always offer inline branch creation before local branch choices");
615
+ assert.match(app, /GIT_BRANCH_TYPE_SUGGESTIONS = \["feat", "fix", "change", "perf", "test", "chore", "refactor", "docs", "style", "build", "ci", "revert"\]/, "new branch creation should reuse the conventional type suggestions from /git-staged-msg");
616
+ assert.match(app, /function renderFooterBranchCreateForm\(state = footerBranchPickerState\)[\s\S]*footer-branch-create-dropdown-inputfield[\s\S]*footer-branch-type-suggestions[\s\S]*footer-branch-create-input-field[\s\S]*Create new branch/, "new branch creation should use a styled editable suggestions dropdown plus input field instead of a browser prompt");
617
+ assert.match(app, /let footerBranchCreateDraft = \{ type: "", name: "" \}/, "new branch creation should not default the editable type prefix to feat");
618
+ assert.match(app, /function footerBranchCreateType\(value = footerBranchCreateDraft\.type\) \{\n\s+return slugifyGitBranchPart\(value\);\n\}/, "branch type suggestions should not restrict or default custom user-entered prefixes");
619
+ assert.match(app, /const slash = make\("span", "footer-branch-create-slash", "\/"\)/, "branch creation should visibly separate the prefix and user input with a slash");
620
+ assert.match(app, /function gitSwitchCreateCommandDisplay\(branch\)[\s\S]*`git switch -c \$\{quoteGitBranchForDisplay\(branch\)\}`[\s\S]*preview\.textContent = gitSwitchCreateCommandDisplay\(branchName \|\| footerBranchCreatePreviewName\(\)\)/, "branch creation should show the live complete quoted git switch -c command");
621
+ assert.match(app, /function footerBranchCreateTooltip\(branchName = footerBranchCreateName\(\)\)[\s\S]*A branch is a safe workspace for your changes\.[\s\S]*does not commit, push, or delete anything[\s\S]*Tip: use short lowercase words/, "create branch button should have a detailed non-technical explanatory tooltip");
622
+ assert.match(app, /submitButton\.dataset\.tooltip = footerBranchCreateTooltip\(branchName\);[\s\S]*submitButton\.removeAttribute\("title"\)/, "create branch button should use the styled tooltip instead of a native title tooltip");
623
+ assert.match(css, /\.footer-branch-create-submit\[data-tooltip\]::after \{[\s\S]*?content:\s*attr\(data-tooltip\);[\s\S]*?white-space:\s*pre-wrap;[\s\S]*?overflow-wrap:\s*anywhere;/, "create branch button tooltip should be styled and support detailed multiline copy");
624
+ assert.match(app, /submitButton\.disabled = false;[\s\S]*submitButton\.classList\.toggle\("footer-branch-create-submit-disabled", submitDisabled\);[\s\S]*submitButton\.setAttribute\("aria-disabled", submitDisabled \? "true" : "false"\)/, "branch creation should use aria-disabled styling so the tooltip is not dimmed by disabled button opacity");
625
+ assert.match(css, /\.footer-branch-create-submit-disabled \{[\s\S]*?cursor:\s*not-allowed;[\s\S]*?color:\s*rgba\(166, 227, 161, 0\.58\);/, "greyed branch create button should use a class instead of disabled opacity");
626
+ assert.match(app, /Loading existing local branches… New branch creation is available\./, "branch picker loading copy should make clear that creation is still available");
627
+ assert.match(css, /\.footer-model-picker\.footer-branch-picker \{[\s\S]*?overflow:\s*visible;[\s\S]*?max-height:\s*none;/, "branch picker should not force scrolling just to see the open type suggestions dropdown");
628
+ assert.match(css, /\.footer-branch-type-suggestions \{[\s\S]*?grid-template-columns:\s*repeat\(3,[\s\S]*?max-height:\s*none;[\s\S]*?overflow:\s*visible;/, "branch type suggestions should render as a styled multi-column dropdown without internal scrolling");
629
+ assert.match(app, /async function createFooterGitBranch\(branch = footerBranchCreateName\(\)\)[\s\S]*confirmFooterGitBranchAction\(branchName, \{ create: true, requireConfirm: true/, "new branch creation should require confirmation before running git switch -c");
604
630
  assert.match(app, /function footerBranchAgentWarningLines[\s\S]*WARNING:[\s\S]*still running or waiting for input in this Git working tree/, "branch create/switch confirmation should warn when an agent is active in the current git working tree");
605
631
  assert.match(app, /if \(footerBranchPickerOpen\) elements\.statusBar\.append\(renderFooterBranchPicker\(\)\)/, "footer should append the branch picker above the status bar when open");
606
632
  assert.match(server, /url\.pathname === "\/api\/git-branches"[\s\S]*readGitBranches\(tab\.cwd\)/, "server should expose local git branch listing for the footer picker");
@@ -765,26 +791,32 @@ assert.match(app, /if \(isThinkingPart\) \{[\s\S]*?visibleThinkingText\(assistan
765
791
  assert.match(app, /message\.role === "thinking"[\s\S]*?visibleThinkingText\(message\.thinking \|\| textFromContent\(message\.content\)\)[\s\S]*?if \(thinkingOutputVisible && thinkingText\) appendText\(body, thinkingText, "thinking-text"\);/, "thinking cards should suppress empty and provider no-thinking placeholder output");
766
792
  assert.match(app, /function showStreamingThinking\(initialText = ""\)[\s\S]*?if \(initialText && !streamThinking\.textContent\) streamThinking\.textContent = initialText;/, "live thinking should not create a visible placeholder card before content arrives");
767
793
  assert.match(app, /function setStreamingThinkingText\(text\)[\s\S]*?const thinking = visibleThinkingText\(text\);[\s\S]*?if \(!thinkingOutputVisible \|\| !thinking\) return false;[\s\S]*?return true;/, "live thinking text setters should ignore empty text instead of clearing or flashing the card");
768
- assert.match(app, /function syncStreamingThinkingFromMessage\(event[\s\S]*?return setStreamingThinkingText\(text \|\| placeholder\);/, "partial-message thinking sync should only report success after setting visible thinking text");
794
+ assert.match(app, /function syncStreamingThinkingFromUpdate\(event, update, \{ placeholder = "" \} = \{\}\)[\s\S]*?return setStreamingThinkingText\(streamThinkingRawText \|\| placeholder\);/, "incremental thinking sync should only report success after setting visible thinking text");
769
795
  assert.doesNotMatch(app, /text \|\| placeholder \|\| streamThinkingBubble/, "partial-message thinking sync should not clear an existing thinking card when a partial carries no visible thinking text");
770
796
  assert.match(app, /if \(thinkingOutputVisible && delta && \(!synced \|\| !streamThinking\?\.textContent\)\) \{/, "live thinking delta fallback should require visible delta text before creating a card");
771
797
  assert.match(app, /function thinkingDeltaText\(update\) \{[\s\S]*?return visibleThinkingText\(update\.delta \|\| update\.thinking \|\| update\.content \|\| ""\);/, "live thinking deltas should suppress provider no-thinking placeholders too");
772
798
  assert.match(app, /const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible"/, "thinking visibility should persist in browser storage");
773
799
  assert.match(app, /function setThinkingOutputVisible\(visible[\s\S]*renderAllMessages\(\{ preserveScroll: true \}\)/, "thinking visibility changes should immediately re-render the transcript");
774
800
  assert.match(app, /function assistantStreamingMessage\(event\)/, "live streaming should read the authoritative partial assistant message from RPC events like the TUI");
775
- assert.match(app, /assistantThinkingTextFromMessage\(assistantStreamingMessage\(event\), \{ streaming: true \}\) \|\| thinkingDeltaText\(update\)/, "live thinking end should replace deltas with the final partial-message thinking content");
776
- assert.match(app, /if \(typeof partialText === "string"\) streamRawText = partialText;/, "live assistant text should synchronize from partial messages instead of relying only on deltas");
801
+ assert.match(app, /function syncStreamingThinkingFromUpdate\(event, update[\s\S]*?const fallback = streamingThinkingTextFallback\(event\);[\s\S]*?setStreamThinkingRawText\(fallback\);[\s\S]*?return setStreamingThinkingText\(streamThinkingRawText \|\| placeholder\);/, "live thinking end should replace deltas with the final partial-message thinking content");
802
+ assert.match(app, /function setStreamRawText\(text\)[\s\S]*?streamRawText = nextText;[\s\S]*?resetStreamDerivedTextCache\(\);/, "live assistant text should synchronize from partial messages through a cache-aware setter");
777
803
  assert.match(app, /const TODO_PROGRESS_LINE_REGEX = /, "frontend should recognize live todo progress lines that will be moved into the todo widget");
778
804
  assert.match(app, /function stripTodoProgressLines\(text, \{ streaming = false \} = \{\}\)/, "live Assistant output should strip todo-progress lines before rendering final-output text");
779
- assert.match(app, /function renderStreamingAssistantText\(\)[\s\S]*?const assistantText = stripTodoProgressLines\(streamRawText, \{ streaming: true \}\)/, "streamed Assistant text should classify from accumulated output without flashing partial todo-progress lines");
780
- assert.match(app, /function syncStreamingThinkingFormat\(assistantText\)[\s\S]*?splitThinkingFormatText\(assistantText, \{ streaming: true \}\)[\s\S]*?setStreamingThinkingText\(thinking\)/, "tagged <think> streaming output should update the live thinking card instead of flashing raw tags");
781
- assert.match(app, /const finalText = thinkingFormat\?\.hasThinkingFormat \? stripTodoProgressLines\(thinkingFormat\.finalText, \{ streaming: true \}\) : assistantText;/, "tagged <think> streaming output should render only final response text in the Assistant card");
805
+ assert.match(app, /function syncLiveTodoProgressWidgetFromText\(text, tabId = activeTabId\)/, "live Assistant checklist text should update the todo-progress widget before tool execution events");
806
+ assert.match(app, /scheduleLiveTodoProgressWidgetSync\(streamRawText, event\.tabId \|\| activeTabId\)/, "streaming assistant text should feed the live todo-progress widget through the coalesced sync scheduler");
807
+ assert.match(app, /function renderStreamingAssistantText\(\)[\s\S]*?const thinkingFormat = syncStreamingThinkingFormat\(\);[\s\S]*?const finalText = thinkingFormat\?\.hasThinkingFormat \? streamDerivedText\(\)\.finalText : streamRenderableAssistantText\(\);/, "streamed Assistant text should render cached derived output without directly rescanning raw stream text");
808
+ assert.match(app, /function syncStreamingThinkingFormat\(\)[\s\S]*?const parsed = streamDerivedText\(\)\.thinkingFormat;[\s\S]*?setStreamingThinkingText\(thinking\)/, "tagged <think> streaming output should update the live thinking card from cached parse state instead of flashing raw tags");
809
+ assert.match(app, /const finalText = thinkingFormat\?\.hasThinkingFormat \? streamDerivedText\(\)\.finalText : streamRenderableAssistantText\(\);/, "tagged <think> streaming output should render only final response text in the Assistant card");
782
810
  assert.match(app, /const STREAM_OUTPUT_HIDE_DELAY_MS = 300/, "stream output hiding should be debounced to prevent rapid flicker");
783
811
  assert.match(app, /const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220/, "live assistant text should be briefly guarded so pre-tool-call text can be suppressed");
784
812
  assert.match(app, /function scheduleStreamBubbleHide\([\s\S]*?STREAM_OUTPUT_MIN_VISIBLE_MS/, "stream output cards should observe a minimum visible duration before hiding");
785
813
  assert.match(app, /if \(finalText\) \{[\s\S]*?renderStreamingMarkdown\(streamText, finalText\);[\s\S]*?\} else \{\n\s+scheduleStreamBubbleHide\(\);/, "empty filtered stream output should schedule hide while visible stream output renders as Markdown");
786
- assert.match(app, /if \(streamToolCallSeen \|\| streamBubble\) renderStreamingAssistantText\(\);\n\s+else scheduleStreamingAssistantTextRender\(\);/, "live assistant text should wait briefly before showing unless it is already visible or follows a tool call");
814
+ assert.match(app, /scheduleStreamingAssistantTextRender\(\{ immediate: !!\(streamToolCallSeen \|\| streamBubble\) \}\);/, "live assistant text should wait briefly before showing unless it is already visible or follows a tool call");
787
815
  assert.match(app, /streamToolCallSeen = true;\n\s+suppressStreamingAssistantTextBeforeToolCall\(\);/, "tool-call starts should remove pending assistant text from the live transcript");
816
+ assert.match(app, /function renderStreamingToolCallCard\(\{ scroll = false \} = \{\}\)[\s\S]*?appendMessage\(message, \{ streaming: true \}\)[\s\S]*?streamToolCallText\.textContent !== displayText/, "live tool-call cards should render and update the arguments stream in place");
817
+ assert.match(app, /update\.type === "toolcall_delta"[\s\S]*?updateStreamingToolCallFromEvent\(event, \{ appendDelta: true \}\)[\s\S]*?Building tool call:/, "tool-call deltas should update visible streamed arguments instead of a static placeholder");
818
+ assert.match(app, /case "tool_execution_start":[\s\S]*?removeStreamingToolCallCard\(\)[\s\S]*?handleToolExecutionStart\(event\)/, "the streamed tool-call argument card should be removed when the real tool execution card starts");
819
+ assert.doesNotMatch(app, /Preparing tool call:/, "tool-call streaming should no longer show only the static preparing placeholder");
788
820
  assert.match(app, /const created = appendMessage\(\{ role: "assistant", title: "final output"/, "live Assistant cards should be created only for final output text without a noisy Assistant label");
789
821
  assert.match(app, /function renderMarkdownInto\(parent, text\)/, "assistant output should have a browser-native Markdown renderer");
790
822
  assert.match(app, /safeMarkdownLinkHref\(url\)/, "Markdown links should be sanitized before rendering");
@@ -1104,13 +1136,16 @@ assert.match(server, /const EXTENSION_UI_BLOCKING_METHODS = new Set\(\["select",
1104
1136
  assert.match(server, /function trackPendingExtensionUiRequest\(tab, event\)/, "server should track blocking extension UI requests per tab");
1105
1137
  assert.match(server, /pendingExtensionUiRequests: new Map\(\)/, "new tabs should initialize pending extension UI request storage");
1106
1138
  assert.match(server, /extensionStatuses: new Map\(\)/, "new tabs should initialize replayable extension status storage");
1139
+ assert.match(server, /extensionWidgets: new Map\(\)/, "new tabs should initialize replayable extension widget storage");
1107
1140
  assert.match(server, /function rememberExtensionStatusEvent\(tab, event\)[\s\S]*event\.method !== "setStatus"[\s\S]*statuses\.set\(String\(event\.statusKey\), String\(event\.statusText\)\)/, "server should retain extension status events for reconnects");
1108
- assert.match(server, /rememberExtensionStatusEvent\(tab, scopedEvent\)[\s\S]*trackPendingExtensionUiRequest\(tab, scopedEvent\)/, "RPC events should retain extension statuses before broadcasting");
1141
+ assert.match(server, /function rememberExtensionWidgetEvent\(tab, event\)[\s\S]*event\.method !== "setWidget"[\s\S]*widgets\.set\(String\(widgetKey\)/, "server should retain extension widget events for reconnects");
1142
+ assert.match(server, /rememberExtensionStatusEvent\(tab, scopedEvent\)[\s\S]*rememberExtensionWidgetEvent\(tab, scopedEvent\)[\s\S]*trackPendingExtensionUiRequest\(tab, scopedEvent\)/, "RPC events should retain extension statuses and widgets before broadcasting");
1109
1143
  assert.match(server, /trackPendingExtensionUiRequest\(tab, scopedEvent\)/, "RPC events should populate pending extension UI storage before broadcasting");
1110
1144
  assert.match(server, /scopedEvent = \{ \.\.\.scopedEvent,[\s\S]*?pendingExtensionUiRequestCount: pendingExtensionUiRequests\(tab\)\.length \}/, "RPC events should broadcast pending blocker counts for tab indicators");
1111
1145
  assert.match(server, /function replayExtensionStatuses\(tab, res\)[\s\S]*method: "setStatus"/, "server should replay latest extension statuses on SSE reconnect");
1146
+ assert.match(server, /function replayExtensionWidgets\(tab, res\)[\s\S]*method: "setWidget"/, "server should replay latest extension widgets on SSE reconnect");
1112
1147
  assert.match(server, /function replayPendingExtensionUiRequests\(tab, res\)/, "server should be able to replay missed extension UI requests on SSE reconnect");
1113
- assert.match(server, /replayExtensionStatuses\(tab, res\);\n\s+replayPendingExtensionUiRequests\(tab, res\)/, "SSE connections should replay extension statuses before pending blockers");
1148
+ assert.match(server, /replayExtensionStatuses\(tab, res\);\n\s+replayExtensionWidgets\(tab, res\);\n\s+replayPendingExtensionUiRequests\(tab, res\)/, "SSE connections should replay extension statuses and widgets before pending blockers");
1114
1149
  assert.match(server, /pendingExtensionUiRequests: pendingExtensionUiRequestSummaries\(tab\)/, "detailed Web UI status should expose pending extension UI blockers");
1115
1150
  assert.match(server, /resolvePendingExtensionUiRequest\(tab, payload\.id\)/, "extension UI responses should clear the pending blocker cache");
1116
1151
  assert.match(server, /type: "webui_extension_ui_resolved"[\s\S]*?pendingExtensionUiRequestCount/, "extension UI responses should notify clients that a blocker resolved");
@@ -1313,7 +1348,7 @@ assert.match(app, /pruneDisconnectedLiveToolCards\(\);/, "reconciliation must pr
1313
1348
  // --- Performance: incremental streaming markdown (P0-3) ---
1314
1349
  assert.match(app, /function streamingMarkdownStableBoundary\(text\)[\s\S]*?for \(let index = 0; index < lines\.length - 1; index \+= 1\)/, "streaming markdown boundary must never treat the final partial line as stable");
1315
1350
  assert.match(app, /function renderStreamingMarkdown\(block, text\)[\s\S]*?if \(!text\.startsWith\(state\.stableText\)\)/, "streaming markdown must fall back to a full re-render when earlier content changes");
1316
- assert.match(app, /streamRawText = "";\n streamMarkdownState = null;/, "resetting the stream bubble must clear incremental markdown state");
1351
+ assert.match(app, /streamRawText = "";\n streamThinkingRawText = "";\n resetStreamDerivedTextCache\(\);\n streamMarkdownState = null;/, "resetting the stream bubble must clear incremental markdown state and derived caches");
1317
1352
 
1318
1353
  // --- Performance: delta transcript fetch (P1-1) ---
1319
1354
  assert.match(app, /function mergeMessagesDelta\(previous, data\)[\s\S]*?messagesLookEqual\(previous\[since\], data\.messages\[0\]\)/, "delta merges must verify the one-message overlap before applying");