@firstpick/pi-package-webui 0.3.3 → 0.3.5

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=35" />
15
+ <link rel="stylesheet" href="/styles.css?v=36" />
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">
@@ -85,6 +85,28 @@
85
85
  </section>
86
86
  <form id="composer" class="composer">
87
87
  <div class="composer-input-row">
88
+ <div class="composer-context-tags">
89
+ <button
90
+ id="busyPromptBehaviorTag"
91
+ class="composer-busy-mode-tag"
92
+ type="button"
93
+ aria-haspopup="menu"
94
+ aria-expanded="false"
95
+ aria-controls="busyPromptBehaviorMenu"
96
+ aria-live="polite"
97
+ >Follow-up</button>
98
+ <div id="sessionSkillTags" class="composer-skill-tags" aria-live="polite" hidden></div>
99
+ </div>
100
+ <div id="busyPromptBehaviorMenu" class="composer-busy-mode-menu" role="menu" aria-labelledby="busyPromptBehaviorTag" hidden>
101
+ <button class="composer-busy-mode-menu-item" type="button" role="menuitemradio" data-busy-prompt-behavior="followUp" aria-checked="true">
102
+ <span class="composer-busy-mode-menu-label">Follow-up</span>
103
+ <span class="composer-busy-mode-menu-description">Queue as the next prompt after this run.</span>
104
+ </button>
105
+ <button class="composer-busy-mode-menu-item" type="button" role="menuitemradio" data-busy-prompt-behavior="steer" aria-checked="false">
106
+ <span class="composer-busy-mode-menu-label">Steer</span>
107
+ <span class="composer-busy-mode-menu-description">Guide the active run immediately.</span>
108
+ </button>
109
+ </div>
88
110
  <textarea id="promptInput" rows="1" enterkeyhint="enter" placeholder="Ask Pi…" autofocus></textarea>
89
111
  <button
90
112
  id="attachButton"
@@ -294,6 +316,14 @@
294
316
  </span>
295
317
  </label>
296
318
  </div>
319
+ <div class="control-field terminal-tabs-layout-control-field">
320
+ <label for="terminalTabsLayoutSelect">Tabs layout</label>
321
+ <select id="terminalTabsLayoutSelect" title="Terminal tabs layout" aria-describedby="terminalTabsLayoutStatus">
322
+ <option value="top">Top bar</option>
323
+ <option value="left">Left sidebar</option>
324
+ </select>
325
+ <div id="terminalTabsLayoutStatus" class="terminal-tabs-layout-status toggle-control-hint">Top bar</div>
326
+ </div>
297
327
  <div class="control-field">
298
328
  <label for="themeSelect">Theme</label>
299
329
  <select id="themeSelect" title="Theme"></select>
@@ -500,6 +530,19 @@
500
530
  </form>
501
531
  </dialog>
502
532
 
533
+ <dialog id="skillEditorDialog" class="extension-dialog skill-editor-dialog">
534
+ <form method="dialog">
535
+ <h2 id="skillEditorTitle">Edit skill</h2>
536
+ <p id="skillEditorMeta" class="skill-editor-meta muted"></p>
537
+ <textarea id="skillEditorText" class="dialog-editor skill-editor-text" spellcheck="false" aria-label="Skill file contents"></textarea>
538
+ <p id="skillEditorStatus" class="skill-editor-status muted" role="status" aria-live="polite" hidden></p>
539
+ <menu>
540
+ <button id="skillEditorCancelButton" type="button">Cancel</button>
541
+ <button id="skillEditorSaveButton" class="primary" type="button">Save skill</button>
542
+ </menu>
543
+ </form>
544
+ </dialog>
545
+
503
546
  <dialog id="promptListDialog" class="extension-dialog prompt-list-dialog">
504
547
  <form method="dialog">
505
548
  <h2 id="promptListDialogTitle">Create prompt list</h2>
@@ -541,6 +584,6 @@
541
584
  </form>
542
585
  </dialog>
543
586
 
544
- <script type="module" src="/app.js?v=36"></script>
587
+ <script type="module" src="/app.js?v=37"></script>
545
588
  </body>
546
589
  </html>
package/public/styles.css CHANGED
@@ -1494,6 +1494,102 @@ body.side-panel-collapsed .terminal-tabs-shell {
1494
1494
  box-shadow: 0 0 1rem rgba(245, 194, 231, 0.20);
1495
1495
  }
1496
1496
 
1497
+ @media (min-width: 721px) {
1498
+ body.terminal-tabs-left .chat-panel {
1499
+ display: grid;
1500
+ grid-template-columns: clamp(13rem, 18vw, 19rem) minmax(0, 1fr);
1501
+ grid-template-rows: auto minmax(0, 1fr) auto auto auto auto auto;
1502
+ align-items: stretch;
1503
+ }
1504
+ body.terminal-tabs-left .terminal-tabs-shell {
1505
+ grid-column: 1;
1506
+ grid-row: 1 / -1;
1507
+ flex-direction: column;
1508
+ align-items: stretch;
1509
+ gap: 0.58rem;
1510
+ min-width: 0;
1511
+ min-height: 0;
1512
+ padding: 0.76rem;
1513
+ border-right: 1px solid rgba(180, 190, 254, 0.16);
1514
+ border-bottom: 0;
1515
+ background:
1516
+ linear-gradient(180deg, rgba(var(--ctp-crust-rgb), 0.96), rgba(var(--ctp-base-rgb), 0.82), rgba(var(--ctp-mantle-rgb), 0.92)),
1517
+ radial-gradient(circle at 0% 0%, rgba(245, 194, 231, 0.12), transparent 18rem);
1518
+ box-shadow: inset -1px 0 0 rgba(255,255,255,0.035), 0.45rem 0 1rem rgba(var(--ctp-crust-rgb), 0.18);
1519
+ }
1520
+ body.terminal-tabs-left.side-panel-collapsed .terminal-tabs-shell {
1521
+ padding-right: 0.76rem;
1522
+ }
1523
+ body.terminal-tabs-left .terminal-tabs {
1524
+ flex: 1 1 auto;
1525
+ flex-direction: column;
1526
+ align-items: stretch;
1527
+ min-height: 0;
1528
+ padding-right: 0.08rem;
1529
+ overflow-x: hidden;
1530
+ overflow-y: auto;
1531
+ }
1532
+ body.terminal-tabs-left .terminal-tabs.terminal-tabs-dense {
1533
+ flex-wrap: nowrap;
1534
+ max-height: none;
1535
+ overflow-x: hidden;
1536
+ overflow-y: auto;
1537
+ }
1538
+ body.terminal-tabs-left .terminal-tab,
1539
+ body.terminal-tabs-left .terminal-tabs.terminal-tabs-dense .terminal-tab {
1540
+ flex: 0 0 auto;
1541
+ width: 100%;
1542
+ min-width: 0;
1543
+ max-width: none;
1544
+ }
1545
+ body.terminal-tabs-left .terminal-tab-group-menu {
1546
+ --terminal-left-dropdown-bridge: 0.78rem;
1547
+ inset: 0 auto auto 100%;
1548
+ width: clamp(13rem, 18vw, 20rem);
1549
+ min-width: 13rem;
1550
+ max-width: min(22rem, calc(100vw - 2rem));
1551
+ padding-top: 0;
1552
+ padding-left: var(--terminal-left-dropdown-bridge);
1553
+ }
1554
+ body.terminal-tabs-left .terminal-new-tab-menu.composer-publish-menu {
1555
+ width: 100%;
1556
+ }
1557
+ body.terminal-tabs-left .terminal-new-tab-button,
1558
+ body.terminal-tabs-left .terminal-close-all-button {
1559
+ width: 100%;
1560
+ justify-content: flex-start;
1561
+ text-align: left;
1562
+ }
1563
+ body.terminal-tabs-left .terminal-new-tab-menu .composer-publish-menu-panel {
1564
+ --terminal-left-dropdown-bridge: 0.78rem;
1565
+ inset: 0 auto auto 100%;
1566
+ width: clamp(12rem, 16vw, 18rem);
1567
+ min-width: 12rem;
1568
+ padding-top: 0;
1569
+ padding-left: var(--terminal-left-dropdown-bridge);
1570
+ }
1571
+ body.terminal-tabs-left .terminal-close-all-button {
1572
+ margin-top: auto;
1573
+ }
1574
+ body.terminal-tabs-left .widget-area,
1575
+ body.terminal-tabs-left .chat,
1576
+ body.terminal-tabs-left .feedback-tray,
1577
+ body.terminal-tabs-left .jump-to-latest-button,
1578
+ body.terminal-tabs-left .statusbar,
1579
+ body.terminal-tabs-left .git-workflow-panel,
1580
+ body.terminal-tabs-left .composer {
1581
+ grid-column: 2;
1582
+ min-width: 0;
1583
+ }
1584
+ body.terminal-tabs-left .widget-area { grid-row: 1; }
1585
+ body.terminal-tabs-left .chat { grid-row: 2; }
1586
+ body.terminal-tabs-left .feedback-tray { grid-row: 3; }
1587
+ body.terminal-tabs-left .jump-to-latest-button { grid-row: 4; }
1588
+ body.terminal-tabs-left .statusbar { grid-row: 5; }
1589
+ body.terminal-tabs-left .git-workflow-panel { grid-row: 6; }
1590
+ body.terminal-tabs-left .composer { grid-row: 7; }
1591
+ }
1592
+
1497
1593
  .widget-area {
1498
1594
  flex: 0 0 auto;
1499
1595
  border-bottom: 1px solid rgba(180, 190, 254, 0.16);
@@ -3233,11 +3329,182 @@ summary { cursor: pointer; color: var(--warning); }
3233
3329
  opacity: 0.8;
3234
3330
  }
3235
3331
  .composer-input-row {
3332
+ position: relative;
3236
3333
  display: grid;
3237
3334
  grid-template-columns: minmax(0, 1fr) auto;
3238
3335
  gap: 0.55rem;
3239
3336
  align-items: stretch;
3240
3337
  }
3338
+ .composer-context-tags {
3339
+ position: absolute;
3340
+ top: -0.48rem;
3341
+ left: 0.75rem;
3342
+ z-index: 3;
3343
+ display: inline-flex;
3344
+ align-items: center;
3345
+ gap: 0.32rem;
3346
+ max-width: calc(100% - 4.5rem);
3347
+ }
3348
+ .composer-busy-mode-tag {
3349
+ position: relative;
3350
+ flex: 0 1 auto;
3351
+ display: inline-flex;
3352
+ align-items: center;
3353
+ max-width: min(12rem, 100%);
3354
+ overflow: hidden;
3355
+ min-width: 0;
3356
+ min-height: 0;
3357
+ margin: 0;
3358
+ padding: 0.14rem 0.52rem;
3359
+ border: 1px solid rgba(137, 180, 250, 0.34);
3360
+ border-radius: 999px;
3361
+ color: var(--ctp-blue);
3362
+ background:
3363
+ linear-gradient(120deg, rgba(137, 180, 250, 0.24), rgba(137, 180, 250, 0.08)),
3364
+ var(--ctp-crust);
3365
+ box-shadow: 0 0.42rem 1rem rgba(var(--ctp-crust-rgb), 0.42), 0 0 0.8rem rgba(137, 180, 250, 0.14);
3366
+ cursor: pointer;
3367
+ font-size: 0.62rem;
3368
+ font-weight: 900;
3369
+ letter-spacing: 0.08em;
3370
+ line-height: 1.1;
3371
+ text-overflow: ellipsis;
3372
+ text-transform: uppercase;
3373
+ white-space: nowrap;
3374
+ }
3375
+ .composer-busy-mode-tag:hover,
3376
+ .composer-busy-mode-tag:focus-visible,
3377
+ .composer-busy-mode-tag.menu-open {
3378
+ border-color: rgba(137, 180, 250, 0.62);
3379
+ box-shadow: 0 0.42rem 1rem rgba(var(--ctp-crust-rgb), 0.42), 0 0 1rem rgba(137, 180, 250, 0.24);
3380
+ transform: translateY(-1px);
3381
+ }
3382
+ .composer-busy-mode-tag.steer {
3383
+ color: var(--ctp-mauve);
3384
+ border-color: rgba(203, 166, 247, 0.38);
3385
+ background:
3386
+ linear-gradient(120deg, rgba(203, 166, 247, 0.26), rgba(203, 166, 247, 0.08)),
3387
+ var(--ctp-crust);
3388
+ box-shadow: 0 0.42rem 1rem rgba(var(--ctp-crust-rgb), 0.42), 0 0 0.8rem rgba(203, 166, 247, 0.16);
3389
+ }
3390
+ .composer-busy-mode-tag.steer:hover,
3391
+ .composer-busy-mode-tag.steer:focus-visible,
3392
+ .composer-busy-mode-tag.steer.menu-open {
3393
+ border-color: rgba(203, 166, 247, 0.66);
3394
+ box-shadow: 0 0.42rem 1rem rgba(var(--ctp-crust-rgb), 0.42), 0 0 1rem rgba(203, 166, 247, 0.26);
3395
+ }
3396
+ .composer-busy-mode-tag.follow-up {
3397
+ color: var(--ctp-blue);
3398
+ border-color: rgba(137, 180, 250, 0.38);
3399
+ }
3400
+ .composer-skill-tags {
3401
+ display: inline-flex;
3402
+ align-items: center;
3403
+ gap: 0.28rem;
3404
+ min-width: 0;
3405
+ overflow: hidden;
3406
+ }
3407
+ .composer-skill-tags[hidden] {
3408
+ display: none !important;
3409
+ }
3410
+ .composer-skill-tag {
3411
+ display: inline-flex;
3412
+ align-items: center;
3413
+ min-width: 0;
3414
+ min-height: 0;
3415
+ max-width: 9.4rem;
3416
+ overflow: hidden;
3417
+ padding: 0.14rem 0.44rem;
3418
+ border: 1px solid rgba(249, 226, 175, 0.34);
3419
+ border-radius: 999px;
3420
+ color: var(--ctp-yellow);
3421
+ background:
3422
+ linear-gradient(120deg, rgba(249, 226, 175, 0.24), rgba(166, 227, 161, 0.08)),
3423
+ var(--ctp-crust);
3424
+ box-shadow: 0 0.35rem 0.9rem rgba(var(--ctp-crust-rgb), 0.40), 0 0 0.6rem rgba(249, 226, 175, 0.14);
3425
+ font-size: 0.58rem;
3426
+ font-weight: 900;
3427
+ letter-spacing: 0.06em;
3428
+ line-height: 1.1;
3429
+ text-align: left;
3430
+ text-overflow: ellipsis;
3431
+ text-transform: uppercase;
3432
+ white-space: nowrap;
3433
+ }
3434
+ button.composer-skill-tag:hover,
3435
+ button.composer-skill-tag:focus-visible {
3436
+ border-color: rgba(148, 226, 213, 0.68);
3437
+ box-shadow: 0 0.35rem 0.9rem rgba(var(--ctp-crust-rgb), 0.40), 0 0 0.9rem rgba(148, 226, 213, 0.24);
3438
+ transform: translateY(-1px);
3439
+ }
3440
+ .composer-skill-tag.read {
3441
+ color: var(--ctp-teal);
3442
+ border-color: rgba(148, 226, 213, 0.36);
3443
+ }
3444
+ .composer-skill-tag.loaded {
3445
+ color: var(--ctp-green);
3446
+ border-color: rgba(166, 227, 161, 0.36);
3447
+ }
3448
+ .composer-skill-tag.overflow {
3449
+ color: var(--ctp-subtext0);
3450
+ border-color: rgba(166, 173, 200, 0.30);
3451
+ background: linear-gradient(120deg, rgba(166, 173, 200, 0.22), rgba(166, 173, 200, 0.08)), var(--ctp-crust);
3452
+ }
3453
+ .composer-busy-mode-menu {
3454
+ position: absolute;
3455
+ top: auto;
3456
+ bottom: calc(100% + 0.22rem);
3457
+ left: 0.75rem;
3458
+ z-index: 120;
3459
+ display: flex;
3460
+ flex-direction: column;
3461
+ gap: 0.34rem;
3462
+ width: min(18rem, calc(100% - 4.5rem));
3463
+ padding: 0.42rem;
3464
+ border: 1px solid rgba(137, 180, 250, 0.32);
3465
+ border-radius: 0.95rem;
3466
+ background: var(--ctp-crust);
3467
+ box-shadow: 0 0.9rem 2.2rem rgba(var(--ctp-crust-rgb), 0.72), 0 0 1rem rgba(137, 180, 250, 0.14), inset 0 1px 0 rgba(255,255,255,0.05);
3468
+ }
3469
+ .composer-busy-mode-menu[hidden] {
3470
+ display: none !important;
3471
+ }
3472
+ .composer-busy-mode-menu-item {
3473
+ display: grid;
3474
+ gap: 0.1rem;
3475
+ width: 100%;
3476
+ margin: 0;
3477
+ padding: 0.45rem 0.58rem;
3478
+ border-color: rgba(137, 180, 250, 0.24);
3479
+ border-radius: 0.72rem;
3480
+ color: var(--ctp-text);
3481
+ background:
3482
+ linear-gradient(120deg, rgba(137, 180, 250, 0.08), rgba(203, 166, 247, 0.06)),
3483
+ var(--ctp-mantle);
3484
+ text-align: left;
3485
+ }
3486
+ .composer-busy-mode-menu-item:hover,
3487
+ .composer-busy-mode-menu-item:focus-visible {
3488
+ border-color: rgba(148, 226, 213, 0.48);
3489
+ box-shadow: 0 0 0.8rem rgba(148, 226, 213, 0.14);
3490
+ transform: translateY(-1px);
3491
+ }
3492
+ .composer-busy-mode-menu-item[aria-checked="true"] {
3493
+ border-color: rgba(148, 226, 213, 0.52);
3494
+ background:
3495
+ linear-gradient(120deg, rgba(148, 226, 213, 0.18), rgba(137, 180, 250, 0.10)),
3496
+ var(--ctp-mantle);
3497
+ }
3498
+ .composer-busy-mode-menu-label {
3499
+ font-size: 0.76rem;
3500
+ font-weight: 900;
3501
+ letter-spacing: 0.04em;
3502
+ }
3503
+ .composer-busy-mode-menu-description {
3504
+ color: var(--muted);
3505
+ font-size: 0.68rem;
3506
+ line-height: 1.25;
3507
+ }
3241
3508
  .composer-icon-button {
3242
3509
  display: inline-flex;
3243
3510
  align-items: center;
@@ -4033,6 +4300,46 @@ summary { cursor: pointer; color: var(--warning); }
4033
4300
  font-size: 0.72rem;
4034
4301
  font-weight: 800;
4035
4302
  }
4303
+ .extension-dialog.skill-editor-dialog {
4304
+ --skill-editor-size: min(152rem, calc(100vw - 1.5rem), calc(var(--visual-viewport-height, 100dvh) - 1.5rem));
4305
+ width: var(--skill-editor-size);
4306
+ height: var(--skill-editor-size);
4307
+ max-width: calc(100vw - 1.5rem);
4308
+ max-height: calc(var(--visual-viewport-height, 100dvh) - 1.5rem);
4309
+ aspect-ratio: 1 / 1;
4310
+ }
4311
+ .skill-editor-dialog form {
4312
+ display: grid;
4313
+ grid-template-rows: auto auto minmax(0, 1fr) auto auto;
4314
+ gap: 0.78rem;
4315
+ height: 100%;
4316
+ min-height: 0;
4317
+ }
4318
+ .skill-editor-meta {
4319
+ margin: 0;
4320
+ overflow-wrap: anywhere;
4321
+ font-size: 0.82rem;
4322
+ }
4323
+ .skill-editor-text {
4324
+ min-height: 0;
4325
+ resize: none;
4326
+ overflow: auto;
4327
+ overflow-x: hidden;
4328
+ overflow-wrap: anywhere;
4329
+ word-break: break-word;
4330
+ font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
4331
+ font-size: 0.82rem;
4332
+ line-height: 1.45;
4333
+ white-space: pre-wrap;
4334
+ }
4335
+ .skill-editor-status {
4336
+ margin: 0;
4337
+ min-height: 1.2rem;
4338
+ font-size: 0.84rem;
4339
+ }
4340
+ .skill-editor-status.ok { color: var(--ctp-green); }
4341
+ .skill-editor-status.warn { color: var(--ctp-yellow); }
4342
+ .skill-editor-status.error { color: var(--ctp-red); }
4036
4343
  .prompt-list-dialog {
4037
4344
  width: min(58rem, calc(100vw - 2rem));
4038
4345
  }
@@ -61,6 +61,8 @@ assert.match(html, /id="agentDoneNotificationsToggle"/, "side panel should expos
61
61
  assert.match(html, /id="agentDoneNotificationsStatus"/, "agent-done notifications toggle should expose status text");
62
62
  assert.match(html, /id="thinkingVisibilityToggle"/, "side panel should expose a thinking-output visibility toggle");
63
63
  assert.match(html, /id="thinkingVisibilityStatus"/, "thinking-output visibility toggle should expose status text");
64
+ assert.match(html, /id="terminalTabsLayoutSelect"[\s\S]*<option value="left">Left sidebar<\/option>/, "side panel controls should expose a terminal-tabs layout selector");
65
+ assert.match(html, /id="terminalTabsLayoutStatus"/, "terminal-tabs layout selector should expose status text");
64
66
  assert.match(html, /id="nativeCommandDialog"/, "native slash selector UI should have a dedicated dialog");
65
67
  assert.match(html, /id="nativeCommandSearch"[^>]*type="search"/, "native slash selector dialog should expose a filter box");
66
68
  assert.match(html, /id="pathPickerCreateNameInput"[^>]*placeholder="New directory name"/, "cwd picker should expose a new-directory name input");
@@ -104,6 +106,11 @@ assert.match(html, /id="stickyUserPromptButton"/, "chat should expose a fixed la
104
106
  assert.match(html, /id="feedbackTray"/, "chat should expose a queued action-feedback tray");
105
107
  assert.match(html, /id="sendFeedbackButton"/, "action feedback should be submittable after the agent finishes");
106
108
  assert.match(html, /<textarea id="promptInput"[^>]*rows="1"[^>]*enterkeyhint="enter"/, "prompt textarea should start at one row and hint that Return inserts a newline");
109
+ assert.match(html, /id="busyPromptBehaviorTag"[\s\S]*class="composer-busy-mode-tag"[\s\S]*aria-controls="busyPromptBehaviorMenu"/, "composer should expose a clickable busy prompt behavior tag on the input frame");
110
+ assert.doesNotMatch(html, /Busy send:/i, "busy prompt behavior tag should show only the current mode label");
111
+ assert.match(html, /id="sessionSkillTags" class="composer-skill-tags"[\s\S]*hidden/, "composer should expose a hidden-until-used skill tag strip beside the busy mode tag");
112
+ assert.match(html, /id="skillEditorDialog"[\s\S]*id="skillEditorText"[\s\S]*id="skillEditorSaveButton"/, "skill tags should have an in-Web UI SKILL.md editing dialog");
113
+ assert.match(html, /id="busyPromptBehaviorMenu"[\s\S]*data-busy-prompt-behavior="followUp"[\s\S]*data-busy-prompt-behavior="steer"/, "busy prompt behavior dropdown should expose follow-up and steer choices");
107
114
  assert.match(app, /const LONG_INPUT_ATTACHMENT_LINE_THRESHOLD = 20/, "long composer text should use a 20-line threshold before becoming an attachment");
108
115
  assert.match(app, /function attachLongTextAsFile\(text, source = "input text"\)/, "long composer text should be attachable as a generated text file");
109
116
  assert.match(app, /function handleAttachmentPaste\(event\)[\s\S]*attachLongTextAsFile\(text, "clipboard text"\)/, "long pasted text should be attached instead of inserted into the prompt textarea");
@@ -171,6 +178,14 @@ assert.match(css, /\.composer-abort-button\.long-pressing::after[\s\S]*?animatio
171
178
  assert.match(css, /body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-abort-button:not\(\[hidden\]\) \{ grid-column: span 2; \}/, "active mobile runs should keep Abort beside Send in the bottom controls");
172
179
  assert.match(css, /#promptInput \{[\s\S]*?min-height:\s*calc\(1\.5em \+ 1\.8rem\)/, "prompt input should default to a compact single-line height");
173
180
  assert.match(css, /#promptInput \{[\s\S]*?overflow-y:\s*hidden/, "prompt input should be JS-resized instead of showing a scrollbar by default");
181
+ assert.match(css, /\.composer-context-tags \{[\s\S]*?top:\s*-0\.48rem;[\s\S]*?left:\s*0\.75rem;/, "busy prompt behavior and skill tags should sit at the top-left of the input frame");
182
+ assert.match(css, /\.composer-busy-mode-tag \{[\s\S]*?var\(--ctp-crust\)/, "busy prompt behavior tag should use an opaque base background");
183
+ assert.match(css, /\.composer-skill-tag \{[\s\S]*?var\(--ctp-crust\)/, "skill tags should use an opaque base background");
184
+ assert.match(css, /button\.composer-skill-tag:hover,[\s\S]*?button\.composer-skill-tag:focus-visible/, "skill tags should be styled as clickable controls");
185
+ assert.match(css, /\.extension-dialog\.skill-editor-dialog \{[\s\S]*?--skill-editor-size:\s*min\(152rem[\s\S]*?width:\s*var\(--skill-editor-size\);[\s\S]*?height:\s*var\(--skill-editor-size\);[\s\S]*?aspect-ratio:\s*1 \/ 1/, "skill editor should use a square viewport-bounded modal layout");
186
+ assert.match(css, /\.skill-editor-dialog form \{[\s\S]*?height:\s*100%;[\s\S]*?min-height:\s*0/, "skill editor form should fill the square modal without forcing overflow");
187
+ assert.match(css, /\.skill-editor-text \{[\s\S]*?overflow-x:\s*hidden;[\s\S]*?overflow-wrap:\s*anywhere;[\s\S]*?white-space:\s*pre-wrap/, "skill editor text should wrap long lines instead of horizontal scrolling");
188
+ assert.match(css, /\.composer-busy-mode-menu \{[\s\S]*?bottom:\s*calc\(100% \+ 0\.22rem\);[\s\S]*?background:\s*var\(--ctp-crust\)/, "busy prompt behavior dropdown should expand above the tag with an opaque background");
174
189
  assert.match(css, /\.sticky-user-prompt-button \{[\s\S]*?grid-template-columns:\s*auto minmax\(0, 1fr\) auto/, "last-user-prompt jump control should render as a fixed transcript header");
175
190
  assert.match(css, /\.message\.extension,[\s\S]*?\.message\.native/, "extension and native command output should have visible transcript styling");
176
191
  assert.match(css, /\.message\.run-indicator-message \{[\s\S]*?border-color/, "active agent runs should render a visible transcript indicator card");
@@ -253,6 +268,11 @@ assert.match(css, /\.composer-actions-panel > \.composer-publish-menu[\s\S]*?gri
253
268
  assert.match(css, /\.composer-actions-panel[\s\S]*?bottom:\s*calc\(100% \+ 0\.42rem\)/, "mobile composer actions should open as an above-composer sheet");
254
269
  assert.match(css, /body\.composer-actions-open \.composer-actions-panel \{ display: grid; \}/, "composer actions panel should only open when toggled");
255
270
  assert.match(css, /\.terminal-tabs-toggle-button \{ display: none; \}/, "terminal tab toggle should be hidden outside mobile CSS");
271
+ assert.match(css, /body\.terminal-tabs-left \.chat-panel \{[\s\S]*?grid-template-columns:\s*clamp\(13rem, 18vw, 19rem\) minmax\(0, 1fr\)/, "terminal tabs left layout should split the chat panel into a sidebar and transcript area");
272
+ assert.match(css, /body\.terminal-tabs-left \.terminal-tabs-shell \{[\s\S]*?grid-column:\s*1;[\s\S]*?grid-row:\s*1 \/ -1;[\s\S]*?flex-direction:\s*column/, "terminal tabs left layout should turn the top tab strip into a vertical sidebar");
273
+ assert.match(css, /body\.terminal-tabs-left \.terminal-tabs \{[\s\S]*?flex-direction:\s*column/, "terminal tabs left layout should stack tabs vertically");
274
+ assert.match(css, /body\.terminal-tabs-left \.terminal-tab-group-menu \{[\s\S]*?inset:\s*0 auto auto 100%;[\s\S]*?padding-left:\s*var\(--terminal-left-dropdown-bridge\)/, "left-sidebar grouped tab menus should include a hover bridge so they do not vanish between button and dropdown");
275
+ assert.match(css, /body\.terminal-tabs-left \.terminal-new-tab-menu \.composer-publish-menu-panel \{[\s\S]*?inset:\s*0 auto auto 100%;[\s\S]*?padding-left:\s*var\(--terminal-left-dropdown-bridge\)/, "left-sidebar new-tab dropdown should include a hover bridge so it does not vanish between button and dropdown");
256
276
  assert.match(css, /\.terminal-new-tab-menu \.composer-publish-menu-panel \{[\s\S]*?inset:\s*100% 0 auto auto;[\s\S]*?padding-top:\s*0\.38rem/, "new-tab dropdown should reuse the shared composer panel and open below the tab bar");
257
277
  assert.match(css, /\.terminal-new-tab-menu \.composer-publish-menu-item \{[\s\S]*?color:\s*var\(--ctp-pink\)/, "new-tab dropdown items should reuse shared composer menu items with a tab-specific color");
258
278
  assert.match(css, /\.terminal-close-all-button \{[\s\S]*?color:\s*var\(--ctp-red\)/, "close-all tabs action should render as a top-right destructive tab action");
@@ -334,6 +354,9 @@ assert.match(app, /const CUSTOM_BACKGROUNDS_STORAGE_KEY = "pi-webui-custom-backg
334
354
  assert.match(app, /const CUSTOM_BACKGROUND_IDB_NAME = "pi-webui-custom-background"/, "custom backgrounds should prefer IndexedDB persistence for large images");
335
355
  assert.match(app, /const SIDE_PANEL_SECTION_STORAGE_KEY = "pi-webui-side-panel-sections-collapsed"/, "side-panel section collapse state should be persisted in browser storage");
336
356
  assert.match(app, /const AGENT_DONE_NOTIFICATIONS_STORAGE_KEY = "pi-webui-agent-done-notifications"/, "agent-done notification preference should be persisted in browser storage");
357
+ assert.match(app, /const TERMINAL_TABS_LAYOUT_STORAGE_KEY = "pi-webui-terminal-tabs-layout"/, "terminal-tabs layout preference should be persisted in browser storage");
358
+ assert.match(app, /document\.body\.classList\.toggle\("terminal-tabs-left", next === "left"\)/, "terminal-tabs layout should toggle a body class for CSS layout");
359
+ assert.match(app, /terminalTabsLayoutSelect\.addEventListener\("change"/, "terminal-tabs layout selector should update the browser layout immediately");
337
360
  assert.match(app, /async function initializeThemes\(\)/, "frontend should initialize bundled themes");
338
361
  assert.match(app, /api\("\/api\/themes", \{ scoped: false \}\)/, "theme loading should use the unscoped themes endpoint");
339
362
  assert.match(app, /function applyTheme\(theme/, "frontend should apply a selected theme to CSS variables");
@@ -386,7 +409,7 @@ assert.match(app, /function syncMobileChatToBottomForInput\(\)/, "mobile input f
386
409
  assert.match(app, /function focusPromptInput\(\{ defer = false \} = \{\}\)/, "frontend should focus the prompt composer programmatically after tab/app startup");
387
410
  assert.match(app, /async function switchTab\(tabId\)[\s\S]*?restoreActiveDraft\(\);\n\s+focusPromptInput\(\{ defer: true \}\);/, "switching to a newly opened tab should focus the prompt input immediately");
388
411
  assert.match(app, /async function initializeTabs\(\)[\s\S]*?restoreActiveDraft\(\);[\s\S]*if \(!loadedTabs\.length\)[\s\S]*focusPromptInput\(\{ defer: true \}\);/, "starting the Web UI should prompt for cwd when needed and focus active tabs");
389
- assert.match(app, /resizePromptInput\(\);\nfocusPromptInput\(\{ defer: true \}\);\nupdateComposerModeButtons\(\);/, "startup should request prompt focus before waiting for tab state refreshes");
412
+ assert.match(app, /resizePromptInput\(\);\nfocusPromptInput\(\{ defer: true \}\);\nrestoreStoredSkillUsage\(\);\nrestoreBusyPromptBehaviorSetting\(\);\nupdateComposerModeButtons\(\);/, "startup should request prompt focus and restore skill tags before waiting for tab state refreshes");
390
413
  assert.match(app, /elements\.promptInput\.addEventListener\("focus", \(\) => \{\n\s+syncMobileChatToBottomForInput\(\);/, "focusing mobile input should scroll output to bottom");
391
414
  assert.match(app, /navigator\.serviceWorker\.register\("\/service-worker\.js"\)/, "PWA service worker should be registered by the app");
392
415
  assert.match(app, /function serverStartCommandText\(\)[\s\S]*return `pi-webui\$\{currentPortArg\(\)\}`/, "PWA/offline shell should build a pathless pi-webui recovery command");
@@ -673,9 +696,29 @@ assert.match(app, /function updateComposerModeButtons\(\)/, "composer should rel
673
696
  assert.match(app, /const target = runActive \? elements\.composerRow : elements\.composerActionsPanel/, "Steer and Follow-up should move into the bottom row only while an agent run is active");
674
697
  assert.match(app, /const before = runActive \? elements\.abortButton : null/, "active Steer and Follow-up controls should sit before Abort and Send");
675
698
  assert.match(app, /button\.hidden = !runActive;\n\s+button\.disabled = !runActive;/, "Steer and Follow-up should be hidden and disabled when the agent is not running");
699
+ assert.match(app, /renderBusyPromptBehaviorTag\(\);\n\s+document\.body\.classList\.toggle\("pi-run-active", runActive \|\| abortAvailable\)/, "composer mode refresh should keep the busy prompt behavior tag current");
676
700
  assert.match(app, /elements\.abortButton\.hidden = !abortAvailable;\n\s+elements\.abortButton\.disabled = !abortAvailable \|\| abortRequestInFlight;/, "Abort should only be exposed in the bottom bar while a run can be aborted");
677
701
  assert.match(app, /document\.body\.classList\.toggle\("pi-run-active", runActive \|\| abortAvailable\)/, "run-active or abort-available state should be reflected in CSS for mobile composer layout");
678
702
  assert.match(app, /function showComposerButtonTooltip\(button\)/, "empty mode-button taps should show the usage tooltip");
703
+ assert.match(app, /function renderBusyPromptBehaviorTag\(\)[\s\S]*?tag\.textContent = label/, "busy prompt behavior tag should render only the current follow-up\/steer setting");
704
+ assert.doesNotMatch(app, /Busy send: \$\{label\}/, "busy prompt behavior tag should not prefix the current mode label");
705
+ assert.match(app, /function renderSessionSkillTags\(tabId = activeTabId\)[\s\S]*?filter\(\(entry\) => entry\.kinds\.has\("read"\)\)[\s\S]*?make\("button", classes\.join\(" "\), entry\.name\)[\s\S]*?openSkillEditor\(entry\)/, "skill tags should render as clickable buttons only after the full skill context was read");
706
+ assert.ok(app.includes('normalized.match(/\\/skills\\/([^/]+)\\/SKILL\\.md$/i)'), "skill context tracking should require SKILL.md paths");
707
+ assert.match(app, /function trackSkillsFromToolInvocation\(tabId, toolName[\s\S]*?name\.toLowerCase\(\) !== "read"\) return;[\s\S]*?kind: "read"/, "skill context tracking should only follow read-tool invocations");
708
+ assert.match(app, /function trackSkillUsage\(tabId, skillName[\s\S]*?persistSkillUsage\(\);[\s\S]*?renderSessionSkillTags\(tabId\)/, "skill tags should persist and live-update when a read skill is tracked");
709
+ assert.match(app, /const SKILL_USAGE_STORAGE_KEY = "pi-webui-skill-usage-v1"/, "read skill tags should have browser storage for hard-refresh and restart restore");
710
+ assert.match(app, /function persistSkillUsage\(\)[\s\S]*?localStorage\.setItem\(SKILL_USAGE_STORAGE_KEY/, "read skill tags should be persisted to browser storage");
711
+ assert.match(app, /function restoreStoredSkillUsage\(\)[\s\S]*?localStorage\.getItem\(SKILL_USAGE_STORAGE_KEY/, "read skill tags should restore from browser storage");
712
+ assert.match(app, /restoreStoredSkillUsage\(\);[\s\S]*?initializeTabs\(\)/, "stored read skill tags should be restored before tabs initialize");
713
+ assert.match(app, /trackSkillsFromEvent\(event\);[\s\S]*?if \(!eventTargetsActiveTab\(event\)\)/, "skill usage should be tracked as soon as tab events arrive");
714
+ assert.doesNotMatch(app, /trackSkillsFromCommands\(rawAvailableCommands, tabContext\.tabId\)/, "loaded skill commands alone should not populate skill tags");
715
+ assert.match(app, /function openSkillEditor\(entry\)[\s\S]*?api\(skillEditorApiPath\(\{ name, path \}\), \{ tabId \}\)/, "clicking a skill tag should load the corresponding SKILL.md into the editor dialog");
716
+ assert.match(app, /function saveSkillEditor\(\)[\s\S]*?api\("\/api\/skill-file", \{[\s\S]*?method: "POST"[\s\S]*?content: elements\.skillEditorText\.value/, "skill editor should save changed SKILL.md contents through the API");
717
+ assert.match(app, /skillEditorDialog\?\.addEventListener\("keydown"[\s\S]*?saveSkillEditor\(\)/, "skill editor should support Ctrl\/Cmd+S saving");
718
+ assert.match(app, /function setBusyPromptBehaviorMenuOpen\(open,[\s\S]*aria-expanded[\s\S]*busyPromptBehaviorMenu\.hidden/, "busy prompt behavior tag should control a dropdown menu");
719
+ assert.match(app, /busyPromptBehaviorTag\?\.addEventListener\("click"[\s\S]*setBusyPromptBehaviorMenuOpen\(nextOpen\)/, "clicking the busy prompt behavior tag should toggle its dropdown");
720
+ assert.match(app, /busyPromptBehaviorMenu\?\.addEventListener\("click"[\s\S]*chooseBusyPromptBehaviorFromMenu/, "busy prompt behavior dropdown choices should update the setting");
721
+ assert.match(app, /setBusyPromptBehavior\(controls\.busyBehavior\.select\.value\)/, "native settings should update the busy prompt behavior tag immediately");
679
722
  assert.match(app, /sendPromptFromModeButton\("steer", elements\.steerButton\)/, "Steer should show tooltip instead of silently doing nothing when input is empty");
680
723
  assert.match(app, /sendPromptFromModeButton\("follow-up", elements\.followUpButton\)/, "Follow-up should show tooltip instead of silently doing nothing when input is empty");
681
724
  assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*?sendPrompt\("prompt", command\)/, "Publish workflows should send slash commands directly without replacing the draft");
@@ -959,6 +1002,12 @@ assert.match(server, /if \(webuiDevServer\) return installRoot/, "source-checkou
959
1002
  assert.match(server, /Could not determine a safe optional feature install root/, "optional feature installs should fail closed when no declared package root can be found");
960
1003
  assert.match(server, /url\.pathname === "\/api\/optional-feature-install" && req\.method === "POST"/, "server should expose optional feature install endpoint");
961
1004
  assert.match(server, /Installing optional Web UI features is only allowed from localhost/, "optional feature install endpoint should be localhost-only");
1005
+ assert.match(server, /url\.pathname === "\/api\/skill-file" && req\.method === "GET"[\s\S]*?getSkillFileData/, "server should expose GET /api/skill-file for editable skill content");
1006
+ assert.match(server, /url\.pathname === "\/api\/skill-file" && req\.method === "POST"[\s\S]*?Saving skill files is only allowed from localhost[\s\S]*?saveSkillFileData/, "server should expose localhost-only POST /api/skill-file for saving skill content");
1007
+ assert.match(server, /function resolveEditableSkillFile\(tab, request = \{\}\)[\s\S]*?path\.basename\(skill\.filePath\) !== "SKILL\.md"/, "skill file API should validate that edits target resolved SKILL.md resources");
1008
+ assert.match(server, /function resolveExplicitSkillFilePath\(tab, filePath, requestedName = ""\)[\s\S]*?Skill path must point to \/skills\/<name>\/SKILL\.md[\s\S]*?allowedRoots/, "skill file API should allow exact read SKILL.md paths from trusted Pi skill roots");
1009
+ assert.match(server, /Skill path is outside allowed Pi skill locations/, "explicit skill path fallback should reject paths outside Pi skill roots");
1010
+ assert.match(server, /writeFile\(tmpFile, body\.content[\s\S]*?rename\(tmpFile, skill\.filePath\)/, "skill file saves should use an atomic temp-file rename");
962
1011
  assert.match(server, /url\.pathname === "\/api\/themes" && req\.method === "GET"/, "server should expose GET /api/themes");
963
1012
  assert.match(server, /readBundledThemes\(\)/, "server should read bundled theme JSON files for the browser");
964
1013
  assert.match(server, /"apple-touch-icon\.png", "icon-192\.png"/, "server should serve the conventional apple touch icon path");