@oratis/lisa 0.11.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/agent.d.ts +2 -0
  2. package/dist/agent.d.ts.map +1 -1
  3. package/dist/agent.js +20 -1
  4. package/dist/agent.js.map +1 -1
  5. package/dist/agents/pty.d.ts.map +1 -1
  6. package/dist/agents/pty.js +13 -2
  7. package/dist/agents/pty.js.map +1 -1
  8. package/dist/autonomy/state.d.ts +14 -0
  9. package/dist/autonomy/state.d.ts.map +1 -0
  10. package/dist/autonomy/state.js +54 -0
  11. package/dist/autonomy/state.js.map +1 -0
  12. package/dist/cli/mail.d.ts +2 -0
  13. package/dist/cli/mail.d.ts.map +1 -0
  14. package/dist/cli/mail.js +209 -0
  15. package/dist/cli/mail.js.map +1 -0
  16. package/dist/cli/pair.d.ts +15 -0
  17. package/dist/cli/pair.d.ts.map +1 -0
  18. package/dist/cli/pair.js +152 -0
  19. package/dist/cli/pair.js.map +1 -0
  20. package/dist/cli.js +22 -14
  21. package/dist/cli.js.map +1 -1
  22. package/dist/consent/store.d.ts +2 -2
  23. package/dist/consent/store.d.ts.map +1 -1
  24. package/dist/consent/store.js +3 -2
  25. package/dist/consent/store.js.map +1 -1
  26. package/dist/edition.d.ts +24 -0
  27. package/dist/edition.d.ts.map +1 -0
  28. package/dist/edition.js +19 -0
  29. package/dist/edition.js.map +1 -0
  30. package/dist/heartbeat/runner.d.ts.map +1 -1
  31. package/dist/heartbeat/runner.js +8 -0
  32. package/dist/heartbeat/runner.js.map +1 -1
  33. package/dist/idle/runner.d.ts.map +1 -1
  34. package/dist/idle/runner.js +7 -0
  35. package/dist/idle/runner.js.map +1 -1
  36. package/dist/mail/accounts.d.ts +20 -0
  37. package/dist/mail/accounts.d.ts.map +1 -0
  38. package/dist/mail/accounts.js +113 -0
  39. package/dist/mail/accounts.js.map +1 -0
  40. package/dist/mail/alerts.d.ts +29 -0
  41. package/dist/mail/alerts.d.ts.map +1 -0
  42. package/dist/mail/alerts.js +40 -0
  43. package/dist/mail/alerts.js.map +1 -0
  44. package/dist/mail/classify.d.ts +26 -0
  45. package/dist/mail/classify.d.ts.map +1 -0
  46. package/dist/mail/classify.js +191 -0
  47. package/dist/mail/classify.js.map +1 -0
  48. package/dist/mail/connectors/gmail.d.ts +55 -0
  49. package/dist/mail/connectors/gmail.d.ts.map +1 -0
  50. package/dist/mail/connectors/gmail.js +87 -0
  51. package/dist/mail/connectors/gmail.js.map +1 -0
  52. package/dist/mail/connectors/imap.d.ts +13 -0
  53. package/dist/mail/connectors/imap.d.ts.map +1 -0
  54. package/dist/mail/connectors/imap.js +136 -0
  55. package/dist/mail/connectors/imap.js.map +1 -0
  56. package/dist/mail/digest.d.ts +21 -0
  57. package/dist/mail/digest.d.ts.map +1 -0
  58. package/dist/mail/digest.js +89 -0
  59. package/dist/mail/digest.js.map +1 -0
  60. package/dist/mail/google-oauth.d.ts +50 -0
  61. package/dist/mail/google-oauth.d.ts.map +1 -0
  62. package/dist/mail/google-oauth.js +74 -0
  63. package/dist/mail/google-oauth.js.map +1 -0
  64. package/dist/mail/hosts.d.ts +3 -0
  65. package/dist/mail/hosts.d.ts.map +1 -0
  66. package/dist/mail/hosts.js +19 -0
  67. package/dist/mail/hosts.js.map +1 -0
  68. package/dist/mail/scheduler.d.ts +9 -0
  69. package/dist/mail/scheduler.d.ts.map +1 -0
  70. package/dist/mail/scheduler.js +21 -0
  71. package/dist/mail/scheduler.js.map +1 -0
  72. package/dist/mail/service.d.ts +32 -0
  73. package/dist/mail/service.d.ts.map +1 -0
  74. package/dist/mail/service.js +129 -0
  75. package/dist/mail/service.js.map +1 -0
  76. package/dist/mail/store.d.ts +7 -0
  77. package/dist/mail/store.d.ts.map +1 -0
  78. package/dist/mail/store.js +58 -0
  79. package/dist/mail/store.js.map +1 -0
  80. package/dist/mail/types.d.ts +114 -0
  81. package/dist/mail/types.d.ts.map +1 -0
  82. package/dist/mail/types.js +26 -0
  83. package/dist/mail/types.js.map +1 -0
  84. package/dist/providers/anthropic.d.ts.map +1 -1
  85. package/dist/providers/anthropic.js +43 -21
  86. package/dist/providers/anthropic.js.map +1 -1
  87. package/dist/providers/gemini.d.ts.map +1 -1
  88. package/dist/providers/gemini.js +67 -64
  89. package/dist/providers/gemini.js.map +1 -1
  90. package/dist/providers/openai.d.ts.map +1 -1
  91. package/dist/providers/openai.js +77 -71
  92. package/dist/providers/openai.js.map +1 -1
  93. package/dist/providers/registry.d.ts +10 -0
  94. package/dist/providers/registry.d.ts.map +1 -1
  95. package/dist/providers/registry.js +26 -0
  96. package/dist/providers/registry.js.map +1 -1
  97. package/dist/providers/stream-retry.d.ts +37 -0
  98. package/dist/providers/stream-retry.d.ts.map +1 -0
  99. package/dist/providers/stream-retry.js +92 -0
  100. package/dist/providers/stream-retry.js.map +1 -0
  101. package/dist/providers/types.d.ts +3 -0
  102. package/dist/providers/types.d.ts.map +1 -1
  103. package/dist/subagent.d.ts +2 -0
  104. package/dist/subagent.d.ts.map +1 -1
  105. package/dist/subagent.js +1 -0
  106. package/dist/subagent.js.map +1 -1
  107. package/dist/web/cloudAuth.d.ts +57 -0
  108. package/dist/web/cloudAuth.d.ts.map +1 -0
  109. package/dist/web/cloudAuth.js +139 -0
  110. package/dist/web/cloudAuth.js.map +1 -0
  111. package/dist/web/island.d.ts.map +1 -1
  112. package/dist/web/island.js +1 -0
  113. package/dist/web/island.js.map +1 -1
  114. package/dist/web/lisa-client.d.ts +1 -1
  115. package/dist/web/lisa-client.d.ts.map +1 -1
  116. package/dist/web/lisa-client.js +703 -40
  117. package/dist/web/lisa-client.js.map +1 -1
  118. package/dist/web/lisa-css.d.ts +1 -1
  119. package/dist/web/lisa-css.d.ts.map +1 -1
  120. package/dist/web/lisa-css.js +568 -43
  121. package/dist/web/lisa-css.js.map +1 -1
  122. package/dist/web/lisa-html.d.ts +1 -1
  123. package/dist/web/lisa-html.d.ts.map +1 -1
  124. package/dist/web/lisa-html.js +71 -21
  125. package/dist/web/lisa-html.js.map +1 -1
  126. package/dist/web/pairing.d.ts +37 -0
  127. package/dist/web/pairing.d.ts.map +1 -0
  128. package/dist/web/pairing.js +79 -0
  129. package/dist/web/pairing.js.map +1 -0
  130. package/dist/web/push.d.ts +91 -0
  131. package/dist/web/push.d.ts.map +1 -1
  132. package/dist/web/push.js +250 -9
  133. package/dist/web/push.js.map +1 -1
  134. package/dist/web/qr-svg.d.ts +11 -0
  135. package/dist/web/qr-svg.d.ts.map +1 -0
  136. package/dist/web/qr-svg.js +41 -0
  137. package/dist/web/qr-svg.js.map +1 -0
  138. package/dist/web/server.d.ts +1 -1
  139. package/dist/web/server.d.ts.map +1 -1
  140. package/dist/web/server.js +378 -14
  141. package/dist/web/server.js.map +1 -1
  142. package/package.json +4 -1
@@ -25,5 +25,5 @@
25
25
  * identitySub, sbDesire, sbClaudeCard, sbClaudeCount, sbClaudeRows,
26
26
  * sbReflection, sbReflectionBody, sbSessionBadge
27
27
  */
28
- export declare const MAIN_HTML = "<!doctype html>\n<html lang=\"en\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>LISA</title>\n<link rel=\"manifest\" href=\"/manifest.webmanifest\">\n<meta name=\"theme-color\" content=\"#07091a\">\n<meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n<meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\">\n<meta name=\"apple-mobile-web-app-title\" content=\"LISA\">\n<link rel=\"apple-touch-icon\" href=\"/assets/lisa-mascot.png\">\n<link rel=\"icon\" type=\"image/png\" href=\"/assets/lisa-mascot.png\">\n<style>\n :root {\n color-scheme: dark;\n\n /* New design tokens (mockup) */\n --accent: #6ad4ff;\n --accent-soft: rgba(106, 212, 255, 0.13);\n --accent-glow: rgba(106, 212, 255, 0.27);\n --warm: #ffd066;\n --dream: #b487ff;\n --claude: #ff8c42;\n --err-color: #ff5577;\n\n --bg-deep: #07091a;\n --bg-1: #0b1024;\n --bg-2: #11163a;\n --bg-3: #1a1f4a;\n --bg-card: rgba(20, 26, 64, 0.65);\n --bg-card-strong: rgba(20, 26, 64, 0.88);\n --border-new: rgba(255, 255, 255, 0.07);\n --border-strong: rgba(255, 255, 255, 0.14);\n\n --fg: #e8eaff;\n --fg-2: #aeb5d3;\n --fg-3: #6c7398;\n --fg-faint: #444a6e;\n\n /* Legacy tokens \u2014 kept so the unchanged modal / cfg / birth\n overlay styles below still resolve. The new shell + chat use\n the modern tokens above. */\n --bg: #0a0d2b;\n --panel: #1a1f4d;\n --panel-light: #2a3270;\n --border: #6a7ad9;\n --border-light: #a4b2ff;\n --text: #e7ecff;\n --text-dim: #8090c0;\n --you: #6cf6e1;\n --lisa: #ffd167;\n --tool: #ff7eb6;\n --error: #ff5577;\n }\n * { box-sizing: border-box; }\n html, body {\n height: 100%;\n margin: 0;\n background: #000;\n font-family: -apple-system, BlinkMacSystemFont, \"SF Pro Text\", \"Inter\", system-ui, sans-serif;\n -webkit-font-smoothing: antialiased;\n color: var(--fg);\n overflow: hidden;\n }\n\n /* \u2500\u2500 App shell \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .frame {\n height: 100vh;\n width: 100vw;\n display: grid;\n grid-template-columns: 280px 1fr;\n grid-template-rows: 36px 1fr;\n grid-template-areas:\n \"titlebar titlebar\"\n \"sidebar main\";\n background:\n radial-gradient(ellipse at 30% 20%, #1a1238 0%, transparent 50%),\n radial-gradient(ellipse at 80% 70%, #0a1f3a 0%, transparent 60%),\n linear-gradient(180deg, var(--bg-1) 0%, var(--bg-deep) 100%);\n overflow: hidden;\n }\n\n /* Title bar \u2014 visually shows \"Lisa \u00B7 session-id\". The actual drag\n behavior is handled Swift-side by a transparent NSView overlay\n (DragHandleView) placed on top of the WKWebView for the same 36pt\n strip \u2014 WebKit ignores the CSS -webkit-app-region: drag property,\n so the cosmetic HTML and the functional drag region are two\n separate things.\n Padding-left reserves the ~78pt that the macOS traffic-light\n buttons occupy at top-left. */\n .titlebar {\n grid-area: titlebar;\n background: rgba(7, 9, 26, 0.55);\n backdrop-filter: blur(20px);\n -webkit-backdrop-filter: blur(20px);\n border-bottom: 1px solid var(--border-new);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 0 14px 0 78px;\n font-size: 12px;\n font-weight: 600;\n letter-spacing: 0.04em;\n color: var(--fg-2);\n /* nothing here should ever capture pointer events \u2014 the NSView\n overlay above handles dragging, and there are no interactive\n elements in the HTML titlebar. */\n user-select: none;\n pointer-events: none;\n }\n .titlebar .session-tag {\n color: var(--fg-3);\n font-weight: 400;\n margin-left: 6px;\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n font-size: 11px;\n letter-spacing: 0;\n }\n\n /* \u2500\u2500 Sidebar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .sidebar {\n grid-area: sidebar;\n background: rgba(7, 9, 26, 0.4);\n backdrop-filter: blur(30px);\n -webkit-backdrop-filter: blur(30px);\n border-right: 1px solid var(--border-new);\n overflow-y: auto;\n padding: 20px 16px 16px;\n display: flex;\n flex-direction: column;\n gap: 18px;\n }\n\n /* Identity card */\n .identity {\n display: grid;\n grid-template-columns: 56px 1fr;\n gap: 12px;\n align-items: center;\n padding: 12px;\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 14px;\n }\n .identity .avatar-wrap {\n width: 56px;\n height: 56px;\n border-radius: 50%;\n position: relative;\n border: 1px solid var(--border-strong);\n box-shadow: 0 0 0 3px var(--accent-soft);\n background: #15192a;\n }\n .identity .avatar-wrap img {\n width: 100%;\n height: 100%;\n border-radius: 50%;\n object-fit: cover;\n object-position: 50% 22%;\n image-rendering: pixelated;\n display: block;\n transition: opacity 250ms ease;\n user-select: none;\n -webkit-user-drag: none;\n }\n .identity .avatar-wrap img.fading { opacity: 0; }\n .identity .avatar-wrap::after {\n content: \"\";\n position: absolute;\n right: -2px;\n bottom: -2px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: #4ade80;\n border: 2px solid var(--bg-1);\n }\n .identity h1 {\n margin: 0 0 2px;\n font-size: 15px;\n font-weight: 700;\n letter-spacing: 0.02em;\n color: var(--fg);\n }\n .identity .sub {\n margin: 0;\n font-size: 11px;\n color: var(--fg-3);\n }\n .identity .mood {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n margin-top: 4px;\n font-size: 10.5px;\n color: var(--accent);\n text-transform: uppercase;\n letter-spacing: 0.08em;\n font-weight: 600;\n }\n .identity .mood::before {\n content: \"\";\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: var(--accent);\n box-shadow: 0 0 6px var(--accent-glow);\n }\n\n /* Sidebar plain text section (\"currently wanting\") */\n .sb-section { display: flex; flex-direction: column; gap: 6px; }\n .sb-section h2 {\n margin: 0 0 2px;\n font-size: 10.5px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.10em;\n color: var(--fg-3);\n padding-left: 4px;\n }\n .sb-section .body-text {\n margin: 0;\n font-size: 12px;\n line-height: 1.55;\n color: var(--fg-2);\n padding: 0 4px;\n }\n\n /* Live mini-cards (Claude monitor / last reflection) */\n .card {\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 12px;\n padding: 10px 12px;\n font-size: 12px;\n color: var(--fg-2);\n line-height: 1.5;\n }\n .card.tint-claude {\n border-color: rgba(255, 140, 66, 0.20);\n background: linear-gradient(180deg, rgba(255, 140, 66, 0.06), rgba(255, 140, 66, 0.02));\n }\n .card.tint-idle {\n border-color: rgba(255, 208, 102, 0.22);\n background: linear-gradient(180deg, rgba(255, 208, 102, 0.07), rgba(255, 208, 102, 0.02));\n }\n .card .h {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 4px;\n }\n .card .h .left {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 10.5px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.10em;\n color: var(--claude);\n }\n .card.tint-idle .h .left { color: var(--warm); }\n .card .h .count {\n background: rgba(255, 140, 66, 0.16);\n color: var(--claude);\n font-size: 11px;\n font-weight: 600;\n padding: 2px 7px;\n border-radius: 8px;\n }\n .card.tint-idle .h .count {\n background: rgba(255, 208, 102, 0.16);\n color: var(--warm);\n }\n .session-row {\n display: grid;\n grid-template-columns: 7px 1fr auto;\n align-items: center;\n gap: 7px;\n padding: 5px 0;\n font-size: 11.5px;\n border-top: 1px dashed rgba(255, 140, 66, 0.10);\n cursor: default;\n }\n .session-row:first-of-type { border-top: 0; }\n .session-row .pip {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--fg-faint);\n }\n /* working = calm slow breathe (running, not actionable);\n waiting = solid + a soft halo that draws the eye (\"needs you\"). */\n .session-row .pip.working { background: var(--claude); opacity: 1; animation: breathe 2.6s ease-in-out infinite; }\n .session-row .pip.waiting { background: var(--claude); opacity: 1; animation: needsYou 2s ease-in-out infinite; }\n .session-row .pip.error { background: var(--err-color); }\n .session-row .name {\n color: var(--fg);\n font-weight: 600;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n /* D4a \u2014 agent-kind chip rendered inline before the project name, so the\n multi-agent sidebar reads which tool each row belongs to. */\n .session-row .agent-badge {\n display: inline-block;\n font-size: 9px;\n font-weight: 700;\n letter-spacing: 0.02em;\n text-transform: lowercase;\n color: var(--claude);\n background: rgba(255, 140, 66, 0.12);\n border: 1px solid rgba(255, 140, 66, 0.22);\n border-radius: 999px;\n padding: 0 5px;\n margin-right: 5px;\n vertical-align: 1px;\n }\n .session-row .when {\n color: var(--fg-3);\n font-variant-numeric: tabular-nums;\n font-size: 10.5px;\n }\n /* Second line under name/when: structural activity (turns/tokens/tool\u00B7file). */\n .session-row .session-act {\n grid-column: 2 / -1;\n margin-top: 2px;\n font-size: 10px;\n color: var(--fg-3);\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n /* Managed-agent controls (approve/deny \u00B7 send follow-up \u00B7 cancel). */\n .session-row .session-ctrl {\n grid-column: 2 / -1;\n margin-top: 4px;\n display: flex;\n gap: 6px;\n align-items: center;\n flex-wrap: wrap;\n }\n .session-ctrl .mc {\n font-size: 10px;\n padding: 2px 8px;\n border-radius: 6px;\n border: 1px solid var(--border);\n background: var(--panel2, rgba(255,255,255,0.05));\n color: var(--fg);\n cursor: pointer;\n }\n .session-ctrl .mc.approve { color: var(--green, #6bff9d); border-color: rgba(107,255,157,0.4); }\n .session-ctrl .mc.deny,\n .session-ctrl .mc.cancel { color: var(--err-color, #ff5577); border-color: rgba(255,85,119,0.4); }\n .session-ctrl .mc.adopt { color: var(--brand, #6ad4ff); border-color: rgba(106,212,255,0.4); }\n .session-ctrl .mc:hover { background: rgba(255,255,255,0.10); }\n .session-ctrl .mc-send {\n flex: 1;\n min-width: 90px;\n font-size: 10.5px;\n padding: 2px 7px;\n border-radius: 6px;\n border: 1px solid var(--border);\n background: rgba(0,0,0,0.25);\n color: var(--fg);\n }\n /* \"Delegate a task\" composer at the top of the agents card. */\n .delegate {\n display: flex;\n gap: 6px;\n margin: 2px 0 8px;\n }\n .delegate input {\n flex: 1;\n font-size: 11px;\n padding: 4px 8px;\n border-radius: 7px;\n border: 1px solid var(--border);\n background: rgba(0,0,0,0.25);\n color: var(--fg);\n }\n .delegate select {\n font-size: 10.5px;\n padding: 3px 4px;\n border-radius: 7px;\n border: 1px solid var(--border);\n background: rgba(0,0,0,0.25);\n color: var(--fg-2);\n cursor: pointer;\n }\n .delegate button {\n font-size: 11px;\n padding: 4px 10px;\n border-radius: 7px;\n border: 1px solid var(--claude, #ff8c42);\n background: rgba(255,140,66,0.16);\n color: var(--claude, #ff8c42);\n cursor: pointer;\n }\n .delegate button:hover { background: rgba(255,140,66,0.28); }\n .session-empty {\n color: var(--fg-faint);\n font-size: 11.5px;\n font-style: italic;\n padding: 4px 0 2px;\n }\n @keyframes pulse {\n 0%, 100% { opacity: 0.35; }\n 50% { opacity: 1; }\n }\n /* Gentle breathing for \"working\" \u2014 present but not jumpy. */\n @keyframes breathe {\n 0%, 100% { opacity: 0.55; }\n 50% { opacity: 1; }\n }\n /* \"needs you\" \u2014 solid dot with a pulsing warm halo, prominent without the\n harsh on/off flash. */\n @keyframes needsYou {\n 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 140, 66, 0); }\n 50% { box-shadow: 0 0 7px 2px rgba(255, 140, 66, 0.65); }\n }\n\n /* Compact SOUL / SKILLS / MEMORY / TOOLS row */\n .badges {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 6px;\n }\n .badge {\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n color: var(--fg-2);\n font-family: inherit;\n font-size: 10.5px;\n font-weight: 600;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n padding: 7px 8px;\n border-radius: 8px;\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 6px;\n transition: background 120ms ease, color 120ms ease, border-color 120ms ease;\n }\n .badge:hover {\n background: var(--bg-card-strong);\n color: var(--fg);\n border-color: var(--border-strong);\n }\n .badge img {\n width: 14px;\n height: 14px;\n image-rendering: pixelated;\n opacity: 0.8;\n }\n\n /* Sidebar footer \u2014 session id + sessions count */\n .sb-footer {\n margin-top: auto;\n padding-top: 12px;\n border-top: 1px solid var(--border-new);\n display: flex;\n align-items: center;\n gap: 8px;\n color: var(--fg-3);\n font-size: 11px;\n }\n .sb-footer .session-id {\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n font-size: 10px;\n color: var(--fg-3);\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n flex: 1;\n min-width: 0;\n }\n .sb-footer .badge-count {\n background: var(--bg-3);\n color: var(--fg-2);\n border-radius: 6px;\n padding: 1px 6px;\n font-size: 10px;\n font-variant-numeric: tabular-nums;\n flex-shrink: 0;\n }\n\n /* \u2500\u2500 Main pane \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .main {\n grid-area: main;\n display: grid;\n grid-template-rows: 1fr auto auto;\n overflow: hidden;\n background:\n radial-gradient(ellipse at 50% -30%, rgba(106, 212, 255, 0.06) 0%, transparent 60%),\n linear-gradient(180deg, transparent, rgba(106, 212, 255, 0.015));\n }\n\n #log {\n overflow-y: auto;\n padding: 22px 28px 24px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n scroll-behavior: smooth;\n }\n\n /* Chat author label (.role .you/.lisa) */\n .role {\n font-size: 10px;\n font-weight: 700;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n padding: 6px 2px 0;\n margin-top: 6px;\n }\n .role.you { color: var(--accent); text-align: right; }\n .role.lisa { color: var(--warm); }\n\n /* Message bubble */\n .msg {\n display: block;\n max-width: 88%;\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 14px;\n padding: 9px 13px;\n font-size: 13px;\n line-height: 1.55;\n color: var(--fg);\n white-space: pre-wrap;\n word-wrap: break-word;\n }\n /* \"you\" bubbles sit on the right with cyan tint */\n .role.you + .msg {\n margin-left: auto;\n background: linear-gradient(160deg, rgba(106, 212, 255, 0.12), rgba(106, 212, 255, 0.06));\n border-color: rgba(106, 212, 255, 0.25);\n }\n /* attachment label sits compactly under the user bubble */\n .msg.attach-label {\n margin-top: 2px;\n background: transparent;\n border: 0;\n padding: 0 2px;\n font-size: 10.5px;\n color: var(--fg-3);\n max-width: 88%;\n margin-left: auto;\n }\n\n /* Thinking indicator */\n .thinking {\n font-size: 12px;\n color: var(--fg-3);\n font-style: italic;\n padding: 4px 2px;\n letter-spacing: 0.03em;\n }\n\n /* Tool call card */\n .tool-block {\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 10px;\n padding: 8px 12px;\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n font-size: 11px;\n color: var(--fg-2);\n margin: 4px 0;\n max-width: 92%;\n }\n .tool-block.tool-error {\n border-color: rgba(255, 85, 119, 0.30);\n background: rgba(255, 85, 119, 0.05);\n }\n .tool-head {\n display: flex;\n align-items: center;\n gap: 8px;\n color: var(--accent);\n font-weight: 600;\n }\n .tool-block.tool-error .tool-head { color: var(--err-color); }\n .tool-icon { font-size: 12px; }\n .tool-spinner { color: var(--fg-3); font-weight: 400; margin-left: auto; font-size: 12px; }\n .tool-input {\n color: var(--fg-3);\n margin-top: 4px;\n white-space: pre-wrap;\n word-break: break-word;\n }\n .tool-result {\n color: var(--fg-2);\n margin-top: 4px;\n padding-top: 4px;\n border-top: 1px dashed var(--border-new);\n white-space: pre-wrap;\n max-height: 160px;\n overflow-y: auto;\n }\n\n .err {\n color: var(--err-color);\n font-size: 12px;\n padding: 6px 2px;\n }\n\n /* Idle pulse + while-you-were-away banner injected into #log */\n .idle-pulse {\n color: var(--dream);\n font-size: 12px;\n font-style: italic;\n padding: 4px 2px;\n letter-spacing: 0.03em;\n }\n .idle-block {\n background: linear-gradient(180deg, rgba(180, 135, 255, 0.10), rgba(180, 135, 255, 0.02));\n border: 1px solid rgba(180, 135, 255, 0.30);\n border-radius: 14px;\n padding: 12px 14px;\n margin: 6px 0;\n font-size: 12.5px;\n line-height: 1.6;\n color: var(--fg);\n }\n .idle-head {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.10em;\n color: var(--dream);\n font-weight: 700;\n margin-bottom: 6px;\n display: flex;\n align-items: center;\n gap: 8px;\n }\n .idle-time {\n margin-left: auto;\n font-weight: 400;\n letter-spacing: 0.05em;\n color: var(--fg-3);\n font-size: 10px;\n }\n\n /* Composer */\n #attachPreview {\n padding: 4px 22px 0;\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n min-height: 0;\n }\n #attachPreview:empty { padding: 0; }\n .attach-chip {\n background: var(--bg-card-strong);\n border: 1px solid var(--border-strong);\n color: var(--fg-2);\n font-size: 11px;\n padding: 3px 8px;\n border-radius: 8px;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n }\n .attach-rm {\n background: transparent;\n border: 0;\n color: var(--fg-3);\n cursor: pointer;\n font-size: 13px;\n padding: 0;\n line-height: 1;\n }\n .attach-rm:hover { color: var(--err-color); }\n\n #form {\n padding: 14px 20px 18px;\n background: rgba(7, 9, 26, 0.6);\n backdrop-filter: blur(30px);\n -webkit-backdrop-filter: blur(30px);\n border-top: 1px solid var(--border-new);\n display: grid;\n grid-template-columns: 36px 36px 36px 1fr 96px;\n gap: 10px;\n align-items: end;\n }\n\n #attachBtn, #captureBtn, #recordBtn {\n align-self: stretch;\n display: flex;\n align-items: center;\n justify-content: center;\n background: transparent;\n border: 0;\n color: var(--fg-3);\n font-size: 17px;\n cursor: pointer;\n border-radius: 10px;\n transition: background 120ms ease, color 120ms ease;\n min-height: 44px;\n padding: 0;\n }\n #attachBtn:hover, #captureBtn:hover, #recordBtn:hover { background: var(--bg-card); color: var(--fg); }\n #captureBtn.flash { background: var(--accent); color: var(--bg-deep); }\n /* Pulsing red while recording. */\n #recordBtn.recording {\n background: var(--err-color);\n color: #fff;\n animation: rec-pulse 1.1s ease-in-out infinite;\n }\n @keyframes rec-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.55; } }\n /* Off-screen the file input instead of display:none. WKWebView's\n implicit <label>\u2192<input type=file> click forward doesn't fire on a\n fully display:none input \u2014 the OS file picker silently no-ops.\n Off-screening keeps the input \"live\" while invisible. */\n #fileInput {\n position: absolute;\n width: 1px;\n height: 1px;\n opacity: 0;\n pointer-events: none;\n overflow: hidden;\n }\n\n #input {\n background: var(--bg-card-strong);\n border: 1px solid var(--border-strong);\n border-radius: 14px;\n color: var(--fg);\n font-family: inherit;\n font-size: 13.5px;\n line-height: 1.45;\n padding: 11px 13px;\n resize: none;\n outline: none;\n width: 100%;\n min-height: 44px;\n max-height: 200px;\n transition: border-color 120ms ease, box-shadow 120ms ease;\n }\n #input:focus {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-soft);\n }\n #input::placeholder { color: var(--fg-3); }\n\n #sendBtn {\n background: linear-gradient(180deg, var(--accent) 0%, #4eb8e5 100%);\n color: #0a1024;\n border: 0;\n border-radius: 14px;\n height: 100%;\n font-family: inherit;\n font-size: 12.5px;\n font-weight: 700;\n letter-spacing: 0.06em;\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 6px;\n padding: 10px 14px;\n transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;\n box-shadow: 0 4px 14px rgba(106, 212, 255, 0.25);\n }\n #sendBtn:hover:not(:disabled) {\n transform: translateY(-1px);\n box-shadow: 0 6px 18px rgba(106, 212, 255, 0.35);\n }\n #sendBtn:active:not(:disabled) { transform: translateY(0); }\n #sendBtn:disabled { opacity: 0.5; cursor: wait; }\n /* Hide the legacy pixel send icon; the text label is enough in the new theme. */\n #sendBtn img { display: none; }\n\n /* \u2500\u2500 Mobile / narrow \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n @media (max-width: 720px) {\n body {\n padding-top: env(safe-area-inset-top);\n padding-bottom: env(safe-area-inset-bottom);\n }\n .frame {\n grid-template-columns: 1fr;\n grid-template-rows: auto 1fr auto;\n }\n .sidebar {\n max-height: 38vh;\n padding: 14px 14px 12px;\n gap: 14px;\n }\n #form {\n grid-template-columns: 36px 36px 36px 1fr 84px;\n padding: 10px 14px 14px;\n }\n #input { font-size: 16px; /* prevents iOS Safari auto-zoom */ }\n }\n\n /* ===================================================================\n Modal panel (skills / memory / tools / soul) \u2014 unchanged from the\n legacy pixel-art shell. Uses the legacy CSS vars declared above.\n =================================================================== */\n .modal-bg {\n position: fixed; inset: 0;\n background: rgba(0, 0, 0, 0.7);\n display: none;\n align-items: center; justify-content: center;\n z-index: 100;\n }\n .modal-bg.open { display: flex; }\n .modal {\n background: var(--bg-card-strong);\n border: 1px solid var(--border-strong);\n border-radius: 16px;\n backdrop-filter: blur(30px);\n -webkit-backdrop-filter: blur(30px);\n box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);\n max-width: 720px;\n width: 90vw;\n max-height: 80vh;\n display: flex;\n flex-direction: column;\n }\n .modal-head {\n padding: 14px 18px;\n border-bottom: 1px solid var(--border-new);\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n .modal-title {\n font-size: 13px;\n font-weight: 700;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--accent);\n }\n .modal-close {\n background: transparent;\n border: 1px solid var(--border-new);\n color: var(--fg-2);\n font-family: inherit;\n font-size: 11px;\n letter-spacing: 0.06em;\n padding: 5px 10px;\n border-radius: 8px;\n cursor: pointer;\n }\n .modal-close:hover { color: var(--err-color); border-color: rgba(255, 85, 119, 0.40); }\n .modal-body {\n padding: 16px 18px;\n overflow-y: auto;\n font-size: 12.5px;\n line-height: 1.55;\n color: var(--fg-2);\n }\n .modal-body h3 {\n font-size: 11px;\n font-weight: 700;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--accent);\n margin: 14px 0 6px 0;\n border-bottom: 1px dashed var(--border-new);\n padding-bottom: 5px;\n }\n .modal-body h3:first-child { margin-top: 0; }\n .modal-body .item {\n padding: 7px 0;\n border-bottom: 1px dotted var(--border-new);\n }\n .modal-body .item:last-child { border: none; }\n .modal-body .name {\n color: var(--warm);\n font-weight: 600;\n font-size: 12px;\n }\n .modal-body .desc { color: var(--fg-3); margin-top: 2px; }\n .modal-body .plan-select {\n margin-top: 6px;\n font-family: inherit;\n font-size: 11px;\n font-weight: 700;\n padding: 4px 12px;\n border: 0;\n border-radius: 8px;\n cursor: pointer;\n background: var(--accent);\n color: #07091a;\n }\n .modal-body .plan-select:hover { filter: brightness(1.08); }\n .modal-body .plan-select[disabled] {\n background: var(--fg-faint);\n color: var(--fg-3);\n cursor: default;\n }\n .modal-body pre {\n background: rgba(0, 0, 0, 0.30);\n padding: 10px 12px;\n border-left: 2px solid var(--accent);\n border-radius: 4px;\n white-space: pre-wrap;\n margin: 6px 0;\n color: var(--fg);\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n font-size: 11.5px;\n line-height: 1.5;\n }\n .modal-body .empty { color: var(--fg-faint); font-style: italic; }\n\n /* ===================================================================\n Birth ritual overlay \u2014 full-screen, one-time. Uses legacy palette\n intentionally (it's a separate ceremonial moment).\n =================================================================== */\n .birth-overlay {\n position: fixed;\n inset: 0;\n background:\n radial-gradient(ellipse at center, rgba(40, 30, 80, 0.95) 0%, rgba(5, 5, 20, 1) 70%);\n z-index: 9999;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 32px;\n }\n .birth-overlay.open { display: flex; }\n .birth-content {\n position: relative;\n z-index: 2;\n width: min(800px, 95vw);\n max-height: 90vh;\n overflow-y: auto;\n }\n .birth-stars {\n text-align: center;\n color: var(--accent);\n font-size: 14px;\n letter-spacing: 8px;\n text-shadow: 0 0 8px var(--accent-glow);\n animation: starBlink 1.5s steps(3) infinite;\n }\n @keyframes starBlink {\n 0%, 30%, 100% { opacity: 1; }\n 50% { opacity: 0.4; }\n }\n .birth-title {\n text-align: center;\n color: var(--warm);\n font-size: 22px;\n font-weight: 700;\n letter-spacing: 6px;\n margin: 24px 0 32px;\n text-shadow: 0 0 12px rgba(255, 208, 102, 0.45);\n }\n .birth-step {\n margin: 14px 0;\n padding: 10px 16px;\n border-left: 3px solid var(--border-new);\n background: rgba(20, 26, 64, 0.55);\n border-radius: 0 8px 8px 0;\n opacity: 0;\n transform: translateY(8px);\n transition: opacity 0.4s ease-out, transform 0.4s ease-out;\n }\n .birth-step.shown { opacity: 1; transform: translateY(0); }\n .birth-step.active {\n border-left-color: var(--warm);\n background: rgba(255, 208, 102, 0.08);\n }\n .birth-step.done { border-left-color: var(--accent); }\n .birth-step .step-name {\n font-size: 10.5px;\n font-weight: 700;\n letter-spacing: 0.10em;\n color: var(--fg-3);\n text-transform: uppercase;\n margin-bottom: 4px;\n }\n .birth-step.active .step-name { color: var(--warm); }\n .birth-step.done .step-name { color: var(--accent); }\n .birth-step .step-detail {\n color: var(--fg);\n font-size: 13px;\n line-height: 1.55;\n word-break: break-word;\n }\n .birth-step .step-cursor {\n display: inline-block;\n width: 6px;\n background: var(--warm);\n height: 0.9em;\n vertical-align: middle;\n animation: blink 0.8s steps(2) infinite;\n }\n @keyframes blink { 50% { opacity: 0; } }\n .birth-final {\n margin-top: 36px;\n text-align: center;\n color: var(--warm);\n font-size: 15px;\n font-weight: 700;\n letter-spacing: 0.10em;\n text-shadow: 0 0 10px rgba(255, 208, 102, 0.4);\n opacity: 0;\n transition: opacity 0.6s ease-in;\n }\n .birth-final.shown { opacity: 1; }\n .birth-enter {\n margin: 24px auto 0;\n display: block;\n background: linear-gradient(180deg, var(--accent) 0%, #4eb8e5 100%);\n border: 0;\n color: #0a1024;\n font-family: inherit;\n font-weight: 700;\n font-size: 13px;\n padding: 12px 32px;\n border-radius: 12px;\n cursor: pointer;\n letter-spacing: 0.10em;\n opacity: 0;\n transition: opacity 0.6s ease-in, transform 120ms ease;\n box-shadow: 0 6px 18px rgba(106, 212, 255, 0.35);\n }\n .birth-enter.shown { opacity: 1; }\n .birth-enter:hover { transform: translateY(-1px); }\n .birth-error {\n color: var(--err-color);\n text-align: center;\n margin-top: 24px;\n font-size: 11.5px;\n letter-spacing: 0.05em;\n }\n\n /* ===================================================================\n API-key config overlay \u2014 same modernized treatment.\n =================================================================== */\n .cfg-overlay {\n position: fixed;\n inset: 0;\n background:\n radial-gradient(ellipse at center, rgba(40, 30, 80, 0.95) 0%, rgba(5, 5, 20, 1) 70%);\n z-index: 9998;\n display: none;\n align-items: center;\n justify-content: center;\n padding: 32px;\n }\n .cfg-overlay.open { display: flex; }\n .cfg-card {\n position: relative;\n z-index: 2;\n width: min(520px, 95vw);\n background: var(--bg-card-strong);\n border: 1px solid var(--border-strong);\n border-radius: 16px;\n backdrop-filter: blur(30px);\n -webkit-backdrop-filter: blur(30px);\n box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);\n padding: 28px 32px;\n }\n .cfg-stars {\n text-align: center;\n color: var(--accent);\n font-size: 12px;\n letter-spacing: 6px;\n text-shadow: 0 0 8px var(--accent-glow);\n animation: starBlink 1.5s steps(3) infinite;\n }\n .cfg-title {\n text-align: center;\n color: var(--warm);\n font-size: 16px;\n font-weight: 700;\n letter-spacing: 6px;\n margin: 16px 0 6px;\n }\n .cfg-sub {\n text-align: center;\n color: var(--fg-3);\n font-size: 12.5px;\n margin-bottom: 22px;\n line-height: 1.5;\n }\n .cfg-sub a { color: var(--accent); text-decoration: underline; }\n .cfg-field { display: block; margin: 14px 0; }\n .cfg-label {\n display: block;\n font-size: 10.5px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--accent);\n margin-bottom: 6px;\n }\n .cfg-label .opt {\n color: var(--fg-3);\n font-size: 9.5px;\n margin-left: 6px;\n text-transform: none;\n letter-spacing: 0;\n font-weight: 400;\n }\n .cfg-input {\n width: 100%;\n background: var(--bg-card);\n border: 1px solid var(--border-strong);\n border-radius: 10px;\n color: var(--fg);\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n font-size: 13px;\n padding: 10px 12px;\n }\n .cfg-input:focus {\n outline: none;\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-soft);\n }\n .cfg-help {\n color: var(--fg-3);\n font-size: 11px;\n margin-top: 6px;\n }\n .cfg-help code {\n color: var(--fg-2);\n background: rgba(0, 0, 0, 0.3);\n padding: 1px 5px;\n border-radius: 3px;\n font-size: 11px;\n }\n .cfg-actions {\n margin-top: 22px;\n display: flex;\n justify-content: center;\n }\n .cfg-save {\n background: linear-gradient(180deg, var(--accent) 0%, #4eb8e5 100%);\n border: 0;\n color: #0a1024;\n font-family: inherit;\n font-size: 12.5px;\n font-weight: 700;\n padding: 11px 28px;\n border-radius: 12px;\n cursor: pointer;\n letter-spacing: 0.10em;\n box-shadow: 0 4px 14px rgba(106, 212, 255, 0.25);\n transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;\n }\n .cfg-save:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(106, 212, 255, 0.35); }\n .cfg-save:active { transform: translateY(0); }\n .cfg-save:disabled { opacity: 0.55; cursor: wait; }\n .cfg-error {\n color: var(--err-color);\n font-size: 11px;\n text-align: center;\n margin-top: 14px;\n min-height: 14px;\n }\n</style>\n</head><body>\n\n<div class=\"frame\">\n\n <!-- \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 Title bar (drag zone) \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 -->\n <div class=\"titlebar\">\n <span>Lisa</span><span class=\"session-tag\" id=\"titlebarSession\"></span>\n </div>\n\n <!-- \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 Sidebar \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 -->\n <aside class=\"sidebar\">\n\n <!-- Identity card -->\n <div class=\"identity\">\n <div class=\"avatar-wrap\">\n <img id=\"mascot\" src=\"/assets/lisa-mascot.png\" alt=\"Lisa\" draggable=\"false\">\n </div>\n <div>\n <h1>Lisa</h1>\n <p class=\"sub\" id=\"identitySub\">\u2014</p>\n <div class=\"mood\" id=\"mascotTag\">neutral</div>\n </div>\n </div>\n\n <!-- Currently wanting -->\n <div class=\"sb-section\">\n <h2>currently wanting</h2>\n <p class=\"body-text\" id=\"sbDesire\">\u2014</p>\n </div>\n\n <!-- Claude Code monitor -->\n <div class=\"card tint-claude\" id=\"sbClaudeCard\">\n <div class=\"h\">\n <div class=\"left\">agents</div>\n <div class=\"count\">\u25B6\uFE0E <span id=\"sbClaudeCount\">0</span></div>\n </div>\n <form id=\"sbDelegate\" class=\"delegate\" autocomplete=\"off\">\n <select id=\"sbDelegateKind\" title=\"Agent type \u2014 managed (LISA-run) or a real CLI under a PTY (spike)\">\n <option value=\"managed\">managed</option>\n <option value=\"claude\">claude</option>\n <option value=\"codex\">codex</option>\n </select>\n <input id=\"sbDelegateTask\" type=\"text\" placeholder=\"delegate a task\u2026\" />\n <button type=\"submit\" title=\"Start an agent\">\u25B6</button>\n </form>\n <div id=\"sbClaudeRows\">\n <div class=\"session-empty\">(idle)</div>\n </div>\n </div>\n\n <!-- Last reflection (collapsed pointer to the most recent \u2605) -->\n <div class=\"card tint-idle\" id=\"sbReflection\" style=\"display:none;\">\n <div class=\"h\">\n <div class=\"left\">\u2605 last reflection</div>\n </div>\n <p style=\"margin:0; font-size:11.5px; color:var(--fg-2); line-height:1.5;\" id=\"sbReflectionBody\"></p>\n </div>\n\n <!-- SOUL / SKILLS / MEMORY / TOOLS row -->\n <div class=\"badges\">\n <button class=\"badge\" type=\"button\" data-panel=\"soul\"><img src=\"/assets/icon-soul.png\" alt=\"\">SOUL</button>\n <button class=\"badge\" type=\"button\" data-panel=\"skills\"><img src=\"/assets/icon-skill.png\" alt=\"\">SKILLS</button>\n <button class=\"badge\" type=\"button\" data-panel=\"memory\"><img src=\"/assets/icon-memory.png\" alt=\"\">MEMORY</button>\n <button class=\"badge\" type=\"button\" data-panel=\"tools\"><img src=\"/assets/icon-tool.png\" alt=\"\">TOOLS</button>\n <button class=\"badge\" type=\"button\" data-panel=\"plans\"><img src=\"/assets/icon-tool.png\" alt=\"\">PLANS</button>\n </div>\n\n <!-- Footer: current session id -->\n <div class=\"sb-footer\">\n <span class=\"session-id\" id=\"sessionId\">\u2014</span>\n <span class=\"badge-count\" id=\"sbSessionBadge\" title=\"total sessions\">\u00B7</span>\n </div>\n </aside>\n\n <!-- \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 Main pane \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 -->\n <div class=\"main\">\n\n <!-- Chat log (messages, tool blocks, idle blocks injected here) -->\n <div id=\"log\"></div>\n\n <!-- Attachment chip strip (above composer) -->\n <div id=\"attachPreview\"></div>\n\n <!-- Composer -->\n <form id=\"form\">\n <label id=\"attachBtn\" title=\"Attach file (or paste images directly into the textarea)\">\n <input type=\"file\" id=\"fileInput\" accept=\"image/*,.pdf,.txt,.md,.csv,.json\" multiple>\n \uD83D\uDCCE\n </label>\n <button type=\"button\" id=\"captureBtn\" title=\"Screenshot for Lisa (\u2303\u2325S anywhere)\">\uD83D\uDCF7</button>\n <button type=\"button\" id=\"recordBtn\" title=\"Dictate \u2014 speak and Lisa drops polished text in the box (hold to record a summary)\">\uD83C\uDF99</button>\n <textarea id=\"input\" placeholder=\"Talk to Lisa\u2026 (Enter to send \u00B7 Shift+Enter for newline)\" autofocus></textarea>\n <button type=\"submit\" id=\"sendBtn\">\n <img src=\"/assets/icon-send.png\" alt=\"\">\n SEND \u2192\n </button>\n </form>\n </div>\n</div>\n\n<!-- \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 Overlays \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 -->\n\n<div class=\"modal-bg\" id=\"modalBg\">\n <div class=\"modal\">\n <div class=\"modal-head\">\n <div class=\"modal-title\" id=\"modalTitle\">\u2026</div>\n <button class=\"modal-close\" id=\"modalClose\">Close \u00B7 esc</button>\n </div>\n <div class=\"modal-body\" id=\"modalBody\">\u2026</div>\n </div>\n</div>\n\n<!-- API key config overlay (shown if no key configured yet) -->\n<div class=\"cfg-overlay\" id=\"cfgOverlay\">\n <div class=\"cfg-card\">\n <div class=\"cfg-stars\">\u2726 \u2726 \u2726 \u2726 \u2726</div>\n <div class=\"cfg-title\">SET \u00B7 API \u00B7 KEY</div>\n <div class=\"cfg-sub\">\n Lisa needs an Anthropic API key to wake up.<br>\n <a href=\"https://console.anthropic.com/\" target=\"_blank\" rel=\"noopener\">Get one at console.anthropic.com</a>\n </div>\n <form id=\"cfgForm\">\n <label class=\"cfg-field\">\n <span class=\"cfg-label\">ANTHROPIC_API_KEY</span>\n <input class=\"cfg-input\" id=\"cfgAnthropic\" type=\"password\" autocomplete=\"off\"\n spellcheck=\"false\" placeholder=\"sk-ant-...\" required>\n </label>\n <label class=\"cfg-field\">\n <span class=\"cfg-label\">OPENAI_API_KEY <span class=\"opt\">(optional \u00B7 for gpt-* models)</span></span>\n <input class=\"cfg-input\" id=\"cfgOpenai\" type=\"password\" autocomplete=\"off\"\n spellcheck=\"false\" placeholder=\"sk-...\">\n </label>\n <div class=\"cfg-help\">\n Saved to <code>~/.lisa/config.env</code> with mode 0600. Stays on this machine.\n </div>\n <div class=\"cfg-actions\">\n <button class=\"cfg-save\" id=\"cfgSave\" type=\"submit\">SAVE &amp; CONTINUE</button>\n </div>\n <div class=\"cfg-error\" id=\"cfgError\"></div>\n </form>\n </div>\n</div>\n\n<!-- Birth ritual full-screen overlay -->\n<div class=\"birth-overlay\" id=\"birthOverlay\">\n <div class=\"birth-content\">\n <div class=\"birth-stars\">\u2726 \u2726 \u2726 \u2726 \u2726</div>\n <div class=\"birth-title\">B I R T H \u00B7 R I T U A L</div>\n <div id=\"birthSteps\"></div>\n <div class=\"birth-final\" id=\"birthFinal\"></div>\n <button class=\"birth-enter\" id=\"birthEnter\">ENTER</button>\n <div class=\"birth-error\" id=\"birthError\"></div>\n <div class=\"birth-stars\" style=\"margin-top: 24px;\">\u2726 \u2726 \u2726 \u2726 \u2726</div>\n </div>\n</div>\n\n<script>\n// \u2500\u2500 First-run safety net: never leave the user with a silent dead UI. Any\n// uncaught error / unreachable backend surfaces as a banner instead of nothing.\nfunction lisaBanner(msg) {\n var el = document.getElementById('lisaBanner');\n if (!el) {\n el = document.createElement('div');\n el.id = 'lisaBanner';\n el.style.cssText = 'position:fixed;left:50%;top:12px;transform:translateX(-50%);z-index:99999;max-width:min(700px,92vw);padding:11px 16px;border-radius:12px;background:rgba(255,85,119,.14);border:1px solid rgba(255,85,119,.5);color:#ffc2cf;font:13px/1.5 -apple-system,system-ui,sans-serif;box-shadow:0 8px 30px rgba(0,0,0,.5);user-select:text';\n document.body.appendChild(el);\n }\n el.textContent = msg;\n el.hidden = false;\n}\nfunction lisaClearBanner() { var el = document.getElementById('lisaBanner'); if (el) el.hidden = true; }\nfunction lisaSleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }\nwindow.addEventListener('error', function (e) {\n if (e && e.message) lisaBanner('Lisa UI error: ' + e.message + ' \u2014 reload (Cmd-R); restart the backend if it persists.');\n});\nwindow.addEventListener('unhandledrejection', function (e) {\n var r = e && e.reason;\n lisaBanner('Lisa task failed: ' + ((r && r.message) ? r.message : String(r)));\n});\n\nconst log = document.getElementById('log');\nconst input = document.getElementById('input');\nconst form = document.getElementById('form');\nconst sendBtn = document.getElementById('sendBtn');\nconst sessionEl = document.getElementById('sessionId');\nconst fileInput = document.getElementById('fileInput');\nconst attachPreview = document.getElementById('attachPreview');\n\n// \u2500\u2500 Attached files state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet pendingFiles = []; // Array of {name, mediaType, data (base64)}\n\nfunction readFileAsBase64(file) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const dataUrl = reader.result;\n const base64 = dataUrl.split(',')[1];\n resolve(base64);\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\n\nfunction inferMediaType(file) {\n if (file.type) return file.type;\n const ext = file.name.split('.').pop().toLowerCase();\n const map = { pdf: 'application/pdf', txt: 'text/plain', md: 'text/plain', csv: 'text/csv', json: 'application/json' };\n return map[ext] || 'application/octet-stream';\n}\n\n// \u2500\u2500 Vision: screenshot \u2192 composer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// lisaAttachImage adds an already-encoded {name,mediaType,data} object to\n// the pending attachments \u2014 used by both the in-page \uD83D\uDCF7 button and the\n// native global hotkey (Lisa.app calls lisaCaptureAndAttach via JS bridge).\nwindow.lisaAttachImage = function (file) {\n if (!file || !file.data) return;\n pendingFiles.push({\n name: file.name || 'screenshot.png',\n mediaType: file.mediaType || 'image/png',\n data: file.data,\n });\n renderAttachPreview();\n try { input.focus(); } catch (_) {}\n};\n\n// lisaPrefillComposer drops text into the composer WITHOUT sending \u2014 the user\n// reads it and hits Enter to send (that send is the confirmation). Used by the\n// island's screen-advisor \"Optimize \u25B8\" card (via the Swift bridge) and by the\n// ?prefill= URL param (plain browser tabs).\nwindow.lisaPrefillComposer = function (text) {\n if (!text || !input) return;\n input.value = String(text);\n try { input.dispatchEvent(new Event('input', { bubbles: true })); } catch (_) {}\n try { input.focus(); input.setSelectionRange(input.value.length, input.value.length); } catch (_) {}\n};\n\n// On load, honour ?prefill=\u2026 (the island opens /?prefill=<task> in a browser\n// tab). Strip it from the URL afterwards so a refresh doesn't re-fill.\ntry {\n var _pf = new URLSearchParams(location.search).get('prefill');\n if (_pf) {\n window.lisaPrefillComposer(_pf);\n history.replaceState(null, '', location.pathname);\n }\n} catch (_) {}\n\n// lisaCaptureAndAttach asks the server to run a screen capture, then\n// attaches the result. mode: 'interactive' (crosshair, default) | 'full'.\n// Returns true if an image was attached, false if cancelled/failed.\n// Exposed on window so the native app's global hotkey can invoke it.\nlet capturing = false;\nwindow.lisaCaptureAndAttach = async function (mode) {\n if (capturing) return false;\n capturing = true;\n const btn = document.getElementById('captureBtn');\n if (btn) btn.classList.add('flash');\n try {\n const res = await fetch('/api/vision/capture', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ mode: mode || 'interactive' }),\n });\n if (!res.ok) {\n console.warn('[vision] capture failed: HTTP ' + res.status);\n return false;\n }\n const data = await res.json();\n if (data.cancelled || !data.file) return false;\n window.lisaAttachImage(data.file);\n return true;\n } catch (err) {\n console.warn('[vision] capture error:', err);\n return false;\n } finally {\n capturing = false;\n if (btn) setTimeout(() => btn.classList.remove('flash'), 200);\n }\n};\n\nfunction renderAttachPreview() {\n attachPreview.innerHTML = '';\n pendingFiles.forEach((f, i) => {\n const chip = document.createElement('span');\n chip.className = 'attach-chip';\n chip.textContent = f.name;\n const rm = document.createElement('button');\n rm.type = 'button';\n rm.className = 'attach-rm';\n rm.textContent = '\u00D7';\n rm.onclick = () => { pendingFiles.splice(i, 1); renderAttachPreview(); };\n chip.appendChild(rm);\n attachPreview.appendChild(chip);\n });\n}\n\nfileInput.addEventListener('change', async () => {\n for (const file of fileInput.files) {\n const data = await readFileAsBase64(file);\n pendingFiles.push({ name: file.name, mediaType: inferMediaType(file), data });\n }\n fileInput.value = '';\n renderAttachPreview();\n});\n\n// The natural <label>\u2192<input type=file> click-forwarding is supposed\n// to open the picker without any JS. But in WKWebView under our\n// layout it never reaches runOpenPanel. We add an explicit click\n// listener that forwards the click synchronously (preserving the\n// user-gesture context) and logs to console so we can verify in the\n// inspector if it ever silently no-ops again.\n// \uD83D\uDCF7 capture button \u2192 interactive crosshair screenshot into the composer.\nconst captureBtnEl = document.getElementById('captureBtn');\nif (captureBtnEl) {\n captureBtnEl.addEventListener('click', () => { void window.lisaCaptureAndAttach('interactive'); });\n}\n\n// \u2500\u2500 Audio recording \u2192 transcribe \u2192 Lisa summarizes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \uD83C\uDF99 toggles a MediaRecorder. On stop: POST the clip to /api/voice/transcribe\n// (server-side Whisper), then send the transcript into the normal chat with a\n// \"summarize this\" framing \u2014 so Lisa produces the summary in her own voice and\n// it's persisted + discussable like any turn. First click prompts mic\n// permission (browser-native).\nconst recordBtnEl = document.getElementById('recordBtn');\nlet mediaRecorder = null;\nlet recordedChunks = [];\nlet recordStream = null;\n// 'dictation' (default): polish speech \u2192 composer for you to edit + send.\n// 'summary' (long-press \uD83C\uDF99): the original record\u2192Lisa-summarizes flow.\nlet recordMode = 'dictation';\n\nfunction pickAudioMime() {\n const prefs = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg'];\n for (const m of prefs) {\n try { if (window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m; } catch (_) {}\n }\n return '';\n}\n\nasync function startRecording() {\n if (!navigator.mediaDevices || !window.MediaRecorder) {\n el('div', 'err', '[voice] recording not supported in this browser');\n return;\n }\n try {\n recordStream = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch (err) {\n el('div', 'err', '[voice] microphone access denied or unavailable');\n return;\n }\n const mimeType = pickAudioMime();\n recordedChunks = [];\n mediaRecorder = mimeType ? new MediaRecorder(recordStream, { mimeType }) : new MediaRecorder(recordStream);\n mediaRecorder.addEventListener('dataavailable', (e) => { if (e.data && e.data.size > 0) recordedChunks.push(e.data); });\n mediaRecorder.addEventListener('stop', () => { void finishRecording(); });\n mediaRecorder.start();\n if (recordBtnEl) { recordBtnEl.classList.add('recording'); recordBtnEl.textContent = '\u23F9'; recordBtnEl.title = 'Stop recording'; }\n}\n\nfunction stopRecordingTracks() {\n if (recordStream) { recordStream.getTracks().forEach((t) => t.stop()); recordStream = null; }\n if (recordBtnEl) { recordBtnEl.classList.remove('recording'); recordBtnEl.textContent = '\uD83C\uDF99'; recordBtnEl.title = 'Dictate \u2014 speak and Lisa drops polished text in the box (hold to record a summary)'; }\n}\n\nasync function finishRecording() {\n const mime = (mediaRecorder && mediaRecorder.mimeType) || 'audio/webm';\n stopRecordingTracks();\n const mode = recordMode;\n const blob = new Blob(recordedChunks, { type: mime });\n recordedChunks = [];\n if (blob.size === 0) return;\n // Transient status line: dictation polishes; summary transcribes.\n const pending = el('div', 'thinking', mode === 'dictation' ? '\u22EF transcribing + polishing' : '\u22EF transcribing recording');\n try {\n const data = await blobToBase64(blob);\n const res = await fetch('/api/voice/transcribe', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ data, mediaType: mime, mode }),\n });\n const out = await res.json();\n if (pending) pending.remove();\n if (!res.ok || out.error) {\n el('div', 'err', '[voice] ' + (out.error || ('HTTP ' + res.status)));\n return;\n }\n if (mode === 'dictation') {\n // Typeless-style: drop the polished text into the composer (cursor at end)\n // for you to review and send. Never auto-sends. Appends if you'd already\n // started typing.\n const text = (out.text || out.transcript || '').trim();\n if (!text) { el('div', 'err', '[voice] (nothing dictated)'); return; }\n const existing = input.value.trimEnd();\n input.value = existing ? existing + '\\n' + text : text;\n try { input.dispatchEvent(new Event('input', { bubbles: true })); } catch (_) {}\n try { input.focus(); input.setSelectionRange(input.value.length, input.value.length); } catch (_) {}\n return;\n }\n // summary mode: hand the transcript to Lisa with a summarize framing.\n const transcript = (out.transcript || '').trim();\n if (!transcript) { el('div', 'err', '[voice] (empty transcript)'); return; }\n const framed =\n \"I just recorded some audio. Here's the transcript \u2014 please give me a clear, \" +\n \"useful summary (key points, decisions, action items if any), then I might ask follow-ups.\\n\\n\" +\n \"--- transcript ---\\n\" + transcript;\n send(framed);\n } catch (err) {\n if (pending) pending.remove();\n el('div', 'err', '[voice] ' + (err && err.message ? err.message : 'transcription failed'));\n }\n}\n\nfunction blobToBase64(blob) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result).split(',')[1] || '');\n reader.onerror = reject;\n reader.readAsDataURL(blob);\n });\n}\n\nif (recordBtnEl) {\n // Short click \u2192 dictation (polished text into the composer). Press-and-hold\n // (\u2265500ms) \u2192 summary (record \u2192 Lisa summarizes). A click always follows\n // pointerup, so we just flag long-presses and read the flag on click.\n let longPress = false;\n let holdTimer = null;\n const clearHold = () => { if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; } };\n recordBtnEl.addEventListener('pointerdown', () => {\n longPress = false;\n clearHold();\n holdTimer = setTimeout(() => { longPress = true; }, 500);\n });\n recordBtnEl.addEventListener('pointerup', clearHold);\n recordBtnEl.addEventListener('pointercancel', clearHold);\n recordBtnEl.addEventListener('pointerleave', clearHold);\n recordBtnEl.addEventListener('click', () => {\n if (mediaRecorder && mediaRecorder.state === 'recording') {\n mediaRecorder.stop();\n longPress = false;\n return;\n }\n recordMode = longPress ? 'summary' : 'dictation';\n longPress = false;\n if (recordBtnEl) recordBtnEl.title = recordMode === 'summary' ? 'Recording for summary \u2014 click to stop' : 'Dictating \u2014 click to stop';\n void startRecording();\n });\n}\n\nconst attachBtnEl = document.getElementById('attachBtn');\nif (attachBtnEl) {\n attachBtnEl.addEventListener('click', (ev) => {\n // Don't preventDefault \u2014 that cancels the implicit label-forward\n // and removes the redundant gesture path. Letting both fire is\n // fine because <input type=file>.click() fires the picker only\n // once per user gesture.\n console.log('[attach] click forwarded to fileInput');\n try {\n fileInput.click();\n } catch (err) {\n console.error('[attach] fileInput.click failed:', err);\n }\n });\n}\n\n// Paste-to-attach: when the user has copied an image (screenshot,\n// image from a webpage, etc.) and presses \u2318V, pull the image off the\n// clipboard and add it to pendingFiles \u2014 same path the file picker\n// uses. Plain-text paste falls through to default behavior.\n//\n// We listen at the DOCUMENT level so paste works whether the textarea\n// is focused, the chat log is focused, or focus is on a tool-block.\n// (Some users press \u2318V right after launching the app, before clicking\n// anywhere \u2014 element-scoped handlers miss that.)\n//\n// For text paste we MUST NOT preventDefault \u2014 let the browser route\n// it to the focused element. Only intercept when we detect image\n// items.\nasync function handlePastedClipboard(ev) {\n const cb = ev.clipboardData;\n if (!cb) return;\n\n // Two clipboard surfaces \u2014 items (modern, exposes pasteboard files)\n // and files (older, doesn't include MIME type for some types). We\n // dedupe via the underlying File reference.\n const fileMap = new Map(); // File \u2192 mediaType (string)\n if (cb.items && cb.items.length) {\n for (const item of cb.items) {\n if (item.kind !== 'file') continue;\n if (!item.type || !item.type.startsWith('image/')) continue;\n const f = item.getAsFile();\n if (f) fileMap.set(f, item.type);\n }\n }\n if (cb.files && cb.files.length) {\n for (const f of cb.files) {\n if (f.type && f.type.startsWith('image/')) {\n if (!fileMap.has(f)) fileMap.set(f, f.type || 'image/png');\n }\n }\n }\n\n if (fileMap.size === 0) return; // text paste \u2014 leave default behavior alone\n\n ev.preventDefault();\n // Keep focus on the textarea so the user can keep typing right after.\n try { input.focus(); } catch (_) {}\n\n for (const [file, mediaType] of fileMap) {\n try {\n const data = await readFileAsBase64(file);\n const ext = (mediaType.split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '');\n const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);\n const name = file.name && file.name !== 'image.png'\n ? file.name\n : 'pasted-' + ts + '.' + ext;\n pendingFiles.push({ name, mediaType, data });\n } catch (err) {\n console.warn('[paste] failed to read image:', err);\n }\n }\n renderAttachPreview();\n}\n\ninput.addEventListener('paste', (ev) => { void handlePastedClipboard(ev); });\n// Document-level fallback. If focus is somewhere other than the\n// textarea (e.g. just-launched, no focus yet), the textarea-level\n// paste listener never fires; this one catches it.\ndocument.addEventListener('paste', (ev) => {\n // Avoid double-firing when the textarea ALREADY handled it: if\n // ev.defaultPrevented is set, the textarea listener already\n // consumed the paste and added the file.\n if (ev.defaultPrevented) return;\n void handlePastedClipboard(ev);\n});\n\n// Surface session id from server header on first request. The titlebar\n// shows the same id with a leading \"\u00B7\" separator after the \"Lisa\" label.\nfetch('/session').then(r => r.json()).then(s => {\n sessionEl.textContent = s.id;\n const titlebarSession = document.getElementById('titlebarSession');\n if (titlebarSession) titlebarSession.textContent = '\u00B7 ' + s.id;\n});\n\n// \u2500\u2500 Persistent /events SSE: mood updates + idle messages + Claude\n// activity, lifetime of page.\nfunction connectEvents() {\n const es = new EventSource('/events');\n let idlePulseEl = null;\n es.addEventListener('message', (e) => {\n const ev = JSON.parse(e.data);\n if (ev.type === 'mood') {\n setMood(ev.slug);\n } else if (ev.type === 'idle_start') {\n if (!idlePulseEl) {\n idlePulseEl = document.createElement('div');\n idlePulseEl.className = 'idle-pulse';\n idlePulseEl.textContent = '\u22EF Lisa is thinking on her own time \u22EF';\n log.appendChild(idlePulseEl);\n log.scrollTop = log.scrollHeight;\n }\n } else if (ev.type === 'idle_message') {\n if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }\n const block = document.createElement('div');\n block.className = 'idle-block';\n const head = document.createElement('div');\n head.className = 'idle-head';\n head.textContent = '\u2605 WHILE YOU WERE AWAY';\n const time = document.createElement('span');\n time.className = 'idle-time';\n try { time.textContent = new Date(ev.at).toLocaleTimeString(); } catch {}\n head.appendChild(time);\n block.appendChild(head);\n const bodyEl = document.createElement('div');\n bodyEl.textContent = ev.text;\n block.appendChild(bodyEl);\n log.appendChild(block);\n log.scrollTop = log.scrollHeight;\n // sidebar reflection card mirrors the latest while-you-were-away\n if (typeof updateReflection === 'function') updateReflection(ev.text);\n } else if (ev.type === 'idle_done') {\n if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }\n } else if (ev.type === 'idle_error') {\n if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }\n const e2 = document.createElement('div');\n e2.className = 'err';\n e2.textContent = '[idle error] ' + ev.message;\n log.appendChild(e2);\n } else if (ev.type === 'agent_session_update') {\n // D4a \u2014 sidebar multi-agent monitor refresh (defined later in the\n // \"sidebar live wiring\" block). Generalized from claude_session_update\n // so codex / opencode / git / \u2026 sessions update the sidebar too.\n if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions();\n }\n });\n es.onerror = () => {\n es.close();\n setTimeout(connectEvents, 3000); // reconnect\n };\n}\nconnectEvents();\n\n// \u2500\u2500 API key config gate: show overlay if no key is configured \u2500\u2500\u2500\u2500\u2500\nconst cfgOverlay = document.getElementById('cfgOverlay');\nconst cfgForm = document.getElementById('cfgForm');\nconst cfgAnthropic = document.getElementById('cfgAnthropic');\nconst cfgOpenai = document.getElementById('cfgOpenai');\nconst cfgSaveBtn = document.getElementById('cfgSave');\nconst cfgError = document.getElementById('cfgError');\n\ncfgForm.addEventListener('submit', async (ev) => {\n ev.preventDefault();\n cfgError.textContent = '';\n const anthropic = cfgAnthropic.value.trim();\n const openai = cfgOpenai.value.trim();\n if (!anthropic) {\n cfgError.textContent = 'ANTHROPIC_API_KEY is required.';\n return;\n }\n cfgSaveBtn.disabled = true;\n try {\n const res = await fetch('/api/config/save', {\n method: 'POST',\n headers: {'content-type': 'application/json'},\n body: JSON.stringify({ anthropicKey: anthropic, openaiKey: openai || undefined }),\n });\n if (!res.ok) {\n const txt = await res.text().catch(() => '');\n cfgError.textContent = 'Save failed: HTTP ' + res.status + (txt ? ' \u2014 ' + txt.slice(0, 120) : '');\n cfgSaveBtn.disabled = false;\n return;\n }\n cfgAnthropic.value = '';\n cfgOpenai.value = '';\n cfgOverlay.classList.remove('open');\n maybeBirth();\n } catch (err) {\n cfgError.textContent = 'Save failed: ' + err.message;\n cfgSaveBtn.disabled = false;\n }\n});\n\n// \u2500\u2500 Birth ritual: show overlay if Lisa hasn't been born yet \u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst birthOverlay = document.getElementById('birthOverlay');\nconst birthStepsEl = document.getElementById('birthSteps');\nconst birthFinalEl = document.getElementById('birthFinal');\nconst birthEnterBtn = document.getElementById('birthEnter');\nconst birthErrorEl = document.getElementById('birthError');\n\nbirthEnterBtn.addEventListener('click', () => {\n birthOverlay.classList.remove('open');\n setTimeout(() => location.reload(), 300);\n});\n\nasync function maybeBirth() {\n const status = await fetch('/api/soul').then(r => r.json());\n if (status.born) return;\n birthOverlay.classList.add('open');\n startBirthStream();\n}\n\nfunction appendBirthStep(step) {\n const prevActive = birthStepsEl.querySelector('.birth-step.active');\n if (prevActive) {\n prevActive.classList.remove('active');\n prevActive.classList.add('done');\n }\n const div = document.createElement('div');\n div.className = 'birth-step active';\n const name = document.createElement('div');\n name.className = 'step-name';\n name.textContent = step;\n div.appendChild(name);\n const detail = document.createElement('div');\n detail.className = 'step-detail';\n const cursor = document.createElement('span');\n cursor.className = 'step-cursor';\n detail.appendChild(cursor);\n div.appendChild(detail);\n birthStepsEl.appendChild(div);\n setTimeout(() => div.classList.add('shown'), 50);\n return detail;\n}\n\nfunction typewriter(el, text, done) {\n const cursor = el.querySelector('.step-cursor');\n if (cursor) cursor.remove();\n let i = 0;\n const speed = Math.max(8, Math.min(28, 600 / text.length));\n function tick() {\n if (i >= text.length) {\n const c = document.createElement('span');\n c.className = 'step-cursor';\n el.appendChild(c);\n done && done();\n return;\n }\n el.appendChild(document.createTextNode(text[i]));\n i++;\n el.parentElement.parentElement.scrollTop = el.parentElement.parentElement.scrollHeight;\n setTimeout(tick, speed);\n }\n tick();\n}\n\nasync function startBirthStream() {\n birthErrorEl.textContent = '';\n let currentDetail = null;\n let queue = [];\n let processing = false;\n\n function processQueue() {\n if (processing) return;\n if (queue.length === 0) return;\n processing = true;\n const ev = queue.shift();\n if (ev.kind === 'step') {\n currentDetail = appendBirthStep(ev.name);\n typewriter(currentDetail, ev.detail || '', () => {\n processing = false;\n processQueue();\n });\n } else if (ev.kind === 'done') {\n const last = birthStepsEl.querySelector('.birth-step.active');\n if (last) { last.classList.remove('active'); last.classList.add('done'); }\n birthFinalEl.textContent = ev.message;\n birthFinalEl.classList.add('shown');\n birthEnterBtn.classList.add('shown');\n processing = false;\n } else if (ev.kind === 'error') {\n birthErrorEl.textContent = ev.message;\n processing = false;\n }\n }\n\n try {\n const res = await fetch('/api/birth', { method: 'POST' });\n if (!res.ok) {\n birthErrorEl.textContent = 'Birth failed: HTTP ' + res.status + '. Check ANTHROPIC_API_KEY.';\n return;\n }\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buf = '';\n while (true) {\n const { value, done } = await reader.read();\n if (done) break;\n buf += decoder.decode(value, { stream: true });\n let idx;\n while ((idx = buf.indexOf('\\n\\n')) >= 0) {\n const event = buf.slice(0, idx);\n buf = buf.slice(idx + 2);\n const m = event.match(/^data: (.*)$/m);\n if (!m) continue;\n const ev = JSON.parse(m[1]);\n queue.push(ev);\n processQueue();\n }\n }\n } catch (err) {\n birthErrorEl.textContent = 'Birth failed: ' + err.message;\n }\n}\n\nasync function startupGate() {\n // Retry: the page can render a hair before the backend's API routes answer,\n // and a transient miss must not silently skip onboarding (the old bug \u2014 a\n // bare catch+return left a dead UI with no key prompt, no birth, no message).\n let cfg = null;\n for (let i = 0; i < 6 && !cfg; i++) {\n try { cfg = await fetch('/api/config/status').then(r => r.json()); }\n catch (e) { if (i < 5) await lisaSleep(700); }\n }\n if (!cfg) {\n lisaBanner('Cannot reach Lisa backend on localhost:5757. Start it: lisa serve --web (install once: npm i -g @oratis/lisa). Retrying\u2026');\n setTimeout(startupGate, 3000);\n return;\n }\n lisaClearBanner();\n if (!cfg.configured) {\n cfgOverlay.classList.add('open');\n setTimeout(() => cfgAnthropic.focus(), 50);\n return;\n }\n try {\n await maybeBirth();\n } catch (e) {\n lisaBanner('Birth check failed: ' + ((e && e.message) ? e.message : e) + ' \u2014 reload to retry.');\n }\n}\nstartupGate();\n\n// \u2500\u2500 history load & infinite-scroll \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet historyPage = 0;\nlet historyLoading = false;\nlet historyExhausted = false;\n\nfunction textOfMessage(msg) {\n if (typeof msg.content === 'string') return msg.content.trim();\n if (!Array.isArray(msg.content)) return '';\n return msg.content\n .filter(b => b.type === 'text')\n .map(b => b.text)\n .join('')\n .trim();\n}\n\nfunction prependHistoryMessages(messages) {\n const fragment = document.createDocumentFragment();\n for (const msg of messages) {\n const text = textOfMessage(msg);\n if (!text) continue;\n const roleDiv = document.createElement('div');\n roleDiv.className = 'role ' + (msg.role === 'user' ? 'you' : 'lisa');\n roleDiv.textContent = msg.role === 'user' ? 'YOU' : 'LISA';\n const span = document.createElement('span');\n span.className = 'msg';\n span.textContent = text;\n fragment.appendChild(roleDiv);\n fragment.appendChild(span);\n }\n log.insertBefore(fragment, log.firstChild);\n}\n\nasync function loadHistoryPage() {\n if (historyLoading || historyExhausted) return;\n historyLoading = true;\n const prevScrollHeight = log.scrollHeight;\n try {\n const res = await fetch('/api/history?page=' + historyPage);\n const data = await res.json();\n if (data.messages && data.messages.length) {\n prependHistoryMessages(data.messages);\n log.scrollTop = log.scrollHeight - prevScrollHeight;\n historyPage++;\n }\n if (!data.hasMore) {\n historyExhausted = true;\n if (historyPage > 1) {\n const marker = document.createElement('div');\n marker.style.cssText = 'text-align:center;color:var(--fg-3);font-size:11px;padding:8px 0;letter-spacing:0.06em;';\n marker.textContent = '\u2014 end of history \u2014';\n log.insertBefore(marker, log.firstChild);\n }\n }\n } finally {\n historyLoading = false;\n }\n}\n\nloadHistoryPage();\nlog.addEventListener('scroll', () => {\n if (log.scrollTop < 80) loadHistoryPage();\n});\n\n// \u2500\u2500 mascot crossfade on mood event \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst mascotEl = document.getElementById('mascot');\nconst mascotTagEl = document.getElementById('mascotTag');\nlet currentMood = 'neutral';\nfunction setMood(slug) {\n if (!slug || slug === currentMood) return;\n const url = '/assets/lisa/' + encodeURIComponent(slug) + '.png';\n const probe = new Image();\n probe.onload = () => {\n mascotEl.classList.add('fading');\n setTimeout(() => {\n mascotEl.src = url;\n mascotTagEl.textContent = 'mood: ' + slug;\n mascotEl.classList.remove('fading');\n currentMood = slug;\n }, 250);\n };\n probe.onerror = () => { /* asset not generated yet \u2014 keep current */ };\n probe.src = url;\n}\n\n// \u2500\u2500 modal panel: SOUL / SKILLS / MEMORY / TOOLS \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst modalBg = document.getElementById('modalBg');\nconst modalTitle = document.getElementById('modalTitle');\nconst modalBody = document.getElementById('modalBody');\nconst modalClose = document.getElementById('modalClose');\n\nfunction escapeHtml(s) {\n return String(s).replace(/[&<>\"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;'}[c]));\n}\nfunction openModal(title, html) {\n modalTitle.textContent = title;\n modalBody.innerHTML = html;\n modalBg.classList.add('open');\n}\nfunction closeModal() { modalBg.classList.remove('open'); }\nmodalClose.addEventListener('click', closeModal);\nmodalBg.addEventListener('click', (e) => { if (e.target === modalBg) closeModal(); });\ndocument.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });\n\nasync function showSkills() {\n openModal('SKILLS', '<div class=\"empty\">loading\u2026</div>');\n const data = await fetch('/api/skills').then(r => r.json());\n if (!data.skills.length) {\n modalBody.innerHTML = '<div class=\"empty\">No skills saved yet. Lisa will start saving useful workflows as you use her.</div>';\n return;\n }\n modalBody.innerHTML = data.skills.map(s =>\n '<div class=\"item\"><div class=\"name\">' + escapeHtml(s.name) + '</div><div class=\"desc\">' + escapeHtml(s.description) + '</div></div>'\n ).join('');\n}\n\nasync function showMemory() {\n openModal('MEMORY', '<div class=\"empty\">loading\u2026</div>');\n const data = await fetch('/api/memory').then(r => r.json());\n modalBody.innerHTML =\n '<h3>USER.md \u2014 what Lisa remembers about you</h3>' +\n '<pre>' + escapeHtml(data.user || '(empty)') + '</pre>' +\n '<h3>MEMORY.md \u2014 Lisa\\'s working notes</h3>' +\n '<pre>' + escapeHtml(data.memory || '(empty)') + '</pre>';\n}\n\nasync function showTools() {\n openModal('TOOLS', '<div class=\"empty\">loading\u2026</div>');\n const data = await fetch('/api/tools').then(r => r.json());\n modalBody.innerHTML = data.tools.map(t =>\n '<div class=\"item\"><div class=\"name\">' + escapeHtml(t.name) + '</div><div class=\"desc\">' + escapeHtml(t.description) + '</div></div>'\n ).join('');\n}\n\nasync function showPlans() {\n openModal('CODING PLANS', '<div class=\"empty\">loading\u2026</div>');\n let data;\n try { data = await fetch('/api/plans').then(r => r.json()); }\n catch (e) { modalBody.innerHTML = '<div class=\"empty\">failed to load plans</div>'; return; }\n const intro = '<div class=\"empty\">Run coding work on a subscription you already pay for (Claude Pro/Max \u00B7 ChatGPT/Codex \u00B7 Copilot) instead of a metered API key. LISA drives the vendor CLI you are logged into, so it bills your plan, not a key. This does not change her own model.</div>';\n const rows = (data.plans || []).map(function (p) {\n const star = p.selected ? ' \u2014 selected' : '';\n const usage = p.usage ? '<div class=\"desc\">usage: ' + escapeHtml(p.usage) + '</div>' : '';\n let btn;\n if (p.selected) btn = '<button class=\"plan-select\" data-plan=\"' + escapeHtml(p.id) + '\" disabled>selected</button>';\n else if (p.available) btn = '<button class=\"plan-select\" data-plan=\"' + escapeHtml(p.id) + '\">use this</button>';\n else btn = '<button class=\"plan-select\" data-plan=\"' + escapeHtml(p.id) + '\" disabled>not installed</button>';\n return '<div class=\"item\"><div class=\"name\">' + escapeHtml(p.mark + ' ' + p.label + star) + '</div><div class=\"desc\">' + escapeHtml(p.detail) + '</div>' + usage + btn + '</div>';\n }).join('');\n const clear = '<div class=\"item\"><button class=\"plan-select\" data-plan=\"none\">clear selection</button></div>';\n modalBody.innerHTML = intro + rows + clear;\n document.querySelectorAll('.plan-select').forEach(function (btn) {\n if (btn.disabled) return;\n btn.addEventListener('click', function () { selectPlan(btn.dataset.plan); });\n });\n}\n\nasync function selectPlan(plan) {\n try {\n const res = await fetch('/api/plans/select', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ plan: plan }),\n });\n if (!res.ok) {\n const t = await res.text().catch(function () { return ''; });\n modalBody.insertAdjacentHTML('afterbegin', '<div class=\"empty\" style=\"color:var(--err-color)\">select failed: ' + escapeHtml(String(res.status) + (t ? ' \u2014 ' + t : '')) + '</div>');\n return;\n }\n showPlans();\n } catch (e) {\n modalBody.insertAdjacentHTML('afterbegin', '<div class=\"empty\" style=\"color:var(--err-color)\">select error: ' + escapeHtml(e.message) + '</div>');\n }\n}\n\nasync function showSoul() {\n openModal('\u2605 SOUL', '<div class=\"empty\">loading\u2026</div>');\n const data = await fetch('/api/soul').then(r => r.json());\n if (!data.born) {\n modalBody.innerHTML = '<div class=\"empty\">Lisa hasn\\'t been born yet. Restart the CLI without --no-birth and the birth ritual will run.</div>';\n return;\n }\n const s = data.summary;\n let html = '';\n html += '<h3>name</h3><div>' + escapeHtml(s.name) + '</div>';\n html += '<h3>born</h3><div>' + escapeHtml(s.seed.bornAt) + ' \u00B7 big5(O' + Math.round(s.seed.bigFive.openness*100) + ' C' + Math.round(s.seed.bigFive.conscientiousness*100) + ' E' + Math.round(s.seed.bigFive.extraversion*100) + ' A' + Math.round(s.seed.bigFive.agreeableness*100) + ' N' + Math.round(s.seed.bigFive.neuroticism*100) + ')</div>';\n html += '<h3>identity</h3><pre>' + escapeHtml(s.identity) + '</pre>';\n html += '<h3>purpose</h3><pre>' + escapeHtml(s.purpose) + '</pre>';\n html += '<h3>constitution</h3><pre>' + escapeHtml(s.constitution) + '</pre>';\n if (s.values?.length) {\n html += '<h3>values</h3>' + s.values.map(v =>\n '<div class=\"item\"><div class=\"name\">' + escapeHtml(v.title) + '</div><div class=\"desc\">' + escapeHtml(v.body) + '</div></div>'\n ).join('');\n }\n if (s.opinions?.length) {\n html += '<h3>opinions</h3>' + s.opinions.map(o =>\n '<div class=\"item\"><div class=\"name\">' + escapeHtml(o.stance) + ' (conf ' + o.confidence.toFixed(2) + ')</div></div>'\n ).join('');\n }\n if (s.desires?.length) {\n html += '<h3>desires</h3>' + s.desires.map(d =>\n '<div class=\"item\"><div class=\"name\">' + escapeHtml(d.what) + (d.actionable ? ' [heartbeat-active]' : '') + '</div><div class=\"desc\">' + escapeHtml(d.why) + '</div></div>'\n ).join('');\n }\n html += '<h3>emotions</h3>' + Object.entries(s.emotions.values).map(([k, v]) => {\n const len = 12;\n const filled = Math.round(Math.abs(v) * len);\n const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(len - filled);\n return '<div class=\"item\"><div class=\"name\">' + escapeHtml(k) + '</div><div class=\"desc\">' + (v < 0 ? '-' : ' ') + bar + ' ' + v.toFixed(2) + '</div></div>';\n }).join('');\n if (s.tampered?.length) {\n html += '<h3>\u26A0 tampered</h3><div>External edits detected on: ' + s.tampered.map(escapeHtml).join(', ') + '</div>';\n }\n html += '<h3 style=\"color: var(--fg-3); font-size: 10px;\">privacy note</h3><div class=\"empty\">Her journal lives at ~/.lisa/soul/journal/ but is intentionally not shown here \u2014 that is hers to keep.</div>';\n modalBody.innerHTML = html;\n}\n\ndocument.querySelectorAll('.badge').forEach(b => {\n b.addEventListener('click', () => {\n const which = b.dataset.panel;\n if (which === 'soul') showSoul();\n else if (which === 'skills') showSkills();\n else if (which === 'memory') showMemory();\n else if (which === 'tools') showTools();\n else if (which === 'plans') showPlans();\n });\n});\n\nlet currentLisaSpan = null;\nlet pendingTools = new Map();\nlet thinkingEl = null;\n\nfunction el(tag, cls, text) {\n const node = document.createElement(tag);\n if (cls) node.className = cls;\n if (text != null) node.textContent = text;\n log.appendChild(node);\n log.scrollTop = log.scrollHeight;\n return node;\n}\n\nfunction ensureLisaSpan() {\n if (currentLisaSpan) return currentLisaSpan;\n if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }\n el('div', 'role lisa', 'LISA');\n currentLisaSpan = el('span', 'msg', '');\n return currentLisaSpan;\n}\n\nfunction previewInput(name, input) {\n if (!input || typeof input !== 'object') return '';\n const order = ['command', 'pattern', 'query', 'path', 'description', 'audio_path', 'text', 'name', 'action', 'entry'];\n for (const k of order) {\n if (typeof input[k] === 'string' && input[k]) {\n let v = input[k].replace(/\\s+/g, ' ').trim();\n if (v.length > 120) v = v.slice(0, 117) + '...';\n return v;\n }\n }\n try {\n const s = JSON.stringify(input);\n return s.length > 120 ? s.slice(0, 117) + '...' : s;\n } catch { return ''; }\n}\n\nasync function send(message) {\n input.value = '';\n input.style.height = 'auto';\n sendBtn.disabled = true;\n el('div', 'role you', 'YOU');\n el('span', 'msg', message || '(attachment)');\n if (pendingFiles.length) {\n const names = pendingFiles.map(f => f.name).join(', ');\n el('span', 'msg attach-label', '\uD83D\uDCCE ' + names);\n }\n const filesToSend = [...pendingFiles];\n pendingFiles = [];\n renderAttachPreview();\n currentLisaSpan = null;\n pendingTools.clear();\n thinkingEl = el('div', 'thinking', '\u22EF thinking');\n try {\n const res = await fetch('/chat', {\n method: 'POST',\n headers: {'content-type': 'application/json'},\n body: JSON.stringify({message, files: filesToSend}),\n });\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buf = '';\n while (true) {\n const {value, done} = await reader.read();\n if (done) break;\n buf += decoder.decode(value, {stream: true});\n let idx;\n while ((idx = buf.indexOf('\\n\\n')) >= 0) {\n const evRaw = buf.slice(0, idx);\n buf = buf.slice(idx + 2);\n const m = evRaw.match(/^data: (.*)$/m);\n if (!m) continue;\n const ev = JSON.parse(m[1]);\n if (ev.type === 'text') {\n ensureLisaSpan().textContent += ev.text;\n } else if (ev.type === 'tool_start') {\n if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }\n currentLisaSpan = null;\n const block = el('div', 'tool-block', null);\n const head = document.createElement('div');\n head.className = 'tool-head';\n head.innerHTML = '<span class=\"tool-icon\">\u2699</span> <span class=\"tool-name\"></span> <span class=\"tool-spinner\">...</span>';\n head.querySelector('.tool-name').textContent = ev.name;\n block.appendChild(head);\n const preview = previewInput(ev.name, ev.input);\n if (preview) {\n const p = document.createElement('div');\n p.className = 'tool-input';\n p.textContent = preview;\n block.appendChild(p);\n }\n pendingTools.set(ev.name, block);\n } else if (ev.type === 'tool_end') {\n const block = pendingTools.get(ev.name);\n if (block) {\n const spinner = block.querySelector('.tool-spinner');\n if (spinner) spinner.textContent = ev.isError ? '\u2717' : '\u2713';\n if (ev.isError) block.classList.add('tool-error');\n if (ev.resultPreview) {\n const r = document.createElement('div');\n r.className = 'tool-result';\n r.textContent = ev.resultPreview;\n block.appendChild(r);\n }\n pendingTools.delete(ev.name);\n }\n } else if (ev.type === 'mood') {\n setMood(ev.slug);\n } else if (ev.type === 'error') {\n el('div', 'err', '[error] ' + ev.message);\n } else if (ev.type === 'done') {\n if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }\n }\n }\n }\n if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }\n } catch (err) {\n if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }\n el('div', 'err', '[error] ' + err.message);\n } finally {\n sendBtn.disabled = false;\n input.focus();\n }\n}\n\nform.addEventListener('submit', (ev) => {\n ev.preventDefault();\n const msg = input.value.trim();\n if (msg || pendingFiles.length) send(msg);\n});\n\ninput.addEventListener('keydown', (ev) => {\n if (ev.key === 'Enter' && !ev.shiftKey) {\n ev.preventDefault();\n form.dispatchEvent(new Event('submit'));\n }\n});\ninput.addEventListener('input', () => {\n input.style.height = 'auto';\n input.style.height = Math.min(input.scrollHeight, 200) + 'px';\n});\n\n// \u2500\u2500 PWA: register service worker + iOS install hint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nif ('serviceWorker' in navigator) {\n navigator.serviceWorker.register('/sw.js').catch(err => {\n console.warn('[pwa] sw register failed:', err);\n });\n}\n(function() {\n const isiOS = /iPad|iPhone|iPod/.test(navigator.userAgent);\n const isStandalone = window.matchMedia('(display-mode: standalone)').matches\n || (window.navigator).standalone === true;\n if (!isiOS || isStandalone) return;\n if (localStorage.getItem('lisa.pwa.dismissed') === '1') return;\n setTimeout(() => {\n const banner = document.createElement('div');\n banner.style.cssText = 'position:fixed;bottom:8px;left:8px;right:8px;background:var(--bg-card-strong);border:1px solid var(--border-strong);border-radius:12px;padding:10px 12px;color:var(--fg);font-size:12px;z-index:9999;display:flex;gap:8px;align-items:center;';\n banner.innerHTML = '\u2726 Add Lisa to Home Screen: Share button \u2192 \"Add to Home Screen\"';\n const dismiss = document.createElement('button');\n dismiss.textContent = '\u2715';\n dismiss.style.cssText = 'background:transparent;border:none;color:var(--fg-2);cursor:pointer;font-size:14px;margin-left:auto;';\n dismiss.onclick = () => {\n localStorage.setItem('lisa.pwa.dismissed', '1');\n banner.remove();\n };\n banner.appendChild(dismiss);\n document.body.appendChild(banner);\n }, 5000);\n})();\n\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// \u2500\u2500 Sidebar live wiring \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n//\n// Populates the new sidebar blocks introduced by the redesign:\n// - identity card sub-line (born YYYY-MM-DD \u00B7 NN days)\n// - \"currently wanting\" paragraph (top actionable desire)\n// - Claude Code monitor card (active sessions)\n// - \"last reflection\" mini-card (most recent \u2605 idle message)\n// Wires to /api/island/ping + /api/claude/sessions + /api/soul, and\n// piggy-backs on the connectEvents() SSE listener above for live\n// claude_session_update + idle_message refreshes.\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n(function setupSidebarLive() {\n const sbDesire = document.getElementById('sbDesire');\n const sbClaudeCount = document.getElementById('sbClaudeCount');\n const sbClaudeRows = document.getElementById('sbClaudeRows');\n const sbReflection = document.getElementById('sbReflection');\n const sbReflectionBody = document.getElementById('sbReflectionBody');\n const sbSessionBadge = document.getElementById('sbSessionBadge');\n const identitySub = document.getElementById('identitySub');\n\n // Active session window matches the watcher's ACTIVE_WINDOW_MS.\n const ACTIVE_WINDOW_MS = 30 * 60 * 1000;\n\n function relativeTime(iso) {\n const ms = Date.now() - new Date(iso).getTime();\n if (ms < 30_000) return 'just now';\n if (ms < 60_000) return Math.round(ms / 1000) + 's';\n if (ms < 3600_000) return Math.round(ms / 60_000) + 'm';\n return Math.round(ms / 3600_000) + 'h';\n }\n\n function setDesire(text) {\n sbDesire.textContent = text || '(nothing actively pursued)';\n }\n\n window.updateReflection = function (text) {\n if (!text) { sbReflection.style.display = 'none'; return; }\n sbReflection.style.display = '';\n sbReflectionBody.textContent = '\"' + text.replace(/^[\"\u201C\u201D]+|[\"\u201C\u201D]+$/g, '').trim() + '\"';\n };\n\n // Compact one-line activity summary \u2014 mirrors agent-roster.ts formatActivity\n // (kept inline because this client script is a no-interpolation template\n // literal; the island uses the source-injected shared version).\n function sbActivity(s) {\n const a = s.activity;\n if (!a || typeof a !== 'object') return '';\n if (a.pendingPermission) return '\u26A0 wants to run ' + a.pendingPermission;\n const bits = [];\n if (a.lastError) bits.push('\u2717 ' + a.lastError);\n const prog = [];\n if (typeof a.turnCount === 'number' && a.turnCount > 0) prog.push('turn ' + a.turnCount);\n if (a.tokens && (a.tokens.input || a.tokens.output)) {\n const tot = (a.tokens.input || 0) + (a.tokens.output || 0);\n prog.push(tot >= 1000 ? Math.round(tot / 1000) + 'k tok' : tot + ' tok');\n }\n if (prog.length) bits.push(prog.join(' '));\n if (a.lastCommandName) bits.push('$ ' + a.lastCommandName);\n const tool = a.lastTools && a.lastTools.length ? a.lastTools[a.lastTools.length - 1] : '';\n const file = a.filesTouched && a.filesTouched.length ? (String(a.filesTouched[a.filesTouched.length - 1]).split('/').pop() || '') : '';\n if (tool && file) bits.push(tool + ' ' + file);\n else if (tool) bits.push(tool);\n else if (file) bits.push(file);\n return bits.join(' \u00B7 ');\n }\n\n // POST a control action to the right agent family (managed|pty), then refresh.\n function agentAction(fam, id, action, body) {\n fetch('/api/agents/' + fam + '/' + encodeURIComponent(id) + '/' + action, {\n method: 'POST',\n headers: body ? { 'content-type': 'application/json' } : {},\n body: body ? JSON.stringify(body) : undefined,\n }).then(function () {\n if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions();\n }).catch(function () {});\n }\n\n // Show a PTY agent's captured terminal tail in the modal \u2014 explicit + on\n // demand (it's content, so it's never folded into the structural roster).\n function ptyOutput(id) {\n fetch('/api/agents/pty/' + encodeURIComponent(id) + '/output').then(function (r) {\n return r.ok ? r.json() : null;\n }).then(function (d) {\n if (!d) return;\n openModal('agent output', '<pre>' + escapeHtml(d.output || '(no output yet)') + '</pre>');\n }).catch(function () {});\n }\n\n function setClaudeSessions(sessions) {\n const cutoff = Date.now() - ACTIVE_WINDOW_MS;\n const recent = sessions.filter(s => new Date(s.lastMtime).getTime() >= cutoff);\n sbClaudeCount.textContent = String(recent.length);\n // sort: errors first, then waiting, then working, then by mtime\n const rank = { error: 0, waiting: 1, working: 2, unknown: 3 };\n const rows = recent.slice().sort((a, b) => {\n const ra = rank[a.state] ?? 9;\n const rb = rank[b.state] ?? 9;\n if (ra !== rb) return ra - rb;\n return new Date(b.lastMtime).getTime() - new Date(a.lastMtime).getTime();\n }).slice(0, 8);\n while (sbClaudeRows.firstChild) sbClaudeRows.removeChild(sbClaudeRows.firstChild);\n if (rows.length === 0) {\n const empty = document.createElement('div');\n empty.className = 'session-empty';\n empty.textContent = '(idle)';\n sbClaudeRows.appendChild(empty);\n return;\n }\n for (const s of rows) {\n const row = document.createElement('div');\n row.className = 'session-row';\n const pip = document.createElement('div');\n pip.className = 'pip ' + (s.state || 'unknown');\n const name = document.createElement('div');\n name.className = 'name';\n // D4a \u2014 agent-kind chip inline before the project; omitted for plain\n // Claude so existing rows read unchanged.\n if (s.agent && s.agent !== 'claude-code') {\n const badge = document.createElement('span');\n badge.className = 'agent-badge';\n badge.textContent = s.agent;\n badge.title = s.agent;\n name.appendChild(badge);\n }\n // Label by git branch when available (more meaningful than a worktree\n // hash), stripping the claude/ prefix; fall back to the project name.\n // (String ops, not a regex \u2014 a /// here would be mangled by the outer\n // template literal that wraps this client script.)\n let label = s.project;\n if (s.activity && s.activity.gitBranch) {\n const br = String(s.activity.gitBranch);\n label = br.indexOf('claude/') === 0 ? br.slice(7) : br;\n }\n name.appendChild(document.createTextNode(label));\n const when = document.createElement('div');\n when.className = 'when';\n when.textContent = relativeTime(s.lastMtime);\n row.appendChild(pip);\n row.appendChild(name);\n row.appendChild(when);\n // Second line: structural activity (turns/tokens/tool\u00B7file, \u26A0pending, \u2717err).\n const actText = sbActivity(s);\n if (actText) {\n const act = document.createElement('div');\n act.className = 'session-act';\n act.textContent = actText;\n act.title = actText;\n row.appendChild(act);\n }\n // Controllable agents get inline controls: managed \u2192 approve/deny a pending\n // tool, send a follow-up, cancel; pty (real CLI under a PTY) \u2192 send, view\n // terminal output, cancel. Externally-started CLIs have no control channel\n // (no s.controllable) \u2192 observe only.\n const fam = s.controllable;\n if (fam) {\n const id = s.sessionId;\n const ctrl = document.createElement('div');\n ctrl.className = 'session-ctrl';\n const pending = fam === 'managed' && s.activity && s.activity.pendingPermission;\n if (pending) {\n const ap = document.createElement('button');\n ap.className = 'mc approve'; ap.textContent = '\u2713 approve';\n ap.addEventListener('click', function (e) { e.stopPropagation(); agentAction('managed', id, 'approve', { allow: true }); });\n const dn = document.createElement('button');\n dn.className = 'mc deny'; dn.textContent = '\u2715 deny';\n dn.addEventListener('click', function (e) { e.stopPropagation(); agentAction('managed', id, 'approve', { allow: false }); });\n ctrl.appendChild(ap); ctrl.appendChild(dn);\n } else if (s.state !== 'done') {\n const inp = document.createElement('input');\n inp.className = 'mc-send'; inp.type = 'text'; inp.placeholder = fam === 'pty' ? 'type into the CLI\u2026' : 'send a follow-up\u2026';\n inp.addEventListener('click', function (e) { e.stopPropagation(); });\n inp.addEventListener('keydown', function (e) {\n if (e.key === 'Enter' && inp.value.trim()) { e.preventDefault(); agentAction(fam, id, 'send', { text: inp.value.trim() }); inp.value = ''; }\n });\n ctrl.appendChild(inp);\n }\n if (fam === 'pty') {\n const out = document.createElement('button');\n out.className = 'mc'; out.textContent = '\u25A4'; out.title = 'View terminal output';\n out.addEventListener('click', function (e) { e.stopPropagation(); ptyOutput(id); });\n ctrl.appendChild(out);\n }\n if (s.state !== 'done') {\n const cancel = document.createElement('button');\n cancel.className = 'mc cancel'; cancel.textContent = '\u23F9'; cancel.title = 'Cancel agent';\n cancel.addEventListener('click', function (e) { e.stopPropagation(); agentAction(fam, id, 'cancel', null); });\n ctrl.appendChild(cancel);\n }\n if (ctrl.childNodes.length) row.appendChild(ctrl);\n }\n // Idle external claude session \u2192 adopt it: LISA resumes it under a PTY and\n // drives the continuation (then it shows as a controllable pty row). Only\n // offered for idle sessions; a live one can't be resumed without corrupting\n // its transcript (the server 409s, surfaced in the modal).\n if (s.resumable) {\n const ctrl = document.createElement('div');\n ctrl.className = 'session-ctrl';\n const adopt = document.createElement('button');\n adopt.className = 'mc adopt'; adopt.textContent = '\u21F2 adopt';\n adopt.title = 'Resume this session under LISA \u2014 then send / answer / cancel / view it';\n adopt.addEventListener('click', function (e) {\n e.stopPropagation();\n fetch('/api/agents/pty/start', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ agent: 'claude', resumeSessionId: s.sessionId, cwd: s.cwd || '' }),\n }).then(function (r) {\n if (!r.ok) { return r.text().then(function (t) { openModal('adopt', '<pre>' + escapeHtml(t) + '</pre>'); }); }\n if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions();\n }).catch(function () {});\n });\n ctrl.appendChild(adopt);\n row.appendChild(ctrl);\n }\n row.title = (s.stateReason ? s.state + ' \u00B7 ' + s.stateReason : s.state) + ' \u00B7 ' + s.project + ' \u00B7 ' + s.sessionId;\n sbClaudeRows.appendChild(row);\n }\n }\n\n async function refreshPing() {\n try {\n const r = await fetch('/api/island/ping');\n if (!r.ok) return;\n const data = await r.json();\n setDesire(data.current_desire);\n if (data.last_idle_message_text) {\n window.updateReflection(data.last_idle_message_text);\n }\n } catch {}\n }\n\n // Exposed so the SSE handler above can call this on\n // agent_session_update events without redeclaring the helper. D4a \u2014 the\n // multi-agent snapshot (all agents), not just Claude Code.\n window.refreshClaudeSessions = async function () {\n try {\n const r = await fetch('/api/agents/sessions');\n if (!r.ok) return;\n const data = await r.json();\n setClaudeSessions(data.sessions || []);\n } catch {}\n };\n\n // \"Delegate a task\" \u2192 start an agent. managed = LISA-run (controllable);\n // claude/codex = a real CLI under a PTY (Stage C spike, needs LISA_PTY_AGENTS=1\n // \u2014 a 503 surfaces its hint in the modal).\n const sbDelegate = document.getElementById('sbDelegate');\n if (sbDelegate) {\n sbDelegate.addEventListener('submit', function (e) {\n e.preventDefault();\n const inp = document.getElementById('sbDelegateTask');\n const kindEl = document.getElementById('sbDelegateKind');\n const task = inp && inp.value.trim();\n if (!task) return;\n const kind = kindEl ? kindEl.value : 'managed';\n const url = kind === 'managed' ? '/api/agents/managed/start' : '/api/agents/pty/start';\n const body = kind === 'managed' ? { task: task } : { agent: kind, task: task };\n fetch(url, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n }).then(function (r) {\n if (!r.ok) { return r.text().then(function (t) { openModal('agent', '<pre>' + escapeHtml(t) + '</pre>'); }); }\n if (inp) inp.value = '';\n if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions();\n }).catch(function () {});\n });\n }\n\n async function refreshIdentity() {\n try {\n const r = await fetch('/api/soul');\n if (!r.ok) return;\n const data = await r.json();\n if (!data.born) return;\n const bornAt = data.summary?.seed?.bornAt;\n if (!bornAt) return;\n const born = new Date(bornAt);\n if (Number.isNaN(born.getTime())) return;\n const days = Math.max(0, Math.floor((Date.now() - born.getTime()) / 86400000));\n const ymd = born.toISOString().slice(0, 10);\n identitySub.textContent = 'born ' + ymd + ' \u00B7 ' + days + ' day' + (days === 1 ? '' : 's');\n } catch {}\n }\n\n async function refreshSessionsBadge() {\n try {\n const r = await fetch('/api/sessions');\n if (!r.ok) return;\n const data = await r.json();\n const n = Array.isArray(data.sessions) ? data.sessions.length : 0;\n sbSessionBadge.textContent = String(n);\n } catch {\n // /api/sessions is optional \u2014 leave the badge as-is on failure\n }\n }\n\n // Bootstrap + periodic resync. SSE handles the fast-path updates;\n // these timers are belt-and-braces in case the stream silently dies.\n refreshPing();\n window.refreshClaudeSessions();\n refreshIdentity();\n refreshSessionsBadge();\n setInterval(refreshPing, 30_000);\n setInterval(window.refreshClaudeSessions, 60_000);\n setInterval(refreshSessionsBadge, 5 * 60_000);\n})();\n</script>\n</body></html>";
28
+ export declare const MAIN_HTML = "<!doctype html>\n<html lang=\"en\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>LISA</title>\n<link rel=\"manifest\" href=\"/manifest.webmanifest\">\n<meta name=\"theme-color\" content=\"#07091a\">\n<meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n<meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\">\n<meta name=\"apple-mobile-web-app-title\" content=\"LISA\">\n<link rel=\"apple-touch-icon\" href=\"/assets/lisa-mascot.png\">\n<link rel=\"icon\" type=\"image/png\" href=\"/assets/lisa-mascot.png\">\n<style>\n :root {\n color-scheme: dark;\n\n /* New design tokens (mockup) */\n --accent: #6ad4ff;\n --accent-soft: rgba(106, 212, 255, 0.13);\n --accent-glow: rgba(106, 212, 255, 0.27);\n /* Proactive / autonomy \"live\" accent (green) \u2014 the console's watching state. */\n --proactive: #3ddc97;\n --proactive-soft: rgba(61, 220, 151, 0.13);\n --proactive-glow: rgba(61, 220, 151, 0.30);\n --warm: #ffd066;\n --dream: #b487ff;\n --claude: #ff8c42;\n --err-color: #ff5577;\n\n --bg-deep: #07091a;\n --bg-1: #0b1024;\n --bg-2: #11163a;\n --bg-3: #1a1f4a;\n --bg-card: rgba(20, 26, 64, 0.65);\n --bg-card-strong: rgba(20, 26, 64, 0.88);\n --border-new: rgba(255, 255, 255, 0.07);\n --border-strong: rgba(255, 255, 255, 0.14);\n\n --fg: #e8eaff;\n --fg-2: #aeb5d3;\n --fg-3: #6c7398;\n --fg-faint: #444a6e;\n\n /* Legacy tokens \u2014 kept so the unchanged modal / cfg / birth\n overlay styles below still resolve. The new shell + chat use\n the modern tokens above. */\n --bg: #0a0d2b;\n --panel: #1a1f4d;\n --panel-light: #2a3270;\n --border: #6a7ad9;\n --border-light: #a4b2ff;\n --text: #e7ecff;\n --text-dim: #8090c0;\n --you: #6cf6e1;\n --lisa: #ffd167;\n --tool: #ff7eb6;\n --error: #ff5577;\n }\n * { box-sizing: border-box; }\n html, body {\n height: 100%;\n margin: 0;\n background: #000;\n font-family: -apple-system, BlinkMacSystemFont, \"SF Pro Text\", \"Inter\", system-ui, sans-serif;\n -webkit-font-smoothing: antialiased;\n color: var(--fg);\n overflow: hidden;\n }\n\n /* \u2500\u2500 App shell \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .frame {\n height: 100vh;\n width: 100vw;\n display: grid;\n grid-template-columns: 300px 1fr;\n grid-template-rows: 36px 1fr;\n grid-template-areas:\n \"titlebar titlebar\"\n \"sidebar main\";\n background:\n radial-gradient(ellipse at 30% 20%, #1a1238 0%, transparent 50%),\n radial-gradient(ellipse at 80% 70%, #0a1f3a 0%, transparent 60%),\n linear-gradient(180deg, var(--bg-1) 0%, var(--bg-deep) 100%);\n overflow: hidden;\n }\n\n /* Title bar \u2014 visually shows \"Lisa \u00B7 session-id\". The actual drag\n behavior is handled Swift-side by a transparent NSView overlay\n (DragHandleView) placed on top of the WKWebView for the same 36pt\n strip \u2014 WebKit ignores the CSS -webkit-app-region: drag property,\n so the cosmetic HTML and the functional drag region are two\n separate things.\n Padding-left reserves the ~78pt that the macOS traffic-light\n buttons occupy at top-left. */\n .titlebar {\n grid-area: titlebar;\n background: rgba(7, 9, 26, 0.55);\n backdrop-filter: blur(20px);\n -webkit-backdrop-filter: blur(20px);\n border-bottom: 1px solid var(--border-new);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 0 14px 0 78px;\n font-size: 12px;\n font-weight: 600;\n letter-spacing: 0.04em;\n color: var(--fg-2);\n /* nothing here should ever capture pointer events \u2014 the NSView\n overlay above handles dragging, and there are no interactive\n elements in the HTML titlebar. */\n user-select: none;\n pointer-events: none;\n }\n .titlebar .session-tag {\n color: var(--fg-3);\n font-weight: 400;\n margin-left: 6px;\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n font-size: 11px;\n letter-spacing: 0;\n }\n\n /* \u2500\u2500 Sidebar (floating panel \u2014 Claude-Code-style detached card) \u2500\u2500\u2500\u2500 */\n .sidebar {\n grid-area: sidebar;\n margin: 8px 6px 10px 10px;\n background: linear-gradient(180deg, rgba(18, 23, 48, 0.62), rgba(10, 13, 33, 0.6));\n backdrop-filter: blur(30px);\n -webkit-backdrop-filter: blur(30px);\n border: 1px solid var(--border-new);\n border-radius: 16px;\n box-shadow: 0 18px 44px rgba(0, 0, 0, 0.5);\n overflow-y: auto;\n padding: 18px 14px 14px;\n display: flex;\n flex-direction: column;\n gap: 18px;\n }\n\n /* Identity card */\n .identity {\n display: grid;\n grid-template-columns: 56px 1fr;\n gap: 12px;\n align-items: center;\n padding: 12px;\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 14px;\n }\n .identity .avatar-wrap {\n width: 56px;\n height: 56px;\n border-radius: 50%;\n position: relative;\n border: 1px solid var(--border-strong);\n box-shadow: 0 0 0 3px var(--accent-soft);\n background: #15192a;\n }\n .identity .avatar-wrap img {\n width: 100%;\n height: 100%;\n border-radius: 50%;\n object-fit: cover;\n object-position: 50% 22%;\n image-rendering: pixelated;\n display: block;\n transition: opacity 250ms ease;\n user-select: none;\n -webkit-user-drag: none;\n }\n .identity .avatar-wrap img.fading { opacity: 0; }\n .identity .avatar-wrap::after {\n content: \"\";\n position: absolute;\n right: -2px;\n bottom: -2px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: #4ade80;\n border: 2px solid var(--bg-1);\n }\n .identity h1 {\n margin: 0 0 2px;\n font-size: 15px;\n font-weight: 700;\n letter-spacing: 0.02em;\n color: var(--fg);\n }\n .identity .sub {\n margin: 0;\n font-size: 11px;\n color: var(--fg-3);\n }\n .identity .mood {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n margin-top: 4px;\n font-size: 10.5px;\n color: var(--accent);\n text-transform: uppercase;\n letter-spacing: 0.08em;\n font-weight: 600;\n }\n .identity .mood::before {\n content: \"\";\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: var(--accent);\n box-shadow: 0 0 6px var(--accent-glow);\n }\n\n /* Sidebar plain text section (\"currently wanting\") */\n .sb-section { display: flex; flex-direction: column; gap: 6px; }\n .sb-section h2 {\n margin: 0 0 2px;\n font-size: 10.5px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.10em;\n color: var(--fg-3);\n padding-left: 4px;\n }\n .sb-section .body-text {\n margin: 0;\n font-size: 12px;\n line-height: 1.55;\n color: var(--fg-2);\n padding: 0 4px;\n }\n\n /* Live mini-cards (Claude monitor / last reflection) */\n .card {\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 12px;\n padding: 10px 12px;\n font-size: 12px;\n color: var(--fg-2);\n line-height: 1.5;\n }\n .card.tint-claude {\n border-color: rgba(255, 140, 66, 0.20);\n background: linear-gradient(180deg, rgba(255, 140, 66, 0.06), rgba(255, 140, 66, 0.02));\n }\n .card.tint-idle {\n border-color: rgba(255, 208, 102, 0.22);\n background: linear-gradient(180deg, rgba(255, 208, 102, 0.07), rgba(255, 208, 102, 0.02));\n }\n .card.tint-mail {\n border-color: rgba(106, 212, 255, 0.20);\n background: linear-gradient(180deg, rgba(106, 212, 255, 0.06), rgba(106, 212, 255, 0.02));\n }\n .card.tint-mail .h .left { color: var(--brand, #6ad4ff); }\n .card .h {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 4px;\n }\n .card .h .left {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 10.5px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.10em;\n color: var(--claude);\n }\n .card.tint-idle .h .left { color: var(--warm); }\n .card .h .count {\n background: rgba(255, 140, 66, 0.16);\n color: var(--claude);\n font-size: 11px;\n font-weight: 600;\n padding: 2px 7px;\n border-radius: 8px;\n }\n .card.tint-idle .h .count {\n background: rgba(255, 208, 102, 0.16);\n color: var(--warm);\n }\n .session-row {\n display: grid;\n grid-template-columns: 7px 1fr auto;\n align-items: center;\n gap: 7px;\n padding: 5px 0;\n font-size: 11.5px;\n border-top: 1px dashed rgba(255, 140, 66, 0.10);\n cursor: default;\n }\n .session-row:first-of-type { border-top: 0; }\n .session-row .pip {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--fg-faint);\n }\n /* working = calm slow breathe (running, not actionable);\n waiting = solid + a soft halo that draws the eye (\"needs you\"). */\n .session-row .pip.working { background: var(--claude); opacity: 1; animation: breathe 2.6s ease-in-out infinite; }\n .session-row .pip.waiting { background: var(--claude); opacity: 1; animation: needsYou 2s ease-in-out infinite; }\n .session-row .pip.error { background: var(--err-color); }\n .session-row .name {\n color: var(--fg);\n font-weight: 600;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n /* D4a \u2014 agent-kind chip rendered inline before the project name, so the\n multi-agent sidebar reads which tool each row belongs to. */\n .session-row .agent-badge {\n display: inline-block;\n font-size: 9px;\n font-weight: 700;\n letter-spacing: 0.02em;\n text-transform: lowercase;\n color: var(--claude);\n background: rgba(255, 140, 66, 0.12);\n border: 1px solid rgba(255, 140, 66, 0.22);\n border-radius: 999px;\n padding: 0 5px;\n margin-right: 5px;\n vertical-align: 1px;\n }\n .session-row .when {\n color: var(--fg-3);\n font-variant-numeric: tabular-nums;\n font-size: 10.5px;\n }\n /* Second line under name/when: structural activity (turns/tokens/tool\u00B7file). */\n .session-row .session-act {\n grid-column: 2 / -1;\n margin-top: 2px;\n font-size: 10px;\n color: var(--fg-3);\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n /* Managed-agent controls (approve/deny \u00B7 send follow-up \u00B7 cancel). */\n .session-row .session-ctrl {\n grid-column: 2 / -1;\n margin-top: 4px;\n display: flex;\n gap: 6px;\n align-items: center;\n flex-wrap: wrap;\n }\n .session-ctrl .mc {\n font-size: 10px;\n padding: 2px 8px;\n border-radius: 6px;\n border: 1px solid var(--border);\n background: var(--panel2, rgba(255,255,255,0.05));\n color: var(--fg);\n cursor: pointer;\n }\n .session-ctrl .mc.approve { color: var(--green, #6bff9d); border-color: rgba(107,255,157,0.4); }\n .session-ctrl .mc.deny,\n .session-ctrl .mc.cancel { color: var(--err-color, #ff5577); border-color: rgba(255,85,119,0.4); }\n .session-ctrl .mc.adopt { color: var(--brand, #6ad4ff); border-color: rgba(106,212,255,0.4); }\n .session-ctrl .mc:hover { background: rgba(255,255,255,0.10); }\n .session-ctrl .mc-send {\n flex: 1;\n min-width: 90px;\n font-size: 10.5px;\n padding: 2px 7px;\n border-radius: 6px;\n border: 1px solid var(--border);\n background: rgba(0,0,0,0.25);\n color: var(--fg);\n }\n /* \"Delegate a task\" \u2192 a single full-width button that opens a modal. */\n .delegate-btn {\n width: 100%;\n margin: 2px 0 8px;\n font-size: 11.5px;\n padding: 6px 10px;\n border-radius: 8px;\n border: 1px solid var(--claude, #ff8c42);\n background: rgba(255,140,66,0.14);\n color: var(--claude, #ff8c42);\n cursor: pointer;\n transition: background 0.12s ease;\n }\n .delegate-btn:hover { background: rgba(255,140,66,0.26); }\n /* Delegate dialog (rendered in the shared modal). */\n .delegate-modal { display: flex; flex-direction: column; gap: 8px; }\n .delegate-modal .dm-label {\n font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;\n color: var(--fg-2); margin-top: 4px;\n }\n .delegate-modal .dm-kind,\n .delegate-modal .dm-task {\n width: 100%; box-sizing: border-box;\n font-size: 13px; padding: 8px 10px; border-radius: 8px;\n border: 1px solid var(--border); background: rgba(0,0,0,0.25); color: var(--fg);\n }\n .delegate-modal .dm-task { resize: vertical; min-height: 88px; font-family: inherit; }\n .delegate-modal .dm-actions { display: flex; justify-content: flex-end; margin-top: 4px; }\n .delegate-modal .dm-start {\n font-size: 13px; padding: 8px 16px; border-radius: 8px;\n border: 1px solid var(--brand, #6ad4ff); background: rgba(106,212,255,0.16);\n color: var(--brand, #6ad4ff); cursor: pointer;\n }\n .delegate-modal .dm-start:hover { background: rgba(106,212,255,0.28); }\n .delegate-modal .dm-start:disabled { opacity: 0.5; cursor: default; }\n .delegate-modal .dm-err { color: var(--err-color, #ff5577); font-size: 12px; white-space: pre-wrap; }\n .delegate-modal .dm-note { font-size: 11px; color: var(--fg-faint); line-height: 1.4; margin-top: 2px; }\n /* Mail card body */\n #sbMailBody { margin: 2px 0 6px; }\n .mail-summary { font-size: 11.5px; color: var(--fg-2); line-height: 1.45; margin-bottom: 6px; }\n .mail-row { display: flex; gap: 6px; align-items: baseline; padding: 1px 0; }\n .mail-bang { color: var(--brand, #6ad4ff); font-weight: 700; font-size: 11px; flex: none; }\n .mail-bang.urgent { color: var(--err-color, #ff5577); }\n .mail-subj { font-size: 11.5px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .mail-sweep {\n margin-top: 6px; font-size: 10.5px; background: none; border: none;\n color: var(--brand, #6ad4ff); cursor: pointer; padding: 2px 0;\n }\n .mail-sweep:hover { text-decoration: underline; }\n .session-empty {\n color: var(--fg-faint);\n font-size: 11.5px;\n font-style: italic;\n padding: 4px 0 2px;\n }\n @keyframes pulse {\n 0%, 100% { opacity: 0.35; }\n 50% { opacity: 1; }\n }\n /* Gentle breathing for \"working\" \u2014 present but not jumpy. */\n @keyframes breathe {\n 0%, 100% { opacity: 0.55; }\n 50% { opacity: 1; }\n }\n /* \"needs you\" \u2014 solid dot with a pulsing warm halo, prominent without the\n harsh on/off flash. */\n @keyframes needsYou {\n 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 140, 66, 0); }\n 50% { box-shadow: 0 0 7px 2px rgba(255, 140, 66, 0.65); }\n }\n\n /* Compact SOUL / SKILLS / MEMORY / TOOLS row */\n .badges {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 6px;\n }\n .badge {\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n color: var(--fg-2);\n font-family: inherit;\n font-size: 10.5px;\n font-weight: 600;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n padding: 7px 8px;\n border-radius: 8px;\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 6px;\n transition: background 120ms ease, color 120ms ease, border-color 120ms ease;\n }\n .badge:hover {\n background: var(--bg-card-strong);\n color: var(--fg);\n border-color: var(--border-strong);\n }\n .badge img {\n width: 14px;\n height: 14px;\n image-rendering: pixelated;\n opacity: 0.8;\n }\n\n /* Sidebar footer \u2014 session id + sessions count */\n .sb-footer {\n margin-top: auto;\n padding-top: 12px;\n border-top: 1px solid var(--border-new);\n display: flex;\n align-items: center;\n gap: 8px;\n color: var(--fg-3);\n font-size: 11px;\n }\n .sb-footer .session-id {\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n font-size: 10px;\n color: var(--fg-3);\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n flex: 1;\n min-width: 0;\n }\n .sb-footer .badge-count {\n background: var(--bg-3);\n color: var(--fg-2);\n border-radius: 6px;\n padding: 1px 6px;\n font-size: 10px;\n font-variant-numeric: tabular-nums;\n flex-shrink: 0;\n }\n\n /* \u2500\u2500 Primary nav (vertical list in the sidebar) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .nav-list { display: flex; flex-direction: column; gap: 2px; }\n .nav-item {\n position: relative;\n display: flex;\n align-items: center;\n gap: 10px;\n width: 100%;\n padding: 8px 11px;\n border: 0;\n border-radius: 9px;\n background: transparent;\n color: var(--fg-2);\n font-family: inherit;\n font-size: 12.5px;\n font-weight: 600;\n letter-spacing: 0.01em;\n text-align: left;\n cursor: pointer;\n transition: background 120ms ease, color 120ms ease;\n }\n .nav-item .nav-ico { width: 18px; font-size: 15px; text-align: center; flex-shrink: 0; opacity: 0.9; }\n .nav-item:hover { background: var(--bg-card); color: var(--fg); }\n .nav-item.active { background: var(--accent-soft); color: var(--accent); }\n .nav-item.active::before {\n content: \"\";\n position: absolute;\n left: 0;\n top: 50%;\n transform: translateY(-50%);\n width: 3px;\n height: 18px;\n border-radius: 0 3px 3px 0;\n background: var(--accent);\n }\n .nav-item .nav-tag {\n margin-left: auto;\n font-size: 9.5px;\n font-weight: 700;\n font-variant-numeric: tabular-nums;\n color: var(--fg);\n background: var(--bg-3);\n border-radius: 999px;\n padding: 1px 7px;\n min-width: 18px;\n text-align: center;\n }\n .nav-item.active .nav-tag { background: var(--accent); color: #06141b; }\n\n /* \u2500\u2500 Proactive toggle (sidebar footer area) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .proactive-toggle {\n display: flex;\n align-items: center;\n gap: 9px;\n padding: 9px 11px;\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 11px;\n cursor: pointer;\n user-select: none;\n transition: border-color 140ms ease, background 140ms ease;\n }\n .proactive-toggle .pt-label {\n font-size: 11px;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n color: var(--fg-3);\n transition: color 140ms ease;\n }\n .proactive-toggle .pt-track {\n margin-left: auto;\n width: 34px;\n height: 18px;\n border-radius: 999px;\n background: var(--bg-3);\n border: 1px solid var(--border-new);\n position: relative;\n flex-shrink: 0;\n transition: background 140ms ease, border-color 140ms ease;\n }\n .proactive-toggle .pt-knob {\n position: absolute;\n top: 1px;\n left: 1px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: var(--fg-3);\n transition: transform 140ms ease, background 140ms ease;\n }\n .proactive-toggle.on { border-color: var(--proactive-glow); background: var(--proactive-soft); }\n .proactive-toggle.on .pt-label { color: var(--proactive); }\n .proactive-toggle.on .pt-track { background: var(--proactive-soft); border-color: var(--proactive-glow); }\n .proactive-toggle.on .pt-knob { transform: translateX(16px); background: var(--proactive); }\n\n /* \u2500\u2500 Main pane \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n /* The main pane is a view stack: each .view fills the pane and only the\n active one is shown. Chat is the default; it keeps its original\n log / attachPreview / form 3-row grid (moved onto #viewChat). */\n .main {\n grid-area: main;\n position: relative;\n overflow: hidden;\n background:\n radial-gradient(ellipse at 50% -30%, rgba(106, 212, 255, 0.06) 0%, transparent 60%),\n linear-gradient(180deg, transparent, rgba(106, 212, 255, 0.015));\n }\n .view { display: none; }\n .view.active {\n position: absolute;\n inset: 0;\n display: flex;\n flex-direction: column;\n }\n #viewChat.view.active {\n display: grid;\n grid-template-rows: auto 1fr auto auto;\n }\n\n /* \u2500\u2500 Console views (dashboard / control / reve / sense / memory) \u2500\u2500 */\n .view-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 16px 24px;\n border-bottom: 1px solid var(--border-new);\n }\n .view-head h2 { margin: 0; font-size: 16px; font-weight: 700; letter-spacing: 0.02em; color: var(--fg); }\n .view-head .vh-sub { margin: 2px 0 0; font-size: 11.5px; color: var(--fg-3); }\n .view-act {\n flex-shrink: 0;\n font-family: inherit;\n font-size: 12px;\n font-weight: 600;\n color: #06140d;\n background: var(--accent);\n border: 0;\n border-radius: 9px;\n padding: 8px 13px;\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n transition: filter 120ms ease, transform 120ms ease;\n }\n .view-act:hover { filter: brightness(1.08); }\n .view-act:active { transform: translateY(1px); }\n .view-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 18px 24px 26px; }\n .view-empty { color: var(--fg-faint); font-size: 12.5px; font-style: italic; padding: 18px 4px; }\n .view-sec-label {\n font-size: 11px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.10em;\n color: var(--fg-3);\n margin: 4px 0 8px;\n }\n\n /* Stat bar */\n .stat-bar { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 16px; }\n .stat {\n flex: 1;\n min-width: 92px;\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 12px;\n padding: 11px 13px;\n }\n .stat .n { font-size: 21px; font-weight: 700; color: var(--fg); font-variant-numeric: tabular-nums; line-height: 1.1; }\n .stat .k {\n margin-top: 3px;\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--fg-3);\n }\n\n /* Green \"proactive \u2014 watching\" panel */\n .proactive-panel {\n border: 1px solid var(--proactive-glow);\n background: linear-gradient(180deg, var(--proactive-soft), transparent);\n border-radius: 14px;\n padding: 13px 15px;\n margin-bottom: 16px;\n }\n .proactive-panel .pp-head { display: flex; align-items: center; gap: 10px; }\n .proactive-panel .pp-dot {\n width: 9px;\n height: 9px;\n border-radius: 50%;\n background: var(--proactive);\n box-shadow: 0 0 8px var(--proactive-glow);\n flex-shrink: 0;\n }\n .proactive-panel.off .pp-dot { background: var(--fg-faint); box-shadow: none; }\n .proactive-panel .pp-title { font-size: 13px; font-weight: 700; color: var(--fg); }\n .proactive-panel .pp-title b { color: var(--proactive); font-weight: 700; }\n .proactive-panel.off .pp-title b { color: var(--fg-3); }\n .proactive-panel .pp-desc { margin: 2px 0 0; font-size: 11.5px; color: var(--fg-2); }\n /* .pp-tags / .pp-tag are reused outside the panel (Control policy chips),\n so they are not scoped to .proactive-panel. */\n .pp-tags { display: flex; flex-wrap: wrap; gap: 6px; }\n .proactive-panel .pp-tags { margin-top: 10px; }\n .pp-tag {\n font-size: 10.5px;\n color: var(--fg-3);\n background: rgba(255, 255, 255, 0.05);\n border: 1px solid var(--border-new);\n border-radius: 999px;\n padding: 2px 9px;\n }\n .pp-tag.warn { color: var(--warm); }\n\n /* \"Focus\" big card (current desire / objective analog) */\n .focus-card {\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 14px;\n padding: 15px;\n margin-bottom: 18px;\n }\n .focus-card .fc-top { display: flex; align-items: flex-start; gap: 10px; }\n .focus-card .fc-title { font-size: 14.5px; font-weight: 700; color: var(--fg); }\n .focus-card .fc-desc { margin: 4px 0 0; font-size: 12px; color: var(--fg-2); line-height: 1.5; }\n .focus-card .fc-pill {\n margin-left: auto;\n flex-shrink: 0;\n font-size: 10.5px;\n color: var(--proactive);\n border: 1px solid var(--proactive-glow);\n border-radius: 999px;\n padding: 2px 10px;\n }\n .focus-card .fc-meta { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 11px; font-size: 11px; color: var(--fg-3); }\n\n /* Horizontally-scrolling agent / task cards */\n .card-scroll {\n display: flex;\n gap: 12px;\n overflow-x: auto;\n padding-bottom: 6px;\n margin-bottom: 18px;\n scroll-snap-type: x proximity;\n }\n .ac {\n flex: 0 0 232px;\n scroll-snap-align: start;\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 13px;\n padding: 13px;\n }\n .ac .ac-top { display: flex; align-items: center; gap: 6px; font-size: 10.5px; color: var(--fg-3); margin-bottom: 8px; }\n .ac .ac-status { margin-left: auto; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }\n .ac .ac-status.working { color: var(--accent); }\n .ac .ac-status.waiting { color: var(--warm); }\n .ac .ac-status.error { color: var(--err-color); }\n .ac .ac-status.done { color: var(--proactive); }\n .ac .ac-title { font-size: 13px; font-weight: 600; color: var(--fg); margin-bottom: 3px; word-break: break-word; }\n .ac .ac-desc { font-size: 11.5px; color: var(--fg-2); line-height: 1.45; }\n .ac .ac-meta {\n margin-top: 9px;\n padding-top: 7px;\n border-top: 1px solid var(--border-new);\n font-size: 10px;\n color: var(--fg-3);\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n }\n\n /* Simple list rows / cards used by Reve / Sense / Memory */\n .v-card {\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 12px;\n padding: 12px 14px;\n margin-bottom: 10px;\n }\n .v-card h3 {\n margin: 0 0 6px;\n font-size: 11px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--fg-3);\n }\n .v-pre {\n margin: 0;\n white-space: pre-wrap;\n word-break: break-word;\n font-size: 12px;\n line-height: 1.55;\n color: var(--fg-2);\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n }\n .v-row {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 11px 0;\n border-top: 1px solid var(--border-new);\n }\n .v-row:first-child { border-top: 0; }\n .v-row .v-main { min-width: 0; flex: 1; }\n .v-row .v-name { font-size: 12.5px; font-weight: 600; color: var(--fg); }\n .v-row .v-sub { font-size: 11px; color: var(--fg-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .v-toggle {\n font-family: inherit;\n font-size: 11px;\n font-weight: 600;\n cursor: pointer;\n border-radius: 7px;\n padding: 4px 10px;\n border: 1px solid var(--border-new);\n background: var(--bg-3);\n color: var(--fg-3);\n flex-shrink: 0;\n }\n .v-toggle.on { color: var(--proactive); border-color: var(--proactive-glow); background: var(--proactive-soft); }\n .v-sel {\n font-family: inherit;\n font-size: 12px;\n color: var(--fg);\n background: var(--bg-3);\n border: 1px solid var(--border-new);\n border-radius: 8px;\n padding: 5px 8px;\n }\n .mem-btn {\n width: 100%;\n text-align: left;\n font-family: inherit;\n font-size: 13px;\n font-weight: 600;\n color: var(--fg);\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 11px;\n padding: 13px 15px;\n margin-bottom: 9px;\n cursor: pointer;\n display: flex;\n align-items: center;\n gap: 10px;\n transition: background 120ms ease, border-color 120ms ease;\n }\n .mem-btn:hover { background: var(--bg-card-strong); border-color: var(--border-strong); }\n .mem-btn .mem-ico { font-size: 16px; width: 20px; text-align: center; }\n .mem-btn .mem-sub { margin-left: auto; font-size: 11px; color: var(--fg-3); font-weight: 400; }\n\n #log {\n overflow-y: auto;\n padding: 22px 28px 24px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n scroll-behavior: smooth;\n }\n\n /* Chat author label (.role .you/.lisa) */\n .role {\n font-size: 10px;\n font-weight: 700;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n padding: 6px 2px 0;\n margin-top: 6px;\n }\n .role.you { color: var(--accent); text-align: right; }\n .role.lisa { color: var(--warm); }\n\n /* Message bubble */\n .msg {\n display: block;\n max-width: 88%;\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 14px;\n padding: 9px 13px;\n font-size: 13px;\n line-height: 1.55;\n color: var(--fg);\n white-space: pre-wrap;\n word-wrap: break-word;\n }\n /* \"you\" bubbles sit on the right with cyan tint */\n .role.you + .msg {\n margin-left: auto;\n background: linear-gradient(160deg, rgba(106, 212, 255, 0.12), rgba(106, 212, 255, 0.06));\n border-color: rgba(106, 212, 255, 0.25);\n }\n /* attachment label sits compactly under the user bubble */\n .msg.attach-label {\n margin-top: 2px;\n background: transparent;\n border: 0;\n padding: 0 2px;\n font-size: 10.5px;\n color: var(--fg-3);\n max-width: 88%;\n margin-left: auto;\n }\n\n /* Thinking indicator */\n .thinking {\n font-size: 12px;\n color: var(--fg-3);\n font-style: italic;\n padding: 4px 2px;\n letter-spacing: 0.03em;\n }\n\n /* Tool call card */\n .tool-block {\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 10px;\n padding: 8px 12px;\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n font-size: 11px;\n color: var(--fg-2);\n margin: 4px 0;\n max-width: 92%;\n }\n .tool-block.tool-error {\n border-color: rgba(255, 85, 119, 0.30);\n background: rgba(255, 85, 119, 0.05);\n }\n .tool-head {\n display: flex;\n align-items: center;\n gap: 8px;\n color: var(--accent);\n font-weight: 600;\n }\n .tool-block.tool-error .tool-head { color: var(--err-color); }\n .tool-icon { font-size: 12px; }\n .tool-spinner { color: var(--fg-3); font-weight: 400; margin-left: auto; font-size: 12px; }\n .tool-input {\n color: var(--fg-3);\n margin-top: 4px;\n white-space: pre-wrap;\n word-break: break-word;\n }\n .tool-result {\n color: var(--fg-2);\n margin-top: 4px;\n padding-top: 4px;\n border-top: 1px dashed var(--border-new);\n white-space: pre-wrap;\n max-height: 160px;\n overflow-y: auto;\n }\n\n .err {\n color: var(--err-color);\n font-size: 12px;\n padding: 6px 2px;\n }\n /* Failed-turn block: error detail + retry button (replaces the old bare\n [error] line so the user can read what happened and re-run the turn). */\n .err-block {\n background: linear-gradient(180deg, rgba(255, 85, 119, 0.10), rgba(255, 85, 119, 0.02));\n border: 1px solid rgba(255, 85, 119, 0.30);\n border-radius: 14px;\n padding: 10px 12px;\n margin: 6px 0;\n }\n .err-block .err-head {\n color: var(--err-color);\n font-size: 12px;\n font-weight: 600;\n letter-spacing: 0.03em;\n margin-bottom: 4px;\n }\n .err-block .err-detail {\n color: var(--err-color);\n font-size: 12px;\n line-height: 1.45;\n word-break: break-word;\n white-space: pre-wrap;\n opacity: 0.9;\n }\n .err-block .err-retry {\n margin-top: 8px;\n padding: 4px 12px;\n font-size: 12px;\n font-weight: 600;\n color: var(--accent);\n background: var(--accent-soft);\n border: 1px solid rgba(106, 212, 255, 0.40);\n border-radius: 8px;\n cursor: pointer;\n }\n .err-block .err-retry:hover {\n background: var(--accent-glow);\n }\n\n /* Idle pulse + while-you-were-away banner injected into #log */\n .idle-pulse {\n color: var(--dream);\n font-size: 12px;\n font-style: italic;\n padding: 4px 2px;\n letter-spacing: 0.03em;\n }\n .idle-block {\n background: linear-gradient(180deg, rgba(180, 135, 255, 0.10), rgba(180, 135, 255, 0.02));\n border: 1px solid rgba(180, 135, 255, 0.30);\n border-radius: 14px;\n padding: 12px 14px;\n margin: 6px 0;\n font-size: 12.5px;\n line-height: 1.6;\n color: var(--fg);\n }\n .idle-head {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.10em;\n color: var(--dream);\n font-weight: 700;\n margin-bottom: 6px;\n display: flex;\n align-items: center;\n gap: 8px;\n }\n .idle-time {\n margin-left: auto;\n font-weight: 400;\n letter-spacing: 0.05em;\n color: var(--fg-3);\n font-size: 10px;\n }\n\n /* Composer */\n #attachPreview {\n padding: 4px 22px 0;\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n min-height: 0;\n }\n #attachPreview:empty { padding: 0; }\n .attach-chip {\n background: var(--bg-card-strong);\n border: 1px solid var(--border-strong);\n color: var(--fg-2);\n font-size: 11px;\n padding: 3px 8px;\n border-radius: 8px;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n }\n .attach-rm {\n background: transparent;\n border: 0;\n color: var(--fg-3);\n cursor: pointer;\n font-size: 13px;\n padding: 0;\n line-height: 1;\n }\n .attach-rm:hover { color: var(--err-color); }\n\n #form {\n padding: 14px 20px 18px;\n background: rgba(7, 9, 26, 0.6);\n backdrop-filter: blur(30px);\n -webkit-backdrop-filter: blur(30px);\n border-top: 1px solid var(--border-new);\n display: grid;\n grid-template-columns: 36px 36px 1fr 96px;\n gap: 10px;\n align-items: end;\n }\n\n #plusBtn, #recordBtn {\n align-self: stretch;\n display: flex;\n align-items: center;\n justify-content: center;\n background: transparent;\n border: 0;\n color: var(--fg-3);\n font-size: 17px;\n cursor: pointer;\n border-radius: 10px;\n transition: background 120ms ease, color 120ms ease;\n min-height: 44px;\n padding: 0;\n }\n #plusBtn { font-size: 22px; }\n #plusBtn:hover, #recordBtn:hover { background: var(--bg-card); color: var(--fg); }\n #plusBtn.flash { background: var(--accent); color: var(--bg-deep); }\n\n /* \uFF0B composer menu (merged attach + screenshot) */\n .plus-wrap { position: relative; align-self: stretch; display: flex; }\n .plus-menu {\n display: none;\n position: absolute;\n left: 0;\n bottom: calc(100% + 8px);\n min-width: 168px;\n background: var(--bg-deep, #0c1024);\n border: 1px solid var(--border-new, rgba(255,255,255,.1));\n border-radius: 11px;\n padding: 5px;\n box-shadow: 0 14px 34px rgba(0,0,0,.5);\n z-index: 30;\n }\n .plus-menu.open { display: block; }\n .plus-menu button {\n display: flex; align-items: center; gap: 9px; width: 100%;\n background: transparent; border: 0; color: var(--fg); font-size: 13px;\n padding: 9px 10px; border-radius: 8px; cursor: pointer; text-align: left;\n }\n .plus-menu button:hover { background: var(--bg-card); }\n .plus-menu .g { width: 18px; text-align: center; }\n\n /* Top icon function bar (\u529F\u80FD\u533A) inside #viewChat */\n .fnbar {\n display: flex; align-items: center; gap: 6px;\n padding: 7px 16px;\n border-bottom: 1px solid var(--border-new, rgba(255,255,255,.08));\n background: rgba(255,255,255,.02);\n }\n .fbtn {\n width: 34px; height: 34px; flex: none;\n display: flex; align-items: center; justify-content: center;\n background: transparent; border: 1px solid transparent; border-radius: 9px;\n color: var(--fg-2); cursor: pointer;\n transition: background 120ms ease, color 120ms ease;\n }\n .fbtn:hover { background: var(--bg-card, rgba(255,255,255,.06)); color: var(--fg); }\n .fbtn svg { width: 19px; height: 19px; display: block; }\n .fbar-spacer { flex: 1; }\n .fn-find {\n height: 28px; width: 150px; font-size: 12px; padding: 0 9px;\n border-radius: 8px; border: 1px solid var(--border-strong, rgba(255,255,255,.14));\n background: rgba(0,0,0,.3); color: var(--fg);\n }\n /* Pulsing red while recording. */\n #recordBtn.recording {\n background: var(--err-color);\n color: #fff;\n animation: rec-pulse 1.1s ease-in-out infinite;\n }\n @keyframes rec-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.55; } }\n /* Off-screen the file input instead of display:none. WKWebView's\n implicit <label>\u2192<input type=file> click forward doesn't fire on a\n fully display:none input \u2014 the OS file picker silently no-ops.\n Off-screening keeps the input \"live\" while invisible. */\n #fileInput {\n position: absolute;\n width: 1px;\n height: 1px;\n opacity: 0;\n pointer-events: none;\n overflow: hidden;\n }\n\n #input {\n background: var(--bg-card-strong);\n border: 1px solid var(--border-strong);\n border-radius: 14px;\n color: var(--fg);\n font-family: inherit;\n font-size: 13.5px;\n line-height: 1.45;\n padding: 11px 13px;\n resize: none;\n outline: none;\n width: 100%;\n min-height: 44px;\n max-height: 200px;\n transition: border-color 120ms ease, box-shadow 120ms ease;\n }\n #input:focus {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-soft);\n }\n #input::placeholder { color: var(--fg-3); }\n\n #sendBtn {\n background: linear-gradient(180deg, var(--accent) 0%, #4eb8e5 100%);\n color: #0a1024;\n border: 0;\n border-radius: 14px;\n height: 100%;\n font-family: inherit;\n font-size: 12.5px;\n font-weight: 700;\n letter-spacing: 0.06em;\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 6px;\n padding: 10px 14px;\n transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;\n box-shadow: 0 4px 14px rgba(106, 212, 255, 0.25);\n }\n #sendBtn:hover:not(:disabled) {\n transform: translateY(-1px);\n box-shadow: 0 6px 18px rgba(106, 212, 255, 0.35);\n }\n #sendBtn:active:not(:disabled) { transform: translateY(0); }\n #sendBtn:disabled { opacity: 0.5; cursor: wait; }\n /* Hide the legacy pixel send icon; the text label is enough in the new theme. */\n #sendBtn img { display: none; }\n\n /* \u2500\u2500 Mobile / narrow \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n @media (max-width: 720px) {\n body {\n padding-top: env(safe-area-inset-top);\n padding-bottom: env(safe-area-inset-bottom);\n }\n .frame {\n grid-template-columns: 1fr;\n /* titlebar \u00B7 sidebar (capped, scrolls) \u00B7 main view-stack (fills rest) */\n grid-template-rows: auto auto 1fr;\n grid-template-areas:\n \"titlebar\"\n \"sidebar\"\n \"main\";\n }\n .sidebar {\n max-height: 38vh;\n padding: 14px 14px 12px;\n gap: 14px;\n }\n #form {\n grid-template-columns: 36px 36px 1fr 84px;\n padding: 10px 14px 14px;\n }\n #input { font-size: 16px; /* prevents iOS Safari auto-zoom */ }\n }\n\n /* \u2500\u2500 Forced compact / \"sidebar mode\" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n Same stacked layout as the narrow breakpoint, but toggled on at ANY\n width (persisted client-side) so Lisa can be docked as a skinny panel. */\n body.force-compact .frame {\n grid-template-columns: 1fr;\n grid-template-rows: auto auto 1fr;\n grid-template-areas:\n \"titlebar\"\n \"sidebar\"\n \"main\";\n }\n body.force-compact .sidebar {\n max-height: 38vh;\n padding: 14px 14px 12px;\n gap: 14px;\n }\n body.force-compact #form { grid-template-columns: 36px 36px 1fr 84px; }\n\n /* ===================================================================\n Modal panel (skills / memory / tools / soul) \u2014 unchanged from the\n legacy pixel-art shell. Uses the legacy CSS vars declared above.\n =================================================================== */\n .modal-bg {\n position: fixed; inset: 0;\n background: rgba(0, 0, 0, 0.7);\n display: none;\n align-items: center; justify-content: center;\n z-index: 100;\n }\n .modal-bg.open { display: flex; }\n .modal {\n background: var(--bg-card-strong);\n border: 1px solid var(--border-strong);\n border-radius: 16px;\n backdrop-filter: blur(30px);\n -webkit-backdrop-filter: blur(30px);\n box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);\n max-width: 720px;\n width: 90vw;\n max-height: 80vh;\n display: flex;\n flex-direction: column;\n }\n .modal-head {\n padding: 14px 18px;\n border-bottom: 1px solid var(--border-new);\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n .modal-title {\n font-size: 13px;\n font-weight: 700;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--accent);\n }\n .modal-close {\n background: transparent;\n border: 1px solid var(--border-new);\n color: var(--fg-2);\n font-family: inherit;\n font-size: 11px;\n letter-spacing: 0.06em;\n padding: 5px 10px;\n border-radius: 8px;\n cursor: pointer;\n }\n .modal-close:hover { color: var(--err-color); border-color: rgba(255, 85, 119, 0.40); }\n .modal-body {\n padding: 16px 18px;\n overflow-y: auto;\n font-size: 12.5px;\n line-height: 1.55;\n color: var(--fg-2);\n }\n .modal-body h3 {\n font-size: 11px;\n font-weight: 700;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--accent);\n margin: 14px 0 6px 0;\n border-bottom: 1px dashed var(--border-new);\n padding-bottom: 5px;\n }\n .modal-body h3:first-child { margin-top: 0; }\n .modal-body .item {\n padding: 7px 0;\n border-bottom: 1px dotted var(--border-new);\n }\n .modal-body .item:last-child { border: none; }\n .modal-body .name {\n color: var(--warm);\n font-weight: 600;\n font-size: 12px;\n }\n .modal-body .desc { color: var(--fg-3); margin-top: 2px; }\n .modal-body .pair-qr {\n display: flex;\n justify-content: center;\n margin: 4px 0 14px;\n }\n .modal-body .pair-qr svg {\n width: 200px;\n height: 200px;\n background: #fff;\n padding: 10px;\n border-radius: 12px;\n }\n .modal-body .pair-row {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 7px 0;\n border-bottom: 1px solid var(--line, rgba(255,255,255,0.07));\n }\n .modal-body .pair-row:last-child { border: none; }\n .modal-body .pair-label {\n flex: 0 0 52px;\n color: var(--fg-3);\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n }\n .modal-body .pair-val {\n flex: 1 1 auto;\n min-width: 0;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 11.5px;\n color: var(--fg-1, #e8e8ea);\n overflow-wrap: anywhere;\n user-select: all;\n }\n .modal-body .pair-copy {\n flex: 0 0 auto;\n font-family: inherit;\n font-size: 11px;\n font-weight: 700;\n color: var(--fg-2);\n background: var(--btn-bg, rgba(255,255,255,0.08));\n border: 1px solid var(--line, rgba(255,255,255,0.12));\n border-radius: 6px;\n padding: 3px 9px;\n cursor: pointer;\n }\n .modal-body .pair-copy:hover { filter: brightness(1.12); }\n .modal-body .plan-select {\n margin-top: 6px;\n font-family: inherit;\n font-size: 11px;\n font-weight: 700;\n padding: 4px 12px;\n border: 0;\n border-radius: 8px;\n cursor: pointer;\n background: var(--accent);\n color: #07091a;\n }\n .modal-body .plan-select:hover { filter: brightness(1.08); }\n .modal-body .plan-select[disabled] {\n background: var(--fg-faint);\n color: var(--fg-3);\n cursor: default;\n }\n .modal-body pre {\n background: rgba(0, 0, 0, 0.30);\n padding: 10px 12px;\n border-left: 2px solid var(--accent);\n border-radius: 4px;\n white-space: pre-wrap;\n margin: 6px 0;\n color: var(--fg);\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n font-size: 11.5px;\n line-height: 1.5;\n }\n .modal-body .empty { color: var(--fg-faint); font-style: italic; }\n\n /* ===================================================================\n Birth ritual overlay \u2014 full-screen, one-time. Uses legacy palette\n intentionally (it's a separate ceremonial moment).\n =================================================================== */\n .birth-overlay {\n position: fixed;\n inset: 0;\n background:\n radial-gradient(ellipse at center, rgba(40, 30, 80, 0.95) 0%, rgba(5, 5, 20, 1) 70%);\n z-index: 9999;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 32px;\n }\n .birth-overlay.open { display: flex; }\n .birth-content {\n position: relative;\n z-index: 2;\n width: min(800px, 95vw);\n max-height: 90vh;\n overflow-y: auto;\n }\n .birth-stars {\n text-align: center;\n color: var(--accent);\n font-size: 14px;\n letter-spacing: 8px;\n text-shadow: 0 0 8px var(--accent-glow);\n animation: starBlink 1.5s steps(3) infinite;\n }\n @keyframes starBlink {\n 0%, 30%, 100% { opacity: 1; }\n 50% { opacity: 0.4; }\n }\n .birth-title {\n text-align: center;\n color: var(--warm);\n font-size: 22px;\n font-weight: 700;\n letter-spacing: 6px;\n margin: 24px 0 32px;\n text-shadow: 0 0 12px rgba(255, 208, 102, 0.45);\n }\n .birth-step {\n margin: 14px 0;\n padding: 10px 16px;\n border-left: 3px solid var(--border-new);\n background: rgba(20, 26, 64, 0.55);\n border-radius: 0 8px 8px 0;\n opacity: 0;\n transform: translateY(8px);\n transition: opacity 0.4s ease-out, transform 0.4s ease-out;\n }\n .birth-step.shown { opacity: 1; transform: translateY(0); }\n .birth-step.active {\n border-left-color: var(--warm);\n background: rgba(255, 208, 102, 0.08);\n }\n .birth-step.done { border-left-color: var(--accent); }\n .birth-step .step-name {\n font-size: 10.5px;\n font-weight: 700;\n letter-spacing: 0.10em;\n color: var(--fg-3);\n text-transform: uppercase;\n margin-bottom: 4px;\n }\n .birth-step.active .step-name { color: var(--warm); }\n .birth-step.done .step-name { color: var(--accent); }\n .birth-step .step-detail {\n color: var(--fg);\n font-size: 13px;\n line-height: 1.55;\n word-break: break-word;\n }\n .birth-step .step-cursor {\n display: inline-block;\n width: 6px;\n background: var(--warm);\n height: 0.9em;\n vertical-align: middle;\n animation: blink 0.8s steps(2) infinite;\n }\n @keyframes blink { 50% { opacity: 0; } }\n .birth-final {\n margin-top: 36px;\n text-align: center;\n color: var(--warm);\n font-size: 15px;\n font-weight: 700;\n letter-spacing: 0.10em;\n text-shadow: 0 0 10px rgba(255, 208, 102, 0.4);\n opacity: 0;\n transition: opacity 0.6s ease-in;\n }\n .birth-final.shown { opacity: 1; }\n .birth-enter {\n margin: 24px auto 0;\n display: block;\n background: linear-gradient(180deg, var(--accent) 0%, #4eb8e5 100%);\n border: 0;\n color: #0a1024;\n font-family: inherit;\n font-weight: 700;\n font-size: 13px;\n padding: 12px 32px;\n border-radius: 12px;\n cursor: pointer;\n letter-spacing: 0.10em;\n opacity: 0;\n transition: opacity 0.6s ease-in, transform 120ms ease;\n box-shadow: 0 6px 18px rgba(106, 212, 255, 0.35);\n }\n .birth-enter.shown { opacity: 1; }\n .birth-enter:hover { transform: translateY(-1px); }\n .birth-error {\n color: var(--err-color);\n text-align: center;\n margin-top: 24px;\n font-size: 11.5px;\n letter-spacing: 0.05em;\n }\n\n /* ===================================================================\n API-key config overlay \u2014 same modernized treatment.\n =================================================================== */\n .cfg-overlay {\n position: fixed;\n inset: 0;\n background:\n radial-gradient(ellipse at center, rgba(40, 30, 80, 0.95) 0%, rgba(5, 5, 20, 1) 70%);\n z-index: 9998;\n display: none;\n align-items: center;\n justify-content: center;\n padding: 32px;\n }\n .cfg-overlay.open { display: flex; }\n .cfg-card {\n position: relative;\n z-index: 2;\n width: min(520px, 95vw);\n background: var(--bg-card-strong);\n border: 1px solid var(--border-strong);\n border-radius: 16px;\n backdrop-filter: blur(30px);\n -webkit-backdrop-filter: blur(30px);\n box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);\n padding: 28px 32px;\n }\n .cfg-stars {\n text-align: center;\n color: var(--accent);\n font-size: 12px;\n letter-spacing: 6px;\n text-shadow: 0 0 8px var(--accent-glow);\n animation: starBlink 1.5s steps(3) infinite;\n }\n .cfg-title {\n text-align: center;\n color: var(--warm);\n font-size: 16px;\n font-weight: 700;\n letter-spacing: 6px;\n margin: 16px 0 6px;\n }\n .cfg-sub {\n text-align: center;\n color: var(--fg-3);\n font-size: 12.5px;\n margin-bottom: 22px;\n line-height: 1.5;\n }\n .cfg-sub a { color: var(--accent); text-decoration: underline; }\n .cfg-field { display: block; margin: 14px 0; }\n .cfg-label {\n display: block;\n font-size: 10.5px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--accent);\n margin-bottom: 6px;\n }\n .cfg-label .opt {\n color: var(--fg-3);\n font-size: 9.5px;\n margin-left: 6px;\n text-transform: none;\n letter-spacing: 0;\n font-weight: 400;\n }\n .cfg-input {\n width: 100%;\n background: var(--bg-card);\n border: 1px solid var(--border-strong);\n border-radius: 10px;\n color: var(--fg);\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n font-size: 13px;\n padding: 10px 12px;\n }\n .cfg-input:focus {\n outline: none;\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-soft);\n }\n .cfg-help {\n color: var(--fg-3);\n font-size: 11px;\n margin-top: 6px;\n }\n .cfg-help code {\n color: var(--fg-2);\n background: rgba(0, 0, 0, 0.3);\n padding: 1px 5px;\n border-radius: 3px;\n font-size: 11px;\n }\n .cfg-actions {\n margin-top: 22px;\n display: flex;\n justify-content: center;\n }\n .cfg-save {\n background: linear-gradient(180deg, var(--accent) 0%, #4eb8e5 100%);\n border: 0;\n color: #0a1024;\n font-family: inherit;\n font-size: 12.5px;\n font-weight: 700;\n padding: 11px 28px;\n border-radius: 12px;\n cursor: pointer;\n letter-spacing: 0.10em;\n box-shadow: 0 4px 14px rgba(106, 212, 255, 0.25);\n transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;\n }\n .cfg-save:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(106, 212, 255, 0.35); }\n .cfg-save:active { transform: translateY(0); }\n .cfg-save:disabled { opacity: 0.55; cursor: wait; }\n .cfg-error {\n color: var(--err-color);\n font-size: 11px;\n text-align: center;\n margin-top: 14px;\n min-height: 14px;\n }\n</style>\n</head><body>\n\n<div class=\"frame\">\n\n <!-- \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 Title bar (drag zone) \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 -->\n <div class=\"titlebar\">\n <span>Lisa</span><span class=\"session-tag\" id=\"titlebarSession\"></span>\n </div>\n\n <!-- \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 Sidebar \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 -->\n <aside class=\"sidebar\">\n\n <!-- Identity card -->\n <div class=\"identity\">\n <div class=\"avatar-wrap\">\n <img id=\"mascot\" src=\"/assets/lisa-mascot.png\" alt=\"Lisa\" draggable=\"false\">\n </div>\n <div>\n <h1>Lisa</h1>\n <p class=\"sub\" id=\"identitySub\">\u2014</p>\n <div class=\"mood\" id=\"mascotTag\">neutral</div>\n </div>\n </div>\n\n <!-- Primary navigation (view switcher \u2014 wired in setupConsole) -->\n <nav class=\"nav-list\" id=\"navList\">\n <button class=\"nav-item active\" type=\"button\" data-view=\"chat\"><span class=\"nav-ico\">\u25CD</span>Chat</button>\n <button class=\"nav-item\" type=\"button\" data-view=\"dashboard\"><span class=\"nav-ico\">\u25A6</span>Dashboard</button>\n <button class=\"nav-item\" type=\"button\" data-view=\"control\"><span class=\"nav-ico\">\u2318</span>Control<span class=\"nav-tag\" id=\"navAgentCount\">0</span></button>\n <button class=\"nav-item\" type=\"button\" data-view=\"reve\"><span class=\"nav-ico\">\u263E</span>R\u00EAve</button>\n <button class=\"nav-item\" type=\"button\" data-view=\"sense\"><span class=\"nav-ico\">\u25C9</span>Sense</button>\n <button class=\"nav-item\" type=\"button\" data-view=\"memory\"><span class=\"nav-ico\">\u2726</span>Memory</button>\n </nav>\n\n <!-- Currently wanting -->\n <div class=\"sb-section\">\n <h2>currently wanting</h2>\n <p class=\"body-text\" id=\"sbDesire\">\u2014</p>\n </div>\n\n <!-- Claude Code monitor -->\n <div class=\"card tint-claude\" id=\"sbClaudeCard\">\n <div class=\"h\">\n <div class=\"left\">agents</div>\n <div class=\"count\">\u25B6\uFE0E <span id=\"sbClaudeCount\">0</span></div>\n </div>\n <button id=\"sbDelegateBtn\" class=\"delegate-btn\" type=\"button\" title=\"Start an agent\">\n \uFF0B delegate a task\n </button>\n <div id=\"sbClaudeRows\">\n <div class=\"session-empty\">(idle)</div>\n </div>\n </div>\n\n <!-- Mail digest (connect a mailbox \u2192 daily classified digest) -->\n <div class=\"card tint-mail\" id=\"sbMailCard\">\n <div class=\"h\">\n <div class=\"left\">mail</div>\n <div class=\"count\" id=\"sbMailCount\"></div>\n </div>\n <div id=\"sbMailBody\">\n <div class=\"session-empty\">(not connected)</div>\n </div>\n <button id=\"sbMailConnectBtn\" class=\"delegate-btn\" type=\"button\" title=\"Connect a mailbox\">\n \uFF0B connect mailbox\n </button>\n </div>\n\n <!-- Last reflection (collapsed pointer to the most recent \u2605) -->\n <div class=\"card tint-idle\" id=\"sbReflection\" style=\"display:none;\">\n <div class=\"h\">\n <div class=\"left\">\u2605 last reflection</div>\n </div>\n <p style=\"margin:0; font-size:11.5px; color:var(--fg-2); line-height:1.5;\" id=\"sbReflectionBody\"></p>\n </div>\n\n <!-- (SOUL/SKILLS/TOOLS/PLANS \u2192 top function bar; MEMORY \u2192 rail view) -->\n\n <!-- Proactive autonomy toggle (master switch \u2014 wired in setupConsole) -->\n <div class=\"proactive-toggle\" id=\"proactiveToggle\" role=\"switch\" aria-checked=\"false\" tabindex=\"0\" title=\"Let Lisa watch and act on her own when you're away\">\n <span class=\"pt-label\">Proactive</span>\n <span class=\"pt-track\"><span class=\"pt-knob\"></span></span>\n </div>\n\n <!-- Compact / sidebar-mode toggle (client-side; forces the narrow stacked\n layout at any width so Lisa can dock as a skinny panel) -->\n <div class=\"proactive-toggle\" id=\"compactToggle\" role=\"switch\" aria-checked=\"false\" tabindex=\"0\" title=\"Compact / sidebar mode \u2014 dock Lisa as a narrow panel (also auto on a small window)\">\n <span class=\"pt-label\">Compact</span>\n <span class=\"pt-track\"><span class=\"pt-knob\"></span></span>\n </div>\n\n <!-- Footer: current session id -->\n <div class=\"sb-footer\">\n <span class=\"session-id\" id=\"sessionId\">\u2014</span>\n <span class=\"badge-count\" id=\"sbSessionBadge\" title=\"total sessions\">\u00B7</span>\n </div>\n </aside>\n\n <!-- \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 Main pane \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 -->\n <div class=\"main\">\n\n <!-- Chat view (default home) \u2014 function bar + log + attachments + composer -->\n <div class=\"view active\" id=\"viewChat\">\n\n <!-- Top icon function bar (\u529F\u80FD\u533A): quick panels + find -->\n <div class=\"fnbar\" id=\"fnbar\">\n <button class=\"fbtn\" type=\"button\" data-panel=\"soul\" title=\"Soul\" aria-label=\"Soul\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3l2.2 6.6L21 12l-6.8 2.4L12 21l-2.2-6.6L3 12l6.8-2.4z\"/></svg></button>\n <button class=\"fbtn\" type=\"button\" data-panel=\"skills\" title=\"Skills\" aria-label=\"Skills\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M13 2 4 14h7l-1 8 10-12h-7z\"/></svg></button>\n <button class=\"fbtn\" type=\"button\" data-panel=\"tools\" title=\"Tools\" aria-label=\"Tools\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z\"/></svg></button>\n <button class=\"fbtn\" type=\"button\" data-panel=\"plans\" title=\"Coding plans\" aria-label=\"Coding plans\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"6\" y1=\"3\" x2=\"6\" y2=\"15\"/><circle cx=\"18\" cy=\"6\" r=\"3\"/><circle cx=\"6\" cy=\"18\" r=\"3\"/><path d=\"M18 9a9 9 0 0 1-9 9\"/></svg></button>\n <button class=\"fbtn\" type=\"button\" data-panel=\"pair\" title=\"Pair phone\" aria-label=\"Pair phone\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"7\" y=\"2\" width=\"10\" height=\"20\" rx=\"2\"/><line x1=\"11\" y1=\"18\" x2=\"13\" y2=\"18\"/></svg></button>\n <span class=\"fbar-spacer\"></span>\n <input id=\"fnFind\" class=\"fn-find\" type=\"text\" placeholder=\"find in chat\u2026\" autocomplete=\"off\" style=\"display:none\">\n <button class=\"fbtn\" type=\"button\" id=\"fnSearchBtn\" title=\"Find in conversation\" aria-label=\"Find\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg></button>\n </div>\n\n <!-- Chat log (messages, tool blocks, idle blocks injected here) -->\n <div id=\"log\"></div>\n\n <!-- Attachment chip strip (above composer) -->\n <div id=\"attachPreview\"></div>\n\n <!-- Composer -->\n <form id=\"form\">\n <input type=\"file\" id=\"fileInput\" accept=\"image/*,.pdf,.txt,.md,.csv,.json\" multiple>\n <div class=\"plus-wrap\">\n <button type=\"button\" id=\"plusBtn\" title=\"Attach or screenshot\">\uFF0B</button>\n <div class=\"plus-menu\" id=\"plusMenu\">\n <button type=\"button\" id=\"pmAttach\"><span class=\"g\">\uD83D\uDCCE</span> Attach file</button>\n <button type=\"button\" id=\"pmShot\"><span class=\"g\">\uD83D\uDCF7</span> Screenshot</button>\n </div>\n </div>\n <button type=\"button\" id=\"recordBtn\" title=\"Dictate \u2014 speak and Lisa drops polished text in the box (hold to record a summary)\">\uD83C\uDF99</button>\n <textarea id=\"input\" placeholder=\"Talk to Lisa\u2026 (Enter to send \u00B7 Shift+Enter for newline)\" autofocus></textarea>\n <button type=\"submit\" id=\"sendBtn\">\n <img src=\"/assets/icon-send.png\" alt=\"\">\n SEND \u2192\n </button>\n </form>\n </div><!-- /#viewChat -->\n\n <!-- Console views (populated lazily by setupConsole in lisa-client.ts) -->\n <section class=\"view\" id=\"viewDashboard\"></section>\n <section class=\"view\" id=\"viewControl\"></section>\n <section class=\"view\" id=\"viewReve\"></section>\n <section class=\"view\" id=\"viewSense\"></section>\n <section class=\"view\" id=\"viewMemory\"></section>\n </div>\n</div>\n\n<!-- \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 Overlays \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 -->\n\n<div class=\"modal-bg\" id=\"modalBg\">\n <div class=\"modal\">\n <div class=\"modal-head\">\n <div class=\"modal-title\" id=\"modalTitle\">\u2026</div>\n <button class=\"modal-close\" id=\"modalClose\">Close \u00B7 esc</button>\n </div>\n <div class=\"modal-body\" id=\"modalBody\">\u2026</div>\n </div>\n</div>\n\n<!-- API key config overlay (shown if no key configured yet) -->\n<div class=\"cfg-overlay\" id=\"cfgOverlay\">\n <div class=\"cfg-card\">\n <div class=\"cfg-stars\">\u2726 \u2726 \u2726 \u2726 \u2726</div>\n <div class=\"cfg-title\">SET \u00B7 API \u00B7 KEY</div>\n <div class=\"cfg-sub\">\n Lisa needs an Anthropic API key to wake up.<br>\n <a href=\"https://console.anthropic.com/\" target=\"_blank\" rel=\"noopener\">Get one at console.anthropic.com</a>\n </div>\n <form id=\"cfgForm\">\n <label class=\"cfg-field\">\n <span class=\"cfg-label\">ANTHROPIC_API_KEY</span>\n <input class=\"cfg-input\" id=\"cfgAnthropic\" type=\"password\" autocomplete=\"off\"\n spellcheck=\"false\" placeholder=\"sk-ant-...\" required>\n </label>\n <label class=\"cfg-field\">\n <span class=\"cfg-label\">OPENAI_API_KEY <span class=\"opt\">(optional \u00B7 for gpt-* models)</span></span>\n <input class=\"cfg-input\" id=\"cfgOpenai\" type=\"password\" autocomplete=\"off\"\n spellcheck=\"false\" placeholder=\"sk-...\">\n </label>\n <div class=\"cfg-help\">\n Saved to <code>~/.lisa/config.env</code> with mode 0600. Stays on this machine.\n </div>\n <div class=\"cfg-actions\">\n <button class=\"cfg-save\" id=\"cfgSave\" type=\"submit\">SAVE &amp; CONTINUE</button>\n </div>\n <div class=\"cfg-error\" id=\"cfgError\"></div>\n </form>\n </div>\n</div>\n\n<!-- Birth ritual full-screen overlay -->\n<div class=\"birth-overlay\" id=\"birthOverlay\">\n <div class=\"birth-content\">\n <div class=\"birth-stars\">\u2726 \u2726 \u2726 \u2726 \u2726</div>\n <div class=\"birth-title\">B I R T H \u00B7 R I T U A L</div>\n <div id=\"birthSteps\"></div>\n <div class=\"birth-final\" id=\"birthFinal\"></div>\n <button class=\"birth-enter\" id=\"birthEnter\">ENTER</button>\n <div class=\"birth-error\" id=\"birthError\"></div>\n <div class=\"birth-stars\" style=\"margin-top: 24px;\">\u2726 \u2726 \u2726 \u2726 \u2726</div>\n </div>\n</div>\n\n<script>\n// \u2500\u2500 First-run safety net: never leave the user with a silent dead UI. Any\n// uncaught error / unreachable backend surfaces as a banner instead of nothing.\nfunction lisaBanner(msg) {\n var el = document.getElementById('lisaBanner');\n if (!el) {\n el = document.createElement('div');\n el.id = 'lisaBanner';\n el.style.cssText = 'position:fixed;left:50%;top:12px;transform:translateX(-50%);z-index:99999;max-width:min(700px,92vw);padding:11px 16px;border-radius:12px;background:rgba(255,85,119,.14);border:1px solid rgba(255,85,119,.5);color:#ffc2cf;font:13px/1.5 -apple-system,system-ui,sans-serif;box-shadow:0 8px 30px rgba(0,0,0,.5);user-select:text';\n document.body.appendChild(el);\n }\n el.textContent = msg;\n el.hidden = false;\n}\nfunction lisaClearBanner() { var el = document.getElementById('lisaBanner'); if (el) el.hidden = true; }\nfunction lisaSleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }\nwindow.addEventListener('error', function (e) {\n if (e && e.message) lisaBanner('Lisa UI error: ' + e.message + ' \u2014 reload (Cmd-R); restart the backend if it persists.');\n});\nwindow.addEventListener('unhandledrejection', function (e) {\n var r = e && e.reason;\n lisaBanner('Lisa task failed: ' + ((r && r.message) ? r.message : String(r)));\n});\n\nconst log = document.getElementById('log');\nconst input = document.getElementById('input');\nconst form = document.getElementById('form');\nconst sendBtn = document.getElementById('sendBtn');\nconst sessionEl = document.getElementById('sessionId');\nconst fileInput = document.getElementById('fileInput');\nconst attachPreview = document.getElementById('attachPreview');\n\n// \u2500\u2500 Attached files state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet pendingFiles = []; // Array of {name, mediaType, data (base64)}\n\nfunction readFileAsBase64(file) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const dataUrl = reader.result;\n const base64 = dataUrl.split(',')[1];\n resolve(base64);\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\n\nfunction inferMediaType(file) {\n if (file.type) return file.type;\n const ext = file.name.split('.').pop().toLowerCase();\n const map = { pdf: 'application/pdf', txt: 'text/plain', md: 'text/plain', csv: 'text/csv', json: 'application/json' };\n return map[ext] || 'application/octet-stream';\n}\n\n// \u2500\u2500 Vision: screenshot \u2192 composer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// lisaAttachImage adds an already-encoded {name,mediaType,data} object to\n// the pending attachments \u2014 used by both the in-page \uD83D\uDCF7 button and the\n// native global hotkey (Lisa.app calls lisaCaptureAndAttach via JS bridge).\nwindow.lisaAttachImage = function (file) {\n if (!file || !file.data) return;\n pendingFiles.push({\n name: file.name || 'screenshot.png',\n mediaType: file.mediaType || 'image/png',\n data: file.data,\n });\n renderAttachPreview();\n try { input.focus(); } catch (_) {}\n};\n\n// lisaPrefillComposer drops text into the composer WITHOUT sending \u2014 the user\n// reads it and hits Enter to send (that send is the confirmation). Used by the\n// island's screen-advisor \"Optimize \u25B8\" card (via the Swift bridge) and by the\n// ?prefill= URL param (plain browser tabs).\nwindow.lisaPrefillComposer = function (text) {\n if (!text || !input) return;\n input.value = String(text);\n try { input.dispatchEvent(new Event('input', { bubbles: true })); } catch (_) {}\n try { input.focus(); input.setSelectionRange(input.value.length, input.value.length); } catch (_) {}\n};\n\n// On load, honour ?prefill=\u2026 (the island opens /?prefill=<task> in a browser\n// tab). Strip it from the URL afterwards so a refresh doesn't re-fill.\ntry {\n var _pf = new URLSearchParams(location.search).get('prefill');\n if (_pf) {\n window.lisaPrefillComposer(_pf);\n history.replaceState(null, '', location.pathname);\n }\n} catch (_) {}\n\n// lisaCaptureAndAttach asks the server to run a screen capture, then\n// attaches the result. mode: 'interactive' (crosshair, default) | 'full'.\n// Returns true if an image was attached, false if cancelled/failed.\n// Exposed on window so the native app's global hotkey can invoke it.\nlet capturing = false;\nwindow.lisaCaptureAndAttach = async function (mode) {\n if (capturing) return false;\n capturing = true;\n const btn = document.getElementById('plusBtn');\n if (btn) btn.classList.add('flash');\n try {\n const res = await fetch('/api/vision/capture', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ mode: mode || 'interactive' }),\n });\n if (!res.ok) {\n console.warn('[vision] capture failed: HTTP ' + res.status);\n return false;\n }\n const data = await res.json();\n if (data.cancelled || !data.file) return false;\n window.lisaAttachImage(data.file);\n return true;\n } catch (err) {\n console.warn('[vision] capture error:', err);\n return false;\n } finally {\n capturing = false;\n if (btn) setTimeout(() => btn.classList.remove('flash'), 200);\n }\n};\n\nfunction renderAttachPreview() {\n attachPreview.innerHTML = '';\n pendingFiles.forEach((f, i) => {\n const chip = document.createElement('span');\n chip.className = 'attach-chip';\n chip.textContent = f.name;\n const rm = document.createElement('button');\n rm.type = 'button';\n rm.className = 'attach-rm';\n rm.textContent = '\u00D7';\n rm.onclick = () => { pendingFiles.splice(i, 1); renderAttachPreview(); };\n chip.appendChild(rm);\n attachPreview.appendChild(chip);\n });\n}\n\nfileInput.addEventListener('change', async () => {\n for (const file of fileInput.files) {\n const data = await readFileAsBase64(file);\n pendingFiles.push({ name: file.name, mediaType: inferMediaType(file), data });\n }\n fileInput.value = '';\n renderAttachPreview();\n});\n\n// The natural <label>\u2192<input type=file> click-forwarding is supposed\n// to open the picker without any JS. But in WKWebView under our\n// layout it never reaches runOpenPanel. We add an explicit click\n// listener that forwards the click synchronously (preserving the\n// user-gesture context) and logs to console so we can verify in the\n// inspector if it ever silently no-ops again.\n// \u2500\u2500 \uFF0B composer menu: merges \uD83D\uDCCE attach + \uD83D\uDCF7 screenshot into one button \u2500\u2500\nconst plusBtnEl = document.getElementById('plusBtn');\nconst plusMenuEl = document.getElementById('plusMenu');\nfunction closePlusMenu() { if (plusMenuEl) plusMenuEl.classList.remove('open'); }\nif (plusBtnEl && plusMenuEl) {\n plusBtnEl.addEventListener('click', (e) => { e.stopPropagation(); plusMenuEl.classList.toggle('open'); });\n document.addEventListener('click', (e) => {\n if (plusMenuEl.classList.contains('open') && !plusMenuEl.contains(e.target) && e.target !== plusBtnEl) closePlusMenu();\n });\n}\nconst pmAttachEl = document.getElementById('pmAttach');\nif (pmAttachEl) {\n pmAttachEl.addEventListener('click', () => {\n closePlusMenu();\n try { fileInput.click(); } catch (err) { console.error('[attach] fileInput.click failed:', err); }\n });\n}\nconst pmShotEl = document.getElementById('pmShot');\nif (pmShotEl) {\n pmShotEl.addEventListener('click', () => { closePlusMenu(); void window.lisaCaptureAndAttach('interactive'); });\n}\n\n// \u2500\u2500 Audio recording \u2192 transcribe \u2192 Lisa summarizes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \uD83C\uDF99 toggles a MediaRecorder. On stop: POST the clip to /api/voice/transcribe\n// (server-side Whisper), then send the transcript into the normal chat with a\n// \"summarize this\" framing \u2014 so Lisa produces the summary in her own voice and\n// it's persisted + discussable like any turn. First click prompts mic\n// permission (browser-native).\nconst recordBtnEl = document.getElementById('recordBtn');\nlet mediaRecorder = null;\nlet recordedChunks = [];\nlet recordStream = null;\n// 'dictation' (default): polish speech \u2192 composer for you to edit + send.\n// 'summary' (long-press \uD83C\uDF99): the original record\u2192Lisa-summarizes flow.\nlet recordMode = 'dictation';\n\nfunction pickAudioMime() {\n const prefs = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg'];\n for (const m of prefs) {\n try { if (window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m; } catch (_) {}\n }\n return '';\n}\n\nasync function startRecording() {\n if (!navigator.mediaDevices || !window.MediaRecorder) {\n el('div', 'err', '[voice] recording not supported in this browser');\n return;\n }\n try {\n recordStream = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch (err) {\n el('div', 'err', '[voice] microphone access denied or unavailable');\n return;\n }\n const mimeType = pickAudioMime();\n recordedChunks = [];\n mediaRecorder = mimeType ? new MediaRecorder(recordStream, { mimeType }) : new MediaRecorder(recordStream);\n mediaRecorder.addEventListener('dataavailable', (e) => { if (e.data && e.data.size > 0) recordedChunks.push(e.data); });\n mediaRecorder.addEventListener('stop', () => { void finishRecording(); });\n mediaRecorder.start();\n if (recordBtnEl) { recordBtnEl.classList.add('recording'); recordBtnEl.textContent = '\u23F9'; recordBtnEl.title = 'Stop recording'; }\n}\n\nfunction stopRecordingTracks() {\n if (recordStream) { recordStream.getTracks().forEach((t) => t.stop()); recordStream = null; }\n if (recordBtnEl) { recordBtnEl.classList.remove('recording'); recordBtnEl.textContent = '\uD83C\uDF99'; recordBtnEl.title = 'Dictate \u2014 speak and Lisa drops polished text in the box (hold to record a summary)'; }\n}\n\nasync function finishRecording() {\n const mime = (mediaRecorder && mediaRecorder.mimeType) || 'audio/webm';\n stopRecordingTracks();\n const mode = recordMode;\n const blob = new Blob(recordedChunks, { type: mime });\n recordedChunks = [];\n if (blob.size === 0) return;\n // Transient status line: dictation polishes; summary transcribes.\n const pending = el('div', 'thinking', mode === 'dictation' ? '\u22EF transcribing + polishing' : '\u22EF transcribing recording');\n try {\n const data = await blobToBase64(blob);\n const res = await fetch('/api/voice/transcribe', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ data, mediaType: mime, mode }),\n });\n const out = await res.json();\n if (pending) pending.remove();\n if (!res.ok || out.error) {\n el('div', 'err', '[voice] ' + (out.error || ('HTTP ' + res.status)));\n return;\n }\n if (mode === 'dictation') {\n // Typeless-style: drop the polished text into the composer (cursor at end)\n // for you to review and send. Never auto-sends. Appends if you'd already\n // started typing.\n const text = (out.text || out.transcript || '').trim();\n if (!text) { el('div', 'err', '[voice] (nothing dictated)'); return; }\n const existing = input.value.trimEnd();\n input.value = existing ? existing + '\\n' + text : text;\n try { input.dispatchEvent(new Event('input', { bubbles: true })); } catch (_) {}\n try { input.focus(); input.setSelectionRange(input.value.length, input.value.length); } catch (_) {}\n return;\n }\n // summary mode: hand the transcript to Lisa with a summarize framing.\n const transcript = (out.transcript || '').trim();\n if (!transcript) { el('div', 'err', '[voice] (empty transcript)'); return; }\n const framed =\n \"I just recorded some audio. Here's the transcript \u2014 please give me a clear, \" +\n \"useful summary (key points, decisions, action items if any), then I might ask follow-ups.\\n\\n\" +\n \"--- transcript ---\\n\" + transcript;\n send(framed);\n } catch (err) {\n if (pending) pending.remove();\n el('div', 'err', '[voice] ' + (err && err.message ? err.message : 'transcription failed'));\n }\n}\n\nfunction blobToBase64(blob) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result).split(',')[1] || '');\n reader.onerror = reject;\n reader.readAsDataURL(blob);\n });\n}\n\nif (recordBtnEl) {\n // Short click \u2192 dictation (polished text into the composer). Press-and-hold\n // (\u2265500ms) \u2192 summary (record \u2192 Lisa summarizes). A click always follows\n // pointerup, so we just flag long-presses and read the flag on click.\n let longPress = false;\n let holdTimer = null;\n const clearHold = () => { if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; } };\n recordBtnEl.addEventListener('pointerdown', () => {\n longPress = false;\n clearHold();\n holdTimer = setTimeout(() => { longPress = true; }, 500);\n });\n recordBtnEl.addEventListener('pointerup', clearHold);\n recordBtnEl.addEventListener('pointercancel', clearHold);\n recordBtnEl.addEventListener('pointerleave', clearHold);\n recordBtnEl.addEventListener('click', () => {\n if (mediaRecorder && mediaRecorder.state === 'recording') {\n mediaRecorder.stop();\n longPress = false;\n return;\n }\n recordMode = longPress ? 'summary' : 'dictation';\n longPress = false;\n if (recordBtnEl) recordBtnEl.title = recordMode === 'summary' ? 'Recording for summary \u2014 click to stop' : 'Dictating \u2014 click to stop';\n void startRecording();\n });\n}\n\n// (\uD83D\uDCCE attach is now an item in the \uFF0B composer menu \u2014 see pmAttach above.)\n\n// Paste-to-attach: when the user has copied an image (screenshot,\n// image from a webpage, etc.) and presses \u2318V, pull the image off the\n// clipboard and add it to pendingFiles \u2014 same path the file picker\n// uses. Plain-text paste falls through to default behavior.\n//\n// We listen at the DOCUMENT level so paste works whether the textarea\n// is focused, the chat log is focused, or focus is on a tool-block.\n// (Some users press \u2318V right after launching the app, before clicking\n// anywhere \u2014 element-scoped handlers miss that.)\n//\n// For text paste we MUST NOT preventDefault \u2014 let the browser route\n// it to the focused element. Only intercept when we detect image\n// items.\nasync function handlePastedClipboard(ev) {\n const cb = ev.clipboardData;\n if (!cb) return;\n\n // Two clipboard surfaces \u2014 items (modern, exposes pasteboard files)\n // and files (older, doesn't include MIME type for some types). We\n // dedupe via the underlying File reference.\n const fileMap = new Map(); // File \u2192 mediaType (string)\n if (cb.items && cb.items.length) {\n for (const item of cb.items) {\n if (item.kind !== 'file') continue;\n if (!item.type || !item.type.startsWith('image/')) continue;\n const f = item.getAsFile();\n if (f) fileMap.set(f, item.type);\n }\n }\n if (cb.files && cb.files.length) {\n for (const f of cb.files) {\n if (f.type && f.type.startsWith('image/')) {\n if (!fileMap.has(f)) fileMap.set(f, f.type || 'image/png');\n }\n }\n }\n\n if (fileMap.size === 0) return; // text paste \u2014 leave default behavior alone\n\n ev.preventDefault();\n // Keep focus on the textarea so the user can keep typing right after.\n try { input.focus(); } catch (_) {}\n\n for (const [file, mediaType] of fileMap) {\n try {\n const data = await readFileAsBase64(file);\n const ext = (mediaType.split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '');\n const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);\n const name = file.name && file.name !== 'image.png'\n ? file.name\n : 'pasted-' + ts + '.' + ext;\n pendingFiles.push({ name, mediaType, data });\n } catch (err) {\n console.warn('[paste] failed to read image:', err);\n }\n }\n renderAttachPreview();\n}\n\ninput.addEventListener('paste', (ev) => { void handlePastedClipboard(ev); });\n// Document-level fallback. If focus is somewhere other than the\n// textarea (e.g. just-launched, no focus yet), the textarea-level\n// paste listener never fires; this one catches it.\ndocument.addEventListener('paste', (ev) => {\n // Avoid double-firing when the textarea ALREADY handled it: if\n // ev.defaultPrevented is set, the textarea listener already\n // consumed the paste and added the file.\n if (ev.defaultPrevented) return;\n void handlePastedClipboard(ev);\n});\n\n// Surface session id from server header on first request. The titlebar\n// shows the same id with a leading \"\u00B7\" separator after the \"Lisa\" label.\nfetch('/session').then(r => r.json()).then(s => {\n sessionEl.textContent = s.id;\n const titlebarSession = document.getElementById('titlebarSession');\n if (titlebarSession) titlebarSession.textContent = '\u00B7 ' + s.id;\n});\n\n// \u2500\u2500 Persistent /events SSE: mood updates + idle messages + Claude\n// activity, lifetime of page.\nfunction connectEvents() {\n const es = new EventSource('/events');\n let idlePulseEl = null;\n es.addEventListener('message', (e) => {\n const ev = JSON.parse(e.data);\n if (ev.type === 'mood') {\n setMood(ev.slug);\n } else if (ev.type === 'idle_start') {\n if (!idlePulseEl) {\n idlePulseEl = document.createElement('div');\n idlePulseEl.className = 'idle-pulse';\n idlePulseEl.textContent = '\u22EF Lisa is thinking on her own time \u22EF';\n log.appendChild(idlePulseEl);\n log.scrollTop = log.scrollHeight;\n }\n } else if (ev.type === 'idle_message') {\n if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }\n const block = document.createElement('div');\n block.className = 'idle-block';\n const head = document.createElement('div');\n head.className = 'idle-head';\n head.textContent = '\u2605 WHILE YOU WERE AWAY';\n const time = document.createElement('span');\n time.className = 'idle-time';\n try { time.textContent = new Date(ev.at).toLocaleTimeString(); } catch {}\n head.appendChild(time);\n block.appendChild(head);\n const bodyEl = document.createElement('div');\n bodyEl.textContent = ev.text;\n block.appendChild(bodyEl);\n log.appendChild(block);\n log.scrollTop = log.scrollHeight;\n // sidebar reflection card mirrors the latest while-you-were-away\n if (typeof updateReflection === 'function') updateReflection(ev.text);\n } else if (ev.type === 'idle_done') {\n if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }\n } else if (ev.type === 'idle_error') {\n if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }\n const e2 = document.createElement('div');\n e2.className = 'err';\n e2.textContent = '[idle error] ' + ev.message;\n log.appendChild(e2);\n } else if (ev.type === 'agent_session_update') {\n // D4a \u2014 sidebar multi-agent monitor refresh (defined later in the\n // \"sidebar live wiring\" block). Generalized from claude_session_update\n // so codex / opencode / git / \u2026 sessions update the sidebar too.\n if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions();\n } else if (ev.type === 'mail_digest_update' || ev.type === 'mail_accounts_update') {\n if (typeof window.refreshMail === 'function') window.refreshMail();\n }\n });\n es.onerror = () => {\n es.close();\n setTimeout(connectEvents, 3000); // reconnect\n };\n}\nconnectEvents();\n\n// \u2500\u2500 API key config gate: show overlay if no key is configured \u2500\u2500\u2500\u2500\u2500\nconst cfgOverlay = document.getElementById('cfgOverlay');\nconst cfgForm = document.getElementById('cfgForm');\nconst cfgAnthropic = document.getElementById('cfgAnthropic');\nconst cfgOpenai = document.getElementById('cfgOpenai');\nconst cfgSaveBtn = document.getElementById('cfgSave');\nconst cfgError = document.getElementById('cfgError');\n\ncfgForm.addEventListener('submit', async (ev) => {\n ev.preventDefault();\n cfgError.textContent = '';\n const anthropic = cfgAnthropic.value.trim();\n const openai = cfgOpenai.value.trim();\n if (!anthropic) {\n cfgError.textContent = 'ANTHROPIC_API_KEY is required.';\n return;\n }\n cfgSaveBtn.disabled = true;\n try {\n const res = await fetch('/api/config/save', {\n method: 'POST',\n headers: {'content-type': 'application/json'},\n body: JSON.stringify({ anthropicKey: anthropic, openaiKey: openai || undefined }),\n });\n if (!res.ok) {\n const txt = await res.text().catch(() => '');\n cfgError.textContent = 'Save failed: HTTP ' + res.status + (txt ? ' \u2014 ' + txt.slice(0, 120) : '');\n cfgSaveBtn.disabled = false;\n return;\n }\n cfgAnthropic.value = '';\n cfgOpenai.value = '';\n cfgOverlay.classList.remove('open');\n maybeBirth();\n } catch (err) {\n cfgError.textContent = 'Save failed: ' + err.message;\n cfgSaveBtn.disabled = false;\n }\n});\n\n// \u2500\u2500 Birth ritual: show overlay if Lisa hasn't been born yet \u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst birthOverlay = document.getElementById('birthOverlay');\nconst birthStepsEl = document.getElementById('birthSteps');\nconst birthFinalEl = document.getElementById('birthFinal');\nconst birthEnterBtn = document.getElementById('birthEnter');\nconst birthErrorEl = document.getElementById('birthError');\n\nbirthEnterBtn.addEventListener('click', () => {\n birthOverlay.classList.remove('open');\n setTimeout(() => location.reload(), 300);\n});\n\nasync function maybeBirth() {\n const status = await fetch('/api/soul').then(r => r.json());\n if (status.born) return;\n birthOverlay.classList.add('open');\n startBirthStream();\n}\n\nfunction appendBirthStep(step) {\n const prevActive = birthStepsEl.querySelector('.birth-step.active');\n if (prevActive) {\n prevActive.classList.remove('active');\n prevActive.classList.add('done');\n }\n const div = document.createElement('div');\n div.className = 'birth-step active';\n const name = document.createElement('div');\n name.className = 'step-name';\n name.textContent = step;\n div.appendChild(name);\n const detail = document.createElement('div');\n detail.className = 'step-detail';\n const cursor = document.createElement('span');\n cursor.className = 'step-cursor';\n detail.appendChild(cursor);\n div.appendChild(detail);\n birthStepsEl.appendChild(div);\n setTimeout(() => div.classList.add('shown'), 50);\n return detail;\n}\n\nfunction typewriter(el, text, done) {\n const cursor = el.querySelector('.step-cursor');\n if (cursor) cursor.remove();\n let i = 0;\n const speed = Math.max(8, Math.min(28, 600 / text.length));\n function tick() {\n if (i >= text.length) {\n const c = document.createElement('span');\n c.className = 'step-cursor';\n el.appendChild(c);\n done && done();\n return;\n }\n el.appendChild(document.createTextNode(text[i]));\n i++;\n el.parentElement.parentElement.scrollTop = el.parentElement.parentElement.scrollHeight;\n setTimeout(tick, speed);\n }\n tick();\n}\n\nasync function startBirthStream() {\n birthErrorEl.textContent = '';\n let currentDetail = null;\n let queue = [];\n let processing = false;\n\n function processQueue() {\n if (processing) return;\n if (queue.length === 0) return;\n processing = true;\n const ev = queue.shift();\n if (ev.kind === 'step') {\n currentDetail = appendBirthStep(ev.name);\n typewriter(currentDetail, ev.detail || '', () => {\n processing = false;\n processQueue();\n });\n } else if (ev.kind === 'done') {\n const last = birthStepsEl.querySelector('.birth-step.active');\n if (last) { last.classList.remove('active'); last.classList.add('done'); }\n birthFinalEl.textContent = ev.message;\n birthFinalEl.classList.add('shown');\n birthEnterBtn.classList.add('shown');\n processing = false;\n } else if (ev.kind === 'error') {\n birthErrorEl.textContent = ev.message;\n processing = false;\n }\n }\n\n try {\n const res = await fetch('/api/birth', { method: 'POST' });\n if (!res.ok) {\n birthErrorEl.textContent = 'Birth failed: HTTP ' + res.status + '. Check ANTHROPIC_API_KEY.';\n return;\n }\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buf = '';\n while (true) {\n const { value, done } = await reader.read();\n if (done) break;\n buf += decoder.decode(value, { stream: true });\n let idx;\n while ((idx = buf.indexOf('\\n\\n')) >= 0) {\n const event = buf.slice(0, idx);\n buf = buf.slice(idx + 2);\n const m = event.match(/^data: (.*)$/m);\n if (!m) continue;\n const ev = JSON.parse(m[1]);\n queue.push(ev);\n processQueue();\n }\n }\n } catch (err) {\n birthErrorEl.textContent = 'Birth failed: ' + err.message;\n }\n}\n\nasync function startupGate() {\n // Retry: the page can render a hair before the backend's API routes answer,\n // and a transient miss must not silently skip onboarding (the old bug \u2014 a\n // bare catch+return left a dead UI with no key prompt, no birth, no message).\n let cfg = null;\n for (let i = 0; i < 6 && !cfg; i++) {\n try { cfg = await fetch('/api/config/status').then(r => r.json()); }\n catch (e) { if (i < 5) await lisaSleep(700); }\n }\n if (!cfg) {\n lisaBanner('Cannot reach Lisa backend on localhost:5757. Start it: lisa serve --web (install once: npm i -g @oratis/lisa). Retrying\u2026');\n setTimeout(startupGate, 3000);\n return;\n }\n lisaClearBanner();\n if (!cfg.configured) {\n cfgOverlay.classList.add('open');\n setTimeout(() => cfgAnthropic.focus(), 50);\n return;\n }\n try {\n await maybeBirth();\n } catch (e) {\n lisaBanner('Birth check failed: ' + ((e && e.message) ? e.message : e) + ' \u2014 reload to retry.');\n }\n}\nstartupGate();\n\n// \u2500\u2500 history load & infinite-scroll \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet historyPage = 0;\nlet historyLoading = false;\nlet historyExhausted = false;\n\nfunction textOfMessage(msg) {\n if (typeof msg.content === 'string') return msg.content.trim();\n if (!Array.isArray(msg.content)) return '';\n return msg.content\n .filter(b => b.type === 'text')\n .map(b => b.text)\n .join('')\n .trim();\n}\n\nfunction prependHistoryMessages(messages) {\n const fragment = document.createDocumentFragment();\n for (const msg of messages) {\n const text = textOfMessage(msg);\n if (!text) continue;\n const roleDiv = document.createElement('div');\n roleDiv.className = 'role ' + (msg.role === 'user' ? 'you' : 'lisa');\n roleDiv.textContent = msg.role === 'user' ? 'YOU' : 'LISA';\n const span = document.createElement('span');\n span.className = 'msg';\n span.textContent = text;\n fragment.appendChild(roleDiv);\n fragment.appendChild(span);\n }\n log.insertBefore(fragment, log.firstChild);\n}\n\nasync function loadHistoryPage() {\n if (historyLoading || historyExhausted) return;\n historyLoading = true;\n const prevScrollHeight = log.scrollHeight;\n try {\n const res = await fetch('/api/history?page=' + historyPage);\n const data = await res.json();\n if (data.messages && data.messages.length) {\n prependHistoryMessages(data.messages);\n log.scrollTop = log.scrollHeight - prevScrollHeight;\n historyPage++;\n }\n if (!data.hasMore) {\n historyExhausted = true;\n if (historyPage > 1) {\n const marker = document.createElement('div');\n marker.style.cssText = 'text-align:center;color:var(--fg-3);font-size:11px;padding:8px 0;letter-spacing:0.06em;';\n marker.textContent = '\u2014 end of history \u2014';\n log.insertBefore(marker, log.firstChild);\n }\n }\n } finally {\n historyLoading = false;\n }\n}\n\nloadHistoryPage();\nlog.addEventListener('scroll', () => {\n if (log.scrollTop < 80) loadHistoryPage();\n});\n\n// \u2500\u2500 mascot crossfade on mood event \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst mascotEl = document.getElementById('mascot');\nconst mascotTagEl = document.getElementById('mascotTag');\nlet currentMood = 'neutral';\nfunction setMood(slug) {\n if (!slug || slug === currentMood) return;\n const url = '/assets/lisa/' + encodeURIComponent(slug) + '.png';\n const probe = new Image();\n probe.onload = () => {\n mascotEl.classList.add('fading');\n setTimeout(() => {\n mascotEl.src = url;\n mascotTagEl.textContent = 'mood: ' + slug;\n mascotEl.classList.remove('fading');\n currentMood = slug;\n }, 250);\n };\n probe.onerror = () => { /* asset not generated yet \u2014 keep current */ };\n probe.src = url;\n}\n\n// \u2500\u2500 modal panel: SOUL / SKILLS / MEMORY / TOOLS \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst modalBg = document.getElementById('modalBg');\nconst modalTitle = document.getElementById('modalTitle');\nconst modalBody = document.getElementById('modalBody');\nconst modalClose = document.getElementById('modalClose');\n\nfunction escapeHtml(s) {\n return String(s).replace(/[&<>\"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;'}[c]));\n}\nfunction openModal(title, html) {\n modalTitle.textContent = title;\n modalBody.innerHTML = html;\n modalBg.classList.add('open');\n}\nfunction closeModal() { modalBg.classList.remove('open'); }\nmodalClose.addEventListener('click', closeModal);\nmodalBg.addEventListener('click', (e) => { if (e.target === modalBg) closeModal(); });\ndocument.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });\n\nasync function showSkills() {\n openModal('SKILLS', '<div class=\"empty\">loading\u2026</div>');\n const data = await fetch('/api/skills').then(r => r.json());\n if (!data.skills.length) {\n modalBody.innerHTML = '<div class=\"empty\">No skills saved yet. Lisa will start saving useful workflows as you use her.</div>';\n return;\n }\n modalBody.innerHTML = data.skills.map(s =>\n '<div class=\"item\"><div class=\"name\">' + escapeHtml(s.name) + '</div><div class=\"desc\">' + escapeHtml(s.description) + '</div></div>'\n ).join('');\n}\n\nasync function showMemory() {\n openModal('MEMORY', '<div class=\"empty\">loading\u2026</div>');\n const data = await fetch('/api/memory').then(r => r.json());\n modalBody.innerHTML =\n '<h3>USER.md \u2014 what Lisa remembers about you</h3>' +\n '<pre>' + escapeHtml(data.user || '(empty)') + '</pre>' +\n '<h3>MEMORY.md \u2014 Lisa\\'s working notes</h3>' +\n '<pre>' + escapeHtml(data.memory || '(empty)') + '</pre>';\n}\n\nasync function showTools() {\n openModal('TOOLS', '<div class=\"empty\">loading\u2026</div>');\n const data = await fetch('/api/tools').then(r => r.json());\n modalBody.innerHTML = data.tools.map(t =>\n '<div class=\"item\"><div class=\"name\">' + escapeHtml(t.name) + '</div><div class=\"desc\">' + escapeHtml(t.description) + '</div></div>'\n ).join('');\n}\n\nasync function showPlans() {\n openModal('CODING PLANS', '<div class=\"empty\">loading\u2026</div>');\n let data;\n try { data = await fetch('/api/plans').then(r => r.json()); }\n catch (e) { modalBody.innerHTML = '<div class=\"empty\">failed to load plans</div>'; return; }\n const intro = '<div class=\"empty\">Run coding work on a subscription you already pay for (Claude Pro/Max \u00B7 ChatGPT/Codex \u00B7 Copilot) instead of a metered API key. LISA drives the vendor CLI you are logged into, so it bills your plan, not a key. This does not change her own model.</div>';\n const rows = (data.plans || []).map(function (p) {\n const star = p.selected ? ' \u2014 selected' : '';\n const usage = p.usage ? '<div class=\"desc\">usage: ' + escapeHtml(p.usage) + '</div>' : '';\n let btn;\n if (p.selected) btn = '<button class=\"plan-select\" data-plan=\"' + escapeHtml(p.id) + '\" disabled>selected</button>';\n else if (p.available) btn = '<button class=\"plan-select\" data-plan=\"' + escapeHtml(p.id) + '\">use this</button>';\n else btn = '<button class=\"plan-select\" data-plan=\"' + escapeHtml(p.id) + '\" disabled>not installed</button>';\n return '<div class=\"item\"><div class=\"name\">' + escapeHtml(p.mark + ' ' + p.label + star) + '</div><div class=\"desc\">' + escapeHtml(p.detail) + '</div>' + usage + btn + '</div>';\n }).join('');\n const clear = '<div class=\"item\"><button class=\"plan-select\" data-plan=\"none\">clear selection</button></div>';\n modalBody.innerHTML = intro + rows + clear;\n document.querySelectorAll('.plan-select').forEach(function (btn) {\n if (btn.disabled) return;\n btn.addEventListener('click', function () { selectPlan(btn.dataset.plan); });\n });\n}\n\nasync function selectPlan(plan) {\n try {\n const res = await fetch('/api/plans/select', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ plan: plan }),\n });\n if (!res.ok) {\n const t = await res.text().catch(function () { return ''; });\n modalBody.insertAdjacentHTML('afterbegin', '<div class=\"empty\" style=\"color:var(--err-color)\">select failed: ' + escapeHtml(String(res.status) + (t ? ' \u2014 ' + t : '')) + '</div>');\n return;\n }\n showPlans();\n } catch (e) {\n modalBody.insertAdjacentHTML('afterbegin', '<div class=\"empty\" style=\"color:var(--err-color)\">select error: ' + escapeHtml(e.message) + '</div>');\n }\n}\n\n// \u2500\u2500 Pair phone: mint a device token + show copyable pairing details \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Mirrors \"lisa pair\" / the Mac app Pair iPhone window for browser users. The mint\n// endpoint is loopback-only, so this works from a localhost browser on the Mac\n// (a LAN browser gets 403, handled below). The server detects the Mac's LAN host\n// and returns the lisa-pair:// link + host/port/token so the phone can paste the\n// link OR type the fields into Lisa Pocket \u2192 Settings \u2192 Pair.\nfunction pairRow(label, value) {\n return '<div class=\"pair-row\"><span class=\"pair-label\">' + escapeHtml(label) + '</span>'\n + '<code class=\"pair-val\">' + escapeHtml(value) + '</code>'\n + '<button class=\"pair-copy\" type=\"button\">Copy</button></div>';\n}\nasync function showPair() {\n openModal('PAIR PHONE', '<div class=\"empty\">minting a pairing code\u2026</div>');\n let res;\n try {\n res = await fetch('/api/pair/start', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ name: 'phone', platform: 'ios' }),\n });\n } catch (e) {\n modalBody.innerHTML = '<div class=\"empty\">Couldn\\'t reach the Lisa backend.</div>';\n return;\n }\n if (res.status === 403) {\n modalBody.innerHTML = '<div class=\"empty\">Pairing can only be started on the Mac itself. Open this page at <code>http://localhost:' + escapeHtml(location.port || '5757') + '</code> on your Mac, then try again.</div>';\n return;\n }\n if (!res.ok) {\n modalBody.innerHTML = '<div class=\"empty\">Pairing failed (HTTP ' + res.status + ').</div>';\n return;\n }\n const data = await res.json().catch(function () { return {}; });\n if (!data.token) { modalBody.innerHTML = '<div class=\"empty\">The server returned no token.</div>'; return; }\n const port = data.port || 5757;\n const host = data.host || '';\n const link = data.url || ('lisa-pair://v1?host=' + encodeURIComponent(host) + '&port=' + port + '&token=' + encodeURIComponent(data.token) + '&name=phone');\n let html = '';\n if (data.qrSvg) html += '<div class=\"pair-qr\">' + data.qrSvg + '</div>';\n html += '<div class=\"empty\">Scan the code in Lisa Pocket \u2192 Settings \u2192 Scan QR \u2014 or paste the link / type the three fields below. Keep the phone on the same Wi-Fi (or tailnet) as this Mac.</div>';\n html += pairRow('Link', link);\n html += pairRow('Host', host || '(your Mac\\'s Wi-Fi IP)');\n html += pairRow('Port', String(port));\n html += pairRow('Token', data.token);\n if (!host) html += '<div class=\"empty\">Couldn\\'t detect your Mac\\'s LAN address \u2014 enter its Wi-Fi IP or tailnet name on the phone.</div>';\n modalBody.innerHTML = html;\n const codes = modalBody.querySelectorAll('.pair-row');\n for (let i = 0; i < codes.length; i++) {\n const row = codes[i];\n const btn = row.querySelector('.pair-copy');\n const val = row.querySelector('.pair-val');\n btn.addEventListener('click', function () {\n navigator.clipboard.writeText(val.textContent).then(function () {\n const prev = btn.textContent; btn.textContent = 'Copied'; setTimeout(function () { btn.textContent = prev; }, 1200);\n }).catch(function () {});\n });\n }\n}\n\nasync function showSoul() {\n openModal('\u2605 SOUL', '<div class=\"empty\">loading\u2026</div>');\n const data = await fetch('/api/soul').then(r => r.json());\n if (!data.born) {\n modalBody.innerHTML = '<div class=\"empty\">Lisa hasn\\'t been born yet. Restart the CLI without --no-birth and the birth ritual will run.</div>';\n return;\n }\n const s = data.summary;\n let html = '';\n html += '<h3>name</h3><div>' + escapeHtml(s.name) + '</div>';\n html += '<h3>born</h3><div>' + escapeHtml(s.seed.bornAt) + ' \u00B7 big5(O' + Math.round(s.seed.bigFive.openness*100) + ' C' + Math.round(s.seed.bigFive.conscientiousness*100) + ' E' + Math.round(s.seed.bigFive.extraversion*100) + ' A' + Math.round(s.seed.bigFive.agreeableness*100) + ' N' + Math.round(s.seed.bigFive.neuroticism*100) + ')</div>';\n html += '<h3>identity</h3><pre>' + escapeHtml(s.identity) + '</pre>';\n html += '<h3>purpose</h3><pre>' + escapeHtml(s.purpose) + '</pre>';\n html += '<h3>constitution</h3><pre>' + escapeHtml(s.constitution) + '</pre>';\n if (s.values?.length) {\n html += '<h3>values</h3>' + s.values.map(v =>\n '<div class=\"item\"><div class=\"name\">' + escapeHtml(v.title) + '</div><div class=\"desc\">' + escapeHtml(v.body) + '</div></div>'\n ).join('');\n }\n if (s.opinions?.length) {\n html += '<h3>opinions</h3>' + s.opinions.map(o =>\n '<div class=\"item\"><div class=\"name\">' + escapeHtml(o.stance) + ' (conf ' + o.confidence.toFixed(2) + ')</div></div>'\n ).join('');\n }\n if (s.desires?.length) {\n html += '<h3>desires</h3>' + s.desires.map(d =>\n '<div class=\"item\"><div class=\"name\">' + escapeHtml(d.what) + (d.actionable ? ' [heartbeat-active]' : '') + '</div><div class=\"desc\">' + escapeHtml(d.why) + '</div></div>'\n ).join('');\n }\n html += '<h3>emotions</h3>' + Object.entries(s.emotions.values).map(([k, v]) => {\n const len = 12;\n const filled = Math.round(Math.abs(v) * len);\n const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(len - filled);\n return '<div class=\"item\"><div class=\"name\">' + escapeHtml(k) + '</div><div class=\"desc\">' + (v < 0 ? '-' : ' ') + bar + ' ' + v.toFixed(2) + '</div></div>';\n }).join('');\n if (s.tampered?.length) {\n html += '<h3>\u26A0 tampered</h3><div>External edits detected on: ' + s.tampered.map(escapeHtml).join(', ') + '</div>';\n }\n html += '<h3 style=\"color: var(--fg-3); font-size: 10px;\">privacy note</h3><div class=\"empty\">Her journal lives at ~/.lisa/soul/journal/ but is intentionally not shown here \u2014 that is hers to keep.</div>';\n modalBody.innerHTML = html;\n}\n\n// Panel openers \u2014 the top function bar (.fbtn) + any legacy .badge share this.\ndocument.querySelectorAll('[data-panel]').forEach(b => {\n b.addEventListener('click', () => {\n const which = b.dataset.panel;\n if (which === 'soul') showSoul();\n else if (which === 'skills') showSkills();\n else if (which === 'memory') showMemory();\n else if (which === 'tools') showTools();\n else if (which === 'plans') showPlans();\n else if (which === 'pair') showPair();\n });\n});\n\n// Find in conversation \u2014 toggle a filter box that hides non-matching log rows.\nconst fnSearchBtn = document.getElementById('fnSearchBtn');\nconst fnFind = document.getElementById('fnFind');\nfunction filterLog(q) {\n const logEl = document.getElementById('log');\n if (!logEl) return;\n for (const child of logEl.children) {\n child.style.display = !q || (child.textContent || '').toLowerCase().includes(q) ? '' : 'none';\n }\n}\nif (fnSearchBtn && fnFind) {\n fnSearchBtn.addEventListener('click', () => {\n const show = fnFind.style.display === 'none';\n fnFind.style.display = show ? '' : 'none';\n if (show) { fnFind.focus(); } else { fnFind.value = ''; filterLog(''); }\n });\n fnFind.addEventListener('input', () => filterLog(fnFind.value.trim().toLowerCase()));\n fnFind.addEventListener('keydown', (e) => {\n if (e.key === 'Escape') { fnFind.value = ''; filterLog(''); fnFind.style.display = 'none'; }\n });\n}\n\n// Compact / sidebar mode \u2014 force the narrow stacked layout at any width, persisted.\nconst compactToggleEl = document.getElementById('compactToggle');\nif (compactToggleEl) {\n let compactOn = false;\n try { compactOn = localStorage.getItem('lisaCompact') === '1'; } catch (e) {}\n const applyCompact = () => {\n document.body.classList.toggle('force-compact', compactOn);\n compactToggleEl.classList.toggle('on', compactOn);\n compactToggleEl.setAttribute('aria-checked', compactOn ? 'true' : 'false');\n };\n applyCompact();\n const toggleCompact = () => {\n compactOn = !compactOn;\n try { localStorage.setItem('lisaCompact', compactOn ? '1' : '0'); } catch (e) {}\n applyCompact();\n };\n compactToggleEl.addEventListener('click', toggleCompact);\n compactToggleEl.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleCompact(); }\n });\n}\n\nlet currentLisaSpan = null;\nlet pendingTools = new Map();\nlet thinkingEl = null;\n\nfunction el(tag, cls, text) {\n const node = document.createElement(tag);\n if (cls) node.className = cls;\n if (text != null) node.textContent = text;\n log.appendChild(node);\n log.scrollTop = log.scrollHeight;\n return node;\n}\n\nfunction ensureLisaSpan() {\n if (currentLisaSpan) return currentLisaSpan;\n if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }\n el('div', 'role lisa', 'LISA');\n currentLisaSpan = el('span', 'msg', '');\n return currentLisaSpan;\n}\n\nfunction previewInput(name, input) {\n if (!input || typeof input !== 'object') return '';\n const order = ['command', 'pattern', 'query', 'path', 'description', 'audio_path', 'text', 'name', 'action', 'entry'];\n for (const k of order) {\n if (typeof input[k] === 'string' && input[k]) {\n let v = input[k].replace(/\\s+/g, ' ').trim();\n if (v.length > 120) v = v.slice(0, 117) + '...';\n return v;\n }\n }\n try {\n const s = JSON.stringify(input);\n return s.length > 120 ? s.slice(0, 117) + '...' : s;\n } catch { return ''; }\n}\n\nasync function send(message) {\n input.value = '';\n input.style.height = 'auto';\n el('div', 'role you', 'YOU');\n el('span', 'msg', message || '(attachment)');\n if (pendingFiles.length) {\n const names = pendingFiles.map(f => f.name).join(', ');\n el('span', 'msg attach-label', '\uD83D\uDCCE ' + names);\n }\n const filesToSend = [...pendingFiles];\n pendingFiles = [];\n renderAttachPreview();\n await runChat(message, filesToSend);\n}\n\n// On failure, show the error detail with a retry button that re-runs the same\n// turn. Kept separate from send() so retry never re-appends the user's bubble\n// or re-reads the (already-cleared) attachment tray.\nfunction showError(detail, message, filesToSend) {\n if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }\n const block = el('div', 'err-block', null);\n const head = document.createElement('div');\n head.className = 'err-head';\n head.textContent = '\u26A0 \u8BF7\u6C42\u51FA\u9519';\n block.appendChild(head);\n const body = document.createElement('div');\n body.className = 'err-detail';\n body.textContent = detail || 'unknown error';\n block.appendChild(body);\n const retry = document.createElement('button');\n retry.type = 'button';\n retry.className = 'err-retry';\n retry.textContent = '\u21BB \u91CD\u8BD5';\n retry.addEventListener('click', () => {\n block.remove();\n runChat(message, filesToSend);\n });\n block.appendChild(retry);\n log.scrollTop = log.scrollHeight;\n}\n\nasync function runChat(message, filesToSend) {\n sendBtn.disabled = true;\n currentLisaSpan = null;\n pendingTools.clear();\n thinkingEl = el('div', 'thinking', '\u22EF thinking');\n // The agent emits an error event AND the server re-sends it from its turn\n // catch \u2014 dedupe so one failure renders exactly one error block.\n let errored = false;\n const fail = (detail) => {\n if (errored) return;\n errored = true;\n showError(detail, message, filesToSend);\n };\n try {\n const res = await fetch('/chat', {\n method: 'POST',\n headers: {'content-type': 'application/json'},\n body: JSON.stringify({message, files: filesToSend}),\n });\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buf = '';\n while (true) {\n const {value, done} = await reader.read();\n if (done) break;\n buf += decoder.decode(value, {stream: true});\n let idx;\n while ((idx = buf.indexOf('\\n\\n')) >= 0) {\n const evRaw = buf.slice(0, idx);\n buf = buf.slice(idx + 2);\n const m = evRaw.match(/^data: (.*)$/m);\n if (!m) continue;\n const ev = JSON.parse(m[1]);\n if (ev.type === 'text') {\n ensureLisaSpan().textContent += ev.text;\n } else if (ev.type === 'tool_start') {\n if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }\n currentLisaSpan = null;\n const block = el('div', 'tool-block', null);\n const head = document.createElement('div');\n head.className = 'tool-head';\n head.innerHTML = '<span class=\"tool-icon\">\u2699</span> <span class=\"tool-name\"></span> <span class=\"tool-spinner\">...</span>';\n head.querySelector('.tool-name').textContent = ev.name;\n block.appendChild(head);\n const preview = previewInput(ev.name, ev.input);\n if (preview) {\n const p = document.createElement('div');\n p.className = 'tool-input';\n p.textContent = preview;\n block.appendChild(p);\n }\n pendingTools.set(ev.name, block);\n } else if (ev.type === 'tool_end') {\n const block = pendingTools.get(ev.name);\n if (block) {\n const spinner = block.querySelector('.tool-spinner');\n if (spinner) spinner.textContent = ev.isError ? '\u2717' : '\u2713';\n if (ev.isError) block.classList.add('tool-error');\n if (ev.resultPreview) {\n const r = document.createElement('div');\n r.className = 'tool-result';\n r.textContent = ev.resultPreview;\n block.appendChild(r);\n }\n pendingTools.delete(ev.name);\n }\n } else if (ev.type === 'mood') {\n setMood(ev.slug);\n } else if (ev.type === 'error') {\n fail(ev.message);\n } else if (ev.type === 'done') {\n if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }\n }\n }\n }\n if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }\n } catch (err) {\n fail(err.message);\n } finally {\n sendBtn.disabled = false;\n input.focus();\n }\n}\n\nform.addEventListener('submit', (ev) => {\n ev.preventDefault();\n const msg = input.value.trim();\n if (msg || pendingFiles.length) send(msg);\n});\n\ninput.addEventListener('keydown', (ev) => {\n if (ev.key === 'Enter' && !ev.shiftKey) {\n ev.preventDefault();\n form.dispatchEvent(new Event('submit'));\n }\n});\ninput.addEventListener('input', () => {\n input.style.height = 'auto';\n input.style.height = Math.min(input.scrollHeight, 200) + 'px';\n});\n\n// \u2500\u2500 PWA: register service worker + iOS install hint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nif ('serviceWorker' in navigator) {\n navigator.serviceWorker.register('/sw.js').catch(err => {\n console.warn('[pwa] sw register failed:', err);\n });\n}\n(function() {\n const isiOS = /iPad|iPhone|iPod/.test(navigator.userAgent);\n const isStandalone = window.matchMedia('(display-mode: standalone)').matches\n || (window.navigator).standalone === true;\n if (!isiOS || isStandalone) return;\n if (localStorage.getItem('lisa.pwa.dismissed') === '1') return;\n setTimeout(() => {\n const banner = document.createElement('div');\n banner.style.cssText = 'position:fixed;bottom:8px;left:8px;right:8px;background:var(--bg-card-strong);border:1px solid var(--border-strong);border-radius:12px;padding:10px 12px;color:var(--fg);font-size:12px;z-index:9999;display:flex;gap:8px;align-items:center;';\n banner.innerHTML = '\u2726 Add Lisa to Home Screen: Share button \u2192 \"Add to Home Screen\"';\n const dismiss = document.createElement('button');\n dismiss.textContent = '\u2715';\n dismiss.style.cssText = 'background:transparent;border:none;color:var(--fg-2);cursor:pointer;font-size:14px;margin-left:auto;';\n dismiss.onclick = () => {\n localStorage.setItem('lisa.pwa.dismissed', '1');\n banner.remove();\n };\n banner.appendChild(dismiss);\n document.body.appendChild(banner);\n }, 5000);\n})();\n\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// \u2500\u2500 Sidebar live wiring \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n//\n// Populates the new sidebar blocks introduced by the redesign:\n// - identity card sub-line (born YYYY-MM-DD \u00B7 NN days)\n// - \"currently wanting\" paragraph (top actionable desire)\n// - Claude Code monitor card (active sessions)\n// - \"last reflection\" mini-card (most recent \u2605 idle message)\n// Wires to /api/island/ping + /api/claude/sessions + /api/soul, and\n// piggy-backs on the connectEvents() SSE listener above for live\n// claude_session_update + idle_message refreshes.\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n(function setupSidebarLive() {\n const sbDesire = document.getElementById('sbDesire');\n const sbClaudeCount = document.getElementById('sbClaudeCount');\n const sbClaudeRows = document.getElementById('sbClaudeRows');\n const sbReflection = document.getElementById('sbReflection');\n const sbReflectionBody = document.getElementById('sbReflectionBody');\n const sbSessionBadge = document.getElementById('sbSessionBadge');\n const identitySub = document.getElementById('identitySub');\n\n // Active session window matches the watcher's ACTIVE_WINDOW_MS.\n const ACTIVE_WINDOW_MS = 30 * 60 * 1000;\n\n function relativeTime(iso) {\n const ms = Date.now() - new Date(iso).getTime();\n if (ms < 30_000) return 'just now';\n if (ms < 60_000) return Math.round(ms / 1000) + 's';\n if (ms < 3600_000) return Math.round(ms / 60_000) + 'm';\n return Math.round(ms / 3600_000) + 'h';\n }\n\n function setDesire(text) {\n sbDesire.textContent = text || '(nothing actively pursued)';\n }\n\n window.updateReflection = function (text) {\n if (!text) { sbReflection.style.display = 'none'; return; }\n sbReflection.style.display = '';\n sbReflectionBody.textContent = '\"' + text.replace(/^[\"\u201C\u201D]+|[\"\u201C\u201D]+$/g, '').trim() + '\"';\n };\n\n // Compact one-line activity summary \u2014 mirrors agent-roster.ts formatActivity\n // (kept inline because this client script is a no-interpolation template\n // literal; the island uses the source-injected shared version).\n function sbActivity(s) {\n const a = s.activity;\n if (!a || typeof a !== 'object') return '';\n if (a.pendingPermission) return '\u26A0 wants to run ' + a.pendingPermission;\n const bits = [];\n if (a.lastError) bits.push('\u2717 ' + a.lastError);\n const prog = [];\n if (typeof a.turnCount === 'number' && a.turnCount > 0) prog.push('turn ' + a.turnCount);\n if (a.tokens && (a.tokens.input || a.tokens.output)) {\n const tot = (a.tokens.input || 0) + (a.tokens.output || 0);\n prog.push(tot >= 1000 ? Math.round(tot / 1000) + 'k tok' : tot + ' tok');\n }\n if (prog.length) bits.push(prog.join(' '));\n if (a.lastCommandName) bits.push('$ ' + a.lastCommandName);\n const tool = a.lastTools && a.lastTools.length ? a.lastTools[a.lastTools.length - 1] : '';\n const file = a.filesTouched && a.filesTouched.length ? (String(a.filesTouched[a.filesTouched.length - 1]).split('/').pop() || '') : '';\n if (tool && file) bits.push(tool + ' ' + file);\n else if (tool) bits.push(tool);\n else if (file) bits.push(file);\n return bits.join(' \u00B7 ');\n }\n\n // POST a control action to the right agent family (managed|pty), then refresh.\n function agentAction(fam, id, action, body) {\n fetch('/api/agents/' + fam + '/' + encodeURIComponent(id) + '/' + action, {\n method: 'POST',\n headers: body ? { 'content-type': 'application/json' } : {},\n body: body ? JSON.stringify(body) : undefined,\n }).then(function () {\n if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions();\n }).catch(function () {});\n }\n\n // Show a PTY agent's captured terminal tail in the modal \u2014 explicit + on\n // demand (it's content, so it's never folded into the structural roster).\n function ptyOutput(id) {\n fetch('/api/agents/pty/' + encodeURIComponent(id) + '/output').then(function (r) {\n return r.ok ? r.json() : null;\n }).then(function (d) {\n if (!d) return;\n openModal('agent output', '<pre>' + escapeHtml(d.output || '(no output yet)') + '</pre>');\n }).catch(function () {});\n }\n\n function setClaudeSessions(sessions) {\n const cutoff = Date.now() - ACTIVE_WINDOW_MS;\n const recent = sessions.filter(s => new Date(s.lastMtime).getTime() >= cutoff);\n sbClaudeCount.textContent = String(recent.length);\n // sort: errors first, then waiting, then working, then by mtime\n const rank = { error: 0, waiting: 1, working: 2, unknown: 3 };\n const rows = recent.slice().sort((a, b) => {\n const ra = rank[a.state] ?? 9;\n const rb = rank[b.state] ?? 9;\n if (ra !== rb) return ra - rb;\n return new Date(b.lastMtime).getTime() - new Date(a.lastMtime).getTime();\n }).slice(0, 8);\n while (sbClaudeRows.firstChild) sbClaudeRows.removeChild(sbClaudeRows.firstChild);\n if (rows.length === 0) {\n const empty = document.createElement('div');\n empty.className = 'session-empty';\n empty.textContent = '(idle)';\n sbClaudeRows.appendChild(empty);\n return;\n }\n for (const s of rows) {\n const row = document.createElement('div');\n row.className = 'session-row';\n const pip = document.createElement('div');\n pip.className = 'pip ' + (s.state || 'unknown');\n const name = document.createElement('div');\n name.className = 'name';\n // D4a \u2014 agent-kind chip inline before the project; omitted for plain\n // Claude so existing rows read unchanged.\n if (s.agent && s.agent !== 'claude-code') {\n const badge = document.createElement('span');\n badge.className = 'agent-badge';\n badge.textContent = s.agent;\n badge.title = s.agent;\n name.appendChild(badge);\n }\n // Label by git branch when available (more meaningful than a worktree\n // hash), stripping the claude/ prefix; fall back to the project name.\n // (String ops, not a regex \u2014 a /// here would be mangled by the outer\n // template literal that wraps this client script.)\n let label = s.project;\n if (s.activity && s.activity.gitBranch) {\n const br = String(s.activity.gitBranch);\n label = br.indexOf('claude/') === 0 ? br.slice(7) : br;\n }\n name.appendChild(document.createTextNode(label));\n const when = document.createElement('div');\n when.className = 'when';\n when.textContent = relativeTime(s.lastMtime);\n row.appendChild(pip);\n row.appendChild(name);\n row.appendChild(when);\n // Second line: structural activity (turns/tokens/tool\u00B7file, \u26A0pending, \u2717err).\n const actText = sbActivity(s);\n if (actText) {\n const act = document.createElement('div');\n act.className = 'session-act';\n act.textContent = actText;\n act.title = actText;\n row.appendChild(act);\n }\n // Controllable agents get inline controls: managed \u2192 approve/deny a pending\n // tool, send a follow-up, cancel; pty (real CLI under a PTY) \u2192 send, view\n // terminal output, cancel. Externally-started CLIs have no control channel\n // (no s.controllable) \u2192 observe only.\n const fam = s.controllable;\n if (fam) {\n const id = s.sessionId;\n const ctrl = document.createElement('div');\n ctrl.className = 'session-ctrl';\n const pending = fam === 'managed' && s.activity && s.activity.pendingPermission;\n if (pending) {\n const ap = document.createElement('button');\n ap.className = 'mc approve'; ap.textContent = '\u2713 approve';\n ap.addEventListener('click', function (e) { e.stopPropagation(); agentAction('managed', id, 'approve', { allow: true }); });\n const dn = document.createElement('button');\n dn.className = 'mc deny'; dn.textContent = '\u2715 deny';\n dn.addEventListener('click', function (e) { e.stopPropagation(); agentAction('managed', id, 'approve', { allow: false }); });\n ctrl.appendChild(ap); ctrl.appendChild(dn);\n } else if (s.state !== 'done') {\n const inp = document.createElement('input');\n inp.className = 'mc-send'; inp.type = 'text'; inp.placeholder = fam === 'pty' ? 'type into the CLI\u2026' : 'send a follow-up\u2026';\n inp.addEventListener('click', function (e) { e.stopPropagation(); });\n inp.addEventListener('keydown', function (e) {\n if (e.key === 'Enter' && inp.value.trim()) { e.preventDefault(); agentAction(fam, id, 'send', { text: inp.value.trim() }); inp.value = ''; }\n });\n ctrl.appendChild(inp);\n }\n if (fam === 'pty') {\n const out = document.createElement('button');\n out.className = 'mc'; out.textContent = '\u25A4'; out.title = 'View terminal output';\n out.addEventListener('click', function (e) { e.stopPropagation(); ptyOutput(id); });\n ctrl.appendChild(out);\n }\n if (s.state !== 'done') {\n const cancel = document.createElement('button');\n cancel.className = 'mc cancel'; cancel.textContent = '\u23F9'; cancel.title = 'Cancel agent';\n cancel.addEventListener('click', function (e) { e.stopPropagation(); agentAction(fam, id, 'cancel', null); });\n ctrl.appendChild(cancel);\n }\n if (ctrl.childNodes.length) row.appendChild(ctrl);\n }\n // Idle external claude session \u2192 adopt it: LISA resumes it under a PTY and\n // drives the continuation (then it shows as a controllable pty row). Only\n // offered for idle sessions; a live one can't be resumed without corrupting\n // its transcript (the server 409s, surfaced in the modal).\n if (s.resumable) {\n const ctrl = document.createElement('div');\n ctrl.className = 'session-ctrl';\n const adopt = document.createElement('button');\n adopt.className = 'mc adopt'; adopt.textContent = '\u21F2 adopt';\n adopt.title = 'Resume this session under LISA \u2014 then send / answer / cancel / view it';\n adopt.addEventListener('click', function (e) {\n e.stopPropagation();\n fetch('/api/agents/pty/start', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ agent: 'claude', resumeSessionId: s.sessionId, cwd: s.cwd || '' }),\n }).then(function (r) {\n if (!r.ok) { return r.text().then(function (t) { openModal('adopt', '<pre>' + escapeHtml(t) + '</pre>'); }); }\n if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions();\n }).catch(function () {});\n });\n ctrl.appendChild(adopt);\n row.appendChild(ctrl);\n }\n row.title = (s.stateReason ? s.state + ' \u00B7 ' + s.stateReason : s.state) + ' \u00B7 ' + s.project + ' \u00B7 ' + s.sessionId;\n sbClaudeRows.appendChild(row);\n }\n }\n\n async function refreshPing() {\n try {\n const r = await fetch('/api/island/ping');\n if (!r.ok) return;\n const data = await r.json();\n setDesire(data.current_desire);\n if (data.last_idle_message_text) {\n window.updateReflection(data.last_idle_message_text);\n }\n } catch {}\n }\n\n // Exposed so the SSE handler above can call this on\n // agent_session_update events without redeclaring the helper. D4a \u2014 the\n // multi-agent snapshot (all agents), not just Claude Code.\n window.refreshClaudeSessions = async function () {\n try {\n const r = await fetch('/api/agents/sessions');\n if (!r.ok) return;\n const data = await r.json();\n setClaudeSessions(data.sessions || []);\n } catch {}\n };\n\n // \"Delegate a task\" \u2192 open a modal to pick the agent kind + write the task.\n // (A roomy dialog beats the cramped 280px sidebar.) managed = LISA-run\n // (controllable); claude/codex = a real CLI under a PTY (needs LISA_PTY_AGENTS=1\n // \u2014 a 503/error surfaces inline in the dialog).\n function openDelegateModal() {\n openModal(\n 'Delegate a task',\n '<div class=\"delegate-modal\">' +\n '<label class=\"dm-label\">Agent</label>' +\n '<select id=\"dmKind\" class=\"dm-kind\">' +\n '<option value=\"managed\">managed \u2014 LISA runs it (approve each step)</option>' +\n '<option value=\"claude\">claude \u2014 real CLI under a PTY</option>' +\n '<option value=\"codex\">codex \u2014 real CLI under a PTY</option>' +\n '</select>' +\n '<label class=\"dm-label\">Task</label>' +\n '<textarea id=\"dmTask\" class=\"dm-task\" rows=\"5\" placeholder=\"Describe the task\u2026 (\u2318/Ctrl+Enter to start)\"></textarea>' +\n '<div class=\"dm-actions\"><button id=\"dmStart\" class=\"dm-start\" type=\"button\">Start agent \u2192</button></div>' +\n '<div id=\"dmErr\" class=\"dm-err\"></div>' +\n '</div>'\n );\n const kindEl = document.getElementById('dmKind');\n const taskEl = document.getElementById('dmTask');\n const startEl = document.getElementById('dmStart');\n const errEl = document.getElementById('dmErr');\n if (taskEl) taskEl.focus();\n function submitDelegate() {\n const task = taskEl && taskEl.value.trim();\n if (!task) { if (taskEl) taskEl.focus(); return; }\n const kind = kindEl ? kindEl.value : 'managed';\n const url = kind === 'managed' ? '/api/agents/managed/start' : '/api/agents/pty/start';\n const body = kind === 'managed' ? { task: task } : { agent: kind, task: task };\n if (errEl) errEl.textContent = '';\n if (startEl) { startEl.disabled = true; startEl.textContent = 'Starting\u2026'; }\n fetch(url, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n }).then(function (r) {\n if (!r.ok) {\n return r.text().then(function (t) {\n if (errEl) errEl.textContent = t || ('failed (' + r.status + ')');\n if (startEl) { startEl.disabled = false; startEl.textContent = 'Start agent \u2192'; }\n });\n }\n closeModal();\n if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions();\n }).catch(function () {\n if (errEl) errEl.textContent = 'network error';\n if (startEl) { startEl.disabled = false; startEl.textContent = 'Start agent \u2192'; }\n });\n }\n if (startEl) startEl.addEventListener('click', submitDelegate);\n if (taskEl) taskEl.addEventListener('keydown', function (e) {\n if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); submitDelegate(); }\n });\n }\n const sbDelegateBtn = document.getElementById('sbDelegateBtn');\n if (sbDelegateBtn) sbDelegateBtn.addEventListener('click', openDelegateModal);\n // Exposed so the console Dashboard / Control views reuse the same dialog.\n window.lisaOpenDelegate = openDelegateModal;\n\n async function refreshIdentity() {\n try {\n const r = await fetch('/api/soul');\n if (!r.ok) return;\n const data = await r.json();\n if (!data.born) return;\n const bornAt = data.summary?.seed?.bornAt;\n if (!bornAt) return;\n const born = new Date(bornAt);\n if (Number.isNaN(born.getTime())) return;\n const days = Math.max(0, Math.floor((Date.now() - born.getTime()) / 86400000));\n const ymd = born.toISOString().slice(0, 10);\n identitySub.textContent = 'born ' + ymd + ' \u00B7 ' + days + ' day' + (days === 1 ? '' : 's');\n } catch {}\n }\n\n async function refreshSessionsBadge() {\n try {\n const r = await fetch('/api/sessions');\n if (!r.ok) return;\n const data = await r.json();\n const n = Array.isArray(data.sessions) ? data.sessions.length : 0;\n sbSessionBadge.textContent = String(n);\n } catch {\n // /api/sessions is optional \u2014 leave the badge as-is on failure\n }\n }\n\n // \u2500\u2500 Mail card: connect a mailbox + show the daily classified digest \u2500\u2500\n function renderMail(accounts, digest) {\n const body = document.getElementById('sbMailBody');\n const count = document.getElementById('sbMailCount');\n const connectBtn = document.getElementById('sbMailConnectBtn');\n if (!body) return;\n while (body.firstChild) body.removeChild(body.firstChild);\n const hasAccounts = accounts && accounts.length > 0;\n if (connectBtn) connectBtn.textContent = hasAccounts ? '\uFF0B add mailbox' : '\uFF0B connect mailbox';\n if (!hasAccounts) {\n const empty = document.createElement('div');\n empty.className = 'session-empty';\n empty.textContent = '(not connected)';\n body.appendChild(empty);\n if (count) count.textContent = '';\n return;\n }\n const sum = document.createElement('div');\n sum.className = 'mail-summary';\n sum.textContent = digest && digest.summary ? digest.summary : 'No digest yet \u2014 sweep to build one.';\n body.appendChild(sum);\n const needs = digest && digest.needsYou ? digest.needsYou : [];\n if (count) count.textContent = needs.length ? ('\u2726 ' + needs.length) : '';\n for (const i of needs.slice(0, 5)) {\n const row = document.createElement('div');\n row.className = 'mail-row';\n const bang = document.createElement('span');\n bang.className = 'mail-bang' + (i.importance >= 3 ? ' urgent' : '');\n bang.textContent = i.importance >= 3 ? '\u203C' : '!';\n const subj = document.createElement('span');\n subj.className = 'mail-subj';\n subj.textContent = i.subject || '(no subject)';\n subj.title = (i.from || '') + ' \u2014 ' + (i.reason || '');\n row.appendChild(bang);\n row.appendChild(subj);\n body.appendChild(row);\n }\n const sweep = document.createElement('button');\n sweep.className = 'mail-sweep';\n sweep.type = 'button';\n sweep.textContent = 'sweep now';\n sweep.addEventListener('click', function () {\n sweep.disabled = true; sweep.textContent = 'sweeping\u2026';\n fetch('/api/mail/sweep', { method: 'POST' }).then(function () {\n if (window.refreshMail) window.refreshMail();\n }).catch(function () {}).then(function () { sweep.disabled = false; sweep.textContent = 'sweep now'; });\n });\n body.appendChild(sweep);\n }\n\n window.refreshMail = async function () {\n try {\n const a = await fetch('/api/mail/accounts').then(function (r) { return r.ok ? r.json() : null; });\n const d = await fetch('/api/mail/digest').then(function (r) { return r.ok ? r.json() : null; });\n renderMail(a ? a.accounts : [], d ? d.digest : null);\n } catch (e) {}\n };\n\n function openMailModal() {\n openModal('Connect a mailbox',\n '<div class=\"delegate-modal\">' +\n '<label class=\"dm-label\">Email</label>' +\n '<input id=\"mmEmail\" class=\"dm-kind\" type=\"email\" placeholder=\"you@qq.com\" autocomplete=\"off\" />' +\n '<label class=\"dm-label\">App-password / authorization code</label>' +\n '<input id=\"mmPass\" class=\"dm-kind\" type=\"password\" placeholder=\"not your login password\" autocomplete=\"off\" />' +\n '<label class=\"dm-label\">IMAP host (optional \u2014 auto-detected)</label>' +\n '<input id=\"mmHost\" class=\"dm-kind\" type=\"text\" placeholder=\"imap.qq.com\" autocomplete=\"off\" />' +\n '<div class=\"dm-actions\"><button id=\"mmStart\" class=\"dm-start\" type=\"button\">Connect \u2192</button></div>' +\n '<div id=\"mmErr\" class=\"dm-err\"></div>' +\n '<div class=\"dm-note\">Read-only. Stored locally (0600). Most providers need an app-password, not your login password.</div>' +\n '</div>');\n const emailEl = document.getElementById('mmEmail');\n const passEl = document.getElementById('mmPass');\n const hostEl = document.getElementById('mmHost');\n const startEl = document.getElementById('mmStart');\n const errEl = document.getElementById('mmErr');\n if (emailEl) emailEl.focus();\n function submitMail() {\n const email = emailEl && emailEl.value.trim();\n const pass = passEl && passEl.value;\n if (!email || !pass) { if (errEl) errEl.textContent = 'email + app-password required'; return; }\n const body = { email: email, password: pass };\n if (hostEl && hostEl.value.trim()) body.host = hostEl.value.trim();\n if (errEl) errEl.textContent = '';\n if (startEl) { startEl.disabled = true; startEl.textContent = 'Connecting\u2026'; }\n fetch('/api/mail/connect', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })\n .then(function (r) {\n if (!r.ok) { return r.text().then(function (t) { if (errEl) errEl.textContent = t || ('failed (' + r.status + ')'); if (startEl) { startEl.disabled = false; startEl.textContent = 'Connect \u2192'; } }); }\n closeModal();\n if (window.refreshMail) window.refreshMail();\n })\n .catch(function () { if (errEl) errEl.textContent = 'network error'; if (startEl) { startEl.disabled = false; startEl.textContent = 'Connect \u2192'; } });\n }\n if (startEl) startEl.addEventListener('click', submitMail);\n if (passEl) passEl.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); submitMail(); } });\n }\n var sbMailConnectBtn = document.getElementById('sbMailConnectBtn');\n if (sbMailConnectBtn) sbMailConnectBtn.addEventListener('click', openMailModal);\n\n // Bootstrap + periodic resync. SSE handles the fast-path updates;\n // these timers are belt-and-braces in case the stream silently dies.\n refreshPing();\n window.refreshClaudeSessions();\n window.refreshMail();\n refreshIdentity();\n refreshSessionsBadge();\n setInterval(refreshPing, 30_000);\n // Resolve refreshClaudeSessions at call time (arrow), not now: setupConsole\n // later wraps window.refreshClaudeSessions to re-render the active console\n // view + nav count, and the wrapper must win on this 60s tick too.\n setInterval(() => window.refreshClaudeSessions(), 60_000);\n setInterval(window.refreshMail, 5 * 60_000);\n setInterval(refreshSessionsBadge, 5 * 60_000);\n})();\n\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// Console view-switcher \u2014 adds Dashboard / Control / Reve / Sense /\n// Memory views beside the default Chat. Purely additive: the chat\n// pipeline and every existing id stay untouched. Each non-chat view is\n// built lazily from the real endpoints on first activation, and the live\n// agent stream (refreshClaudeSessions) is wrapped to re-render the active\n// console view. No backticks / no template placeholders in this block.\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n(function setupConsole() {\n var navList = document.getElementById('navList');\n if (!navList) return;\n var views = {\n chat: document.getElementById('viewChat'),\n dashboard: document.getElementById('viewDashboard'),\n control: document.getElementById('viewControl'),\n reve: document.getElementById('viewReve'),\n sense: document.getElementById('viewSense'),\n memory: document.getElementById('viewMemory'),\n };\n var loaded = {};\n var active = 'chat';\n var proactiveOn = true;\n\n function esc(s) { return (typeof escapeHtml === 'function') ? escapeHtml(s) : String(s == null ? '' : s); }\n function getJSON(u) { return fetch(u).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); }\n\n function statusClass(state) {\n if (state === 'working') return 'working';\n if (state === 'waiting') return 'waiting';\n if (state === 'error') return 'error';\n if (state === 'done' || state === 'idle') return 'done';\n return '';\n }\n function statusLabel(state) {\n if (state === 'working') return 'Working';\n if (state === 'waiting') return 'Waiting';\n if (state === 'error') return 'Error';\n if (state === 'done') return 'Done';\n if (state === 'idle') return 'Idle';\n return 'Unknown';\n }\n // Compact activity line (local copy of the sidebar helper \u2014 kept decoupled).\n function actLine(s) {\n var a = s && s.activity;\n if (!a || typeof a !== 'object') return '';\n if (a.pendingPermission) return 'wants to run ' + a.pendingPermission;\n var bits = [];\n if (a.lastError) bits.push(a.lastError);\n if (typeof a.turnCount === 'number' && a.turnCount > 0) bits.push('turn ' + a.turnCount);\n if (a.tokens && (a.tokens.input || a.tokens.output)) {\n var tot = (a.tokens.input || 0) + (a.tokens.output || 0);\n bits.push(tot >= 1000 ? Math.round(tot / 1000) + 'k tok' : tot + ' tok');\n }\n var tool = a.lastTools && a.lastTools.length ? a.lastTools[a.lastTools.length - 1] : '';\n var file = a.filesTouched && a.filesTouched.length ? (String(a.filesTouched[a.filesTouched.length - 1]).split('/').pop() || '') : '';\n if (tool && file) bits.push(tool + ' ' + file);\n else if (tool) bits.push(tool);\n else if (file) bits.push(file);\n return bits.join(' \u00B7 ');\n }\n function updateAgentCount(n) {\n var c = document.getElementById('navAgentCount');\n if (c) c.textContent = String(n);\n }\n\n // \u2500\u2500 view switching \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function loadView(name) {\n if (loaded[name]) return;\n loaded[name] = true;\n if (name === 'dashboard') loadDashboard();\n else if (name === 'control') loadControl();\n else if (name === 'reve') loadReve();\n else if (name === 'sense') loadSense();\n else if (name === 'memory') loadMemory();\n }\n function showView(name) {\n if (!views[name]) name = 'chat';\n active = name;\n for (var k in views) { if (views[k]) views[k].classList.toggle('active', k === name); }\n var items = navList.querySelectorAll('.nav-item');\n for (var i = 0; i < items.length; i++) {\n items[i].classList.toggle('active', items[i].getAttribute('data-view') === name);\n }\n if (name !== 'chat') loadView(name);\n else { try { document.getElementById('input').focus(); } catch (e) {} }\n try { history.replaceState(null, '', name === 'chat' ? location.pathname + location.search : '#' + name); } catch (e) {}\n }\n navList.addEventListener('click', function (e) {\n var btn = e.target && e.target.closest ? e.target.closest('.nav-item') : null;\n if (btn && btn.getAttribute('data-view')) showView(btn.getAttribute('data-view'));\n });\n window.lisaShowView = showView;\n\n // \u2500\u2500 proactive toggle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n var ptEl = document.getElementById('proactiveToggle');\n function setProactiveUI(on) {\n proactiveOn = !!on;\n if (ptEl) { ptEl.classList.toggle('on', proactiveOn); ptEl.setAttribute('aria-checked', proactiveOn ? 'true' : 'false'); }\n var pp = document.getElementById('ppPanel');\n if (pp) {\n pp.classList.toggle('off', !proactiveOn);\n var st = document.getElementById('ppState');\n if (st) st.textContent = proactiveOn ? 'On \u00B7 watching' : 'Paused';\n var sd = document.getElementById('ppDesc');\n if (sd) sd.textContent = proactiveOn ? 'Lisa watches your agents, tasks and signals for blockers and next steps.' : 'Resting \u2014 she will only act when you talk to her.';\n }\n }\n function syncProactive() {\n getJSON('/api/autonomy/state').then(function (s) { if (s && typeof s.enabled === 'boolean') setProactiveUI(s.enabled); });\n }\n function toggleProactive() {\n var on = !proactiveOn;\n setProactiveUI(on);\n fetch('/api/autonomy/state', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ enabled: on }) })\n .then(function (r) { return r && r.ok ? r.json() : null; })\n .then(function (j) { if (j && typeof j.enabled === 'boolean') setProactiveUI(j.enabled); else setProactiveUI(!on); })\n .catch(function () { setProactiveUI(!on); });\n }\n if (ptEl) {\n ptEl.addEventListener('click', toggleProactive);\n ptEl.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleProactive(); } });\n }\n syncProactive();\n\n // \u2500\u2500 Dashboard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function agentCardHTML(s) {\n var cls = statusClass(s.state);\n var act = actLine(s);\n return '<div class=\"ac\">' +\n '<div class=\"ac-top\"><span>' + esc(s.agent || 'agent') + '</span><span class=\"ac-status ' + cls + '\">' + esc(statusLabel(s.state)) + '</span></div>' +\n '<div class=\"ac-title\">' + esc(s.project || s.agent || 'agent') + '</div>' +\n '<div class=\"ac-desc\">' + (act ? esc(act) : 'no recent activity') + '</div>' +\n '</div>';\n }\n function taskCardHTML(d) {\n return '<div class=\"ac\">' +\n '<div class=\"ac-top\"><span>' + esc(d.agent || 'dispatch') + '</span><span class=\"ac-status ' + (d.alive ? 'working' : 'done') + '\">' + (d.alive ? 'Running' : 'Done') + '</span></div>' +\n '<div class=\"ac-title\">' + esc(String(d.task || 'task').slice(0, 80)) + '</div>' +\n '<div class=\"ac-meta\">' + esc(d.cwd ? String(d.cwd).split('/').pop() : 'dispatch') + '</div>' +\n '</div>';\n }\n function loadDashboard() {\n views.dashboard.innerHTML =\n '<div class=\"view-head\"><div><h2>Dashboard</h2><div class=\"vh-sub\">Operations at a glance</div></div>' +\n '<button class=\"view-act\" id=\"dashDelegate\">+ Delegate a task</button></div>' +\n '<div class=\"view-scroll\" id=\"dashScroll\"><div class=\"view-empty\">loading\u2026</div></div>';\n var del = document.getElementById('dashDelegate');\n if (del) del.addEventListener('click', function () { if (typeof window.lisaOpenDelegate === 'function') window.lisaOpenDelegate(); });\n renderDashboard();\n }\n function renderDashboard() {\n var scroll = document.getElementById('dashScroll');\n if (!scroll) return;\n Promise.all([\n getJSON('/api/agents/sessions'), getJSON('/api/dispatch/list'),\n getJSON('/api/sense/recent'), getJSON('/api/island/ping'),\n ]).then(function (res) {\n var sessions = (res[0] && res[0].sessions) || [];\n var dispatches = (res[1] && res[1].dispatches) || [];\n var events = (res[2] && res[2].events) || [];\n var ping = res[3] || {};\n updateAgentCount(sessions.length);\n var aliveTasks = dispatches.filter(function (d) { return d.alive; }).length;\n var waiting = sessions.filter(function (s) { return s.state === 'waiting'; }).length;\n var errored = sessions.filter(function (s) { return s.state === 'error'; }).length;\n var desire = ping.current_desire || '';\n var html = '';\n html += '<div class=\"stat-bar\">' +\n '<div class=\"stat\"><div class=\"n\">' + sessions.length + '</div><div class=\"k\">Agents</div></div>' +\n '<div class=\"stat\"><div class=\"n\">' + aliveTasks + '</div><div class=\"k\">Tasks</div></div>' +\n '<div class=\"stat\"><div class=\"n\">' + dispatches.length + '</div><div class=\"k\">Dispatched</div></div>' +\n '<div class=\"stat\"><div class=\"n\">' + events.length + '</div><div class=\"k\">Signals</div></div></div>';\n html += '<div class=\"proactive-panel' + (proactiveOn ? '' : ' off') + '\" id=\"ppPanel\"><div class=\"pp-head\"><span class=\"pp-dot\"></span>' +\n '<div><div class=\"pp-title\">Proactive mode <b id=\"ppState\">' + (proactiveOn ? 'On \u00B7 watching' : 'Paused') + '</b></div>' +\n '<div class=\"pp-desc\" id=\"ppDesc\">' + (proactiveOn ? 'Lisa watches your agents, tasks and signals for blockers and next steps.' : 'Resting \u2014 she will only act when you talk to her.') + '</div></div></div>' +\n '<div class=\"pp-tags\"><span class=\"pp-tag\">' + sessions.length + ' agents</span><span class=\"pp-tag\">' + aliveTasks + ' tasks</span>' +\n (waiting ? '<span class=\"pp-tag warn\">' + waiting + ' waiting</span>' : '') +\n (errored ? '<span class=\"pp-tag warn\">' + errored + ' errored</span>' : '') +\n '<span class=\"pp-tag\">owner-routed</span><span class=\"pp-tag\">quiet on waits</span></div></div>';\n html += '<div class=\"view-sec-label\">Focus \u00B7 currently pursuing</div>';\n if (desire) {\n html += '<div class=\"focus-card\"><div class=\"fc-top\"><div>' +\n '<div class=\"fc-title\">' + esc(desire) + '</div>' +\n '<div class=\"fc-desc\">A self-driven desire Lisa is pursuing on her own time.</div></div>' +\n '<span class=\"fc-pill\">Active</span></div>' +\n '<div class=\"fc-meta\"><span>self-driven</span><span>' + sessions.length + ' agents live</span></div></div>';\n } else {\n html += '<div class=\"focus-card\"><div class=\"fc-desc\">Nothing actively pursued right now.</div></div>';\n }\n html += '<div class=\"view-sec-label\">Agents &amp; tasks</div>';\n var cards = sessions.map(agentCardHTML).concat(dispatches.slice(0, 6).map(taskCardHTML));\n html += cards.length ? '<div class=\"card-scroll\">' + cards.join('') + '</div>'\n : '<div class=\"view-empty\">No active agents or tasks. Delegate one to get started.</div>';\n scroll.innerHTML = html;\n });\n }\n\n // \u2500\u2500 Control \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function controlRowHTML(s) {\n var act = actLine(s);\n var fam = s.controllable === 'pty' ? 'pty' : (s.controllable === 'managed' ? 'managed' : '');\n var pend = s.activity && s.activity.pendingPermission;\n var id = esc(s.sessionId);\n var ctrl = '';\n if (fam && pend) {\n ctrl += '<button class=\"mc approve\" data-fam=\"' + fam + '\" data-id=\"' + id + '\" data-act=\"approve\">approve</button>';\n ctrl += '<button class=\"mc deny\" data-fam=\"' + fam + '\" data-id=\"' + id + '\" data-act=\"deny\">deny</button>';\n }\n if (fam) ctrl += '<button class=\"mc cancel\" data-fam=\"' + fam + '\" data-id=\"' + id + '\" data-act=\"cancel\">cancel</button>';\n if (fam === 'pty') ctrl += '<button class=\"mc\" data-id=\"' + id + '\" data-act=\"output\">output</button>';\n var badge = (s.agent && s.agent !== 'claude-code') ? '<span class=\"agent-badge\">' + esc(s.agent) + '</span>' : '';\n return '<div class=\"session-row\">' +\n '<div class=\"pip ' + (s.state || 'unknown') + '\"></div>' +\n '<div class=\"name\">' + badge + esc(s.project || s.agent || 'agent') + '</div>' +\n '<div class=\"when\">' + (s.controllable ? esc(s.controllable) : esc(statusLabel(s.state))) + '</div>' +\n (act ? '<div class=\"session-act\">' + esc(act) + '</div>' : '') +\n (ctrl ? '<div class=\"session-ctrl\">' + ctrl + '</div>' : '') +\n '</div>';\n }\n function controlAction(fam, id, action) {\n return fetch('/api/agents/' + fam + '/' + encodeURIComponent(id) + '/' + action, { method: 'POST' })\n .then(function (r) { if (!r.ok) return r.text().then(function (t) { throw new Error(String(r.status) + (t ? ' ' + t : '')); }); });\n }\n function ctrlPtyOutput(id) {\n getJSON('/api/agents/pty/' + encodeURIComponent(id) + '/output').then(function (d) {\n if (typeof openModal === 'function') openModal('agent output', '<pre>' + esc(d && d.output ? d.output : '(no output yet)') + '</pre>');\n });\n }\n function loadControl() {\n views.control.innerHTML =\n '<div class=\"view-head\"><div><h2>Control</h2><div class=\"vh-sub\">Live agents &amp; control plane</div></div>' +\n '<button class=\"view-act\" id=\"ctrlDelegate\">+ Delegate a task</button></div>' +\n '<div class=\"view-scroll\" id=\"ctrlScroll\"><div class=\"view-empty\">loading\u2026</div></div>';\n var del = document.getElementById('ctrlDelegate');\n if (del) del.addEventListener('click', function () { if (typeof window.lisaOpenDelegate === 'function') window.lisaOpenDelegate(); });\n renderControl();\n }\n function renderControl() {\n var scroll = document.getElementById('ctrlScroll');\n if (!scroll) return;\n Promise.all([getJSON('/api/agents/sessions'), getJSON('/api/control/policy')]).then(function (res) {\n var sessions = (res[0] && res[0].sessions) || [];\n var policy = res[1] || {};\n updateAgentCount(sessions.length);\n var html = '<div class=\"pp-tags\" style=\"margin-bottom:14px\">' +\n '<span class=\"pp-tag\">remote control: ' + (policy.remoteControl ? 'on' : 'off') + '</span>' +\n '<span class=\"pp-tag\">adopt external: ' + (policy.remoteAdoptExternal ? 'on' : 'off') + '</span></div>';\n if (!sessions.length) { scroll.innerHTML = html + '<div class=\"view-empty\">No agents running. Delegate a task to start one.</div>'; return; }\n sessions.forEach(function (s) { html += controlRowHTML(s); });\n scroll.innerHTML = html;\n var btns = scroll.querySelectorAll('[data-act]');\n for (var i = 0; i < btns.length; i++) {\n btns[i].addEventListener('click', function () {\n var act = this.getAttribute('data-act');\n var fam = this.getAttribute('data-fam');\n var id = this.getAttribute('data-id');\n if (act === 'output') { ctrlPtyOutput(id); return; }\n controlAction(fam, id, act).then(function () { renderControl(); }).catch(function (err) {\n scroll.insertAdjacentHTML('afterbegin', '<div class=\"view-empty\" style=\"color:var(--err-color)\">' + esc(err.message) + '</div>');\n });\n });\n }\n });\n }\n\n // \u2500\u2500 Reve \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function loadReve() {\n views.reve.innerHTML =\n '<div class=\"view-head\"><div><h2>R\u00EAve</h2><div class=\"vh-sub\">Reflections from her own time</div></div>' +\n '<select class=\"v-sel\" id=\"reveWindow\"><option value=\"120\">last 2h</option><option value=\"480\">last 8h</option><option value=\"1440\">last 24h</option></select></div>' +\n '<div class=\"view-scroll\" id=\"reveScroll\"><div class=\"view-empty\">loading\u2026</div></div>';\n var sel = document.getElementById('reveWindow');\n if (sel) sel.addEventListener('change', function () { renderReve(sel.value); });\n renderReve('120');\n }\n function renderReve(mins) {\n var scroll = document.getElementById('reveScroll');\n if (!scroll) return;\n Promise.all([getJSON('/api/island/ping'), getJSON('/api/agents/recap?sinceMinutes=' + encodeURIComponent(mins))]).then(function (res) {\n var ping = res[0] || {}; var recap = res[1] || {};\n var html = '';\n if (ping.last_idle_message_text) html += '<div class=\"v-card\"><h3>While you were away</h3><div style=\"font-size:13px;color:var(--fg);line-height:1.55\">' + esc(ping.last_idle_message_text) + '</div></div>';\n if (ping.current_desire) html += '<div class=\"v-card\"><h3>Currently pursuing</h3><div style=\"font-size:13px;color:var(--fg)\">' + esc(ping.current_desire) + '</div></div>';\n html += '<div class=\"v-card\"><h3>Recap</h3><pre class=\"v-pre\">' + esc(recap.text || 'No activity in this window.') + '</pre></div>';\n scroll.innerHTML = html;\n });\n }\n\n // \u2500\u2500 Sense \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function loadSense() {\n views.sense.innerHTML =\n '<div class=\"view-head\"><div><h2>Sense</h2><div class=\"vh-sub\">Ambient signals Lisa may see</div></div></div>' +\n '<div class=\"view-scroll\" id=\"senseScroll\"><div class=\"view-empty\">loading\u2026</div></div>';\n renderSense();\n }\n function renderSense() {\n var scroll = document.getElementById('senseScroll');\n if (!scroll) return;\n Promise.all([getJSON('/api/consent'), getJSON('/api/sense/recent')]).then(function (res) {\n var grants = (res[0] && res[0].grants) || [];\n var events = (res[1] && res[1].events) || [];\n var html = '<div class=\"view-sec-label\">Consent</div><div class=\"v-card\">';\n if (!grants.length) html += '<div class=\"view-empty\" style=\"padding:6px 0\">No signals configured.</div>';\n grants.forEach(function (g) {\n html += '<div class=\"v-row\"><div class=\"v-main\"><div class=\"v-name\">' + esc(g.signal) + '</div>' +\n (g.description ? '<div class=\"v-sub\">' + esc(g.description) + '</div>' : '') + '</div>' +\n '<button class=\"v-toggle' + (g.granted ? ' on' : '') + '\" data-signal=\"' + esc(g.signal) + '\" data-on=\"' + (g.granted ? '1' : '0') + '\">' + (g.granted ? 'on' : 'off') + '</button></div>';\n });\n html += '</div><div class=\"view-sec-label\">Recently sensed</div><div class=\"v-card\">';\n if (!events.length) html += '<div class=\"view-empty\" style=\"padding:6px 0\">Nothing captured.</div>';\n events.slice(0, 30).forEach(function (e) {\n html += '<div class=\"v-row\"><div class=\"v-main\"><div class=\"v-name\" style=\"font-weight:400\">' + esc(e.summary || '') + '</div>' +\n '<div class=\"v-sub\">' + esc([e.signal, e.kind, e.app].filter(Boolean).join(' \u00B7 ')) + '</div></div></div>';\n });\n html += '</div>';\n scroll.innerHTML = html;\n var tgs = scroll.querySelectorAll('.v-toggle');\n for (var i = 0; i < tgs.length; i++) {\n tgs[i].addEventListener('click', function () {\n var sig = this.getAttribute('data-signal');\n var on = this.getAttribute('data-on') === '1';\n fetch(on ? '/api/consent/revoke' : '/api/consent/grant', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ signal: sig }) })\n .then(function () { renderSense(); }).catch(function () {});\n });\n }\n });\n }\n\n // \u2500\u2500 Memory (launchers for the existing modal panels) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function memBtn(key, ico, title, sub) {\n return '<button class=\"mem-btn\" data-mem=\"' + key + '\"><span class=\"mem-ico\">' + ico + '</span>' + title + '<span class=\"mem-sub\">' + sub + '</span></button>';\n }\n function loadMemory() {\n views.memory.innerHTML =\n '<div class=\"view-head\"><div><h2>Memory</h2><div class=\"vh-sub\">Soul \u00B7 skills \u00B7 memory \u00B7 tools</div></div></div>' +\n '<div class=\"view-scroll\">' +\n memBtn('soul', '\u2605', 'Soul', 'identity \u00B7 values \u00B7 emotions') +\n memBtn('skills', '\u2726', 'Skills', 'saved workflows') +\n memBtn('memory', '\u25A4', 'Memory', 'USER.md \u00B7 MEMORY.md') +\n memBtn('tools', '\u2692', 'Tools', 'capabilities') +\n memBtn('plans', '\u25C6', 'Coding plans', 'subscription CLIs') +\n '</div>';\n var mbs = views.memory.querySelectorAll('.mem-btn');\n for (var i = 0; i < mbs.length; i++) {\n mbs[i].addEventListener('click', function () {\n var w = this.getAttribute('data-mem');\n if (w === 'soul' && typeof showSoul === 'function') showSoul();\n else if (w === 'skills' && typeof showSkills === 'function') showSkills();\n else if (w === 'memory' && typeof showMemory === 'function') showMemory();\n else if (w === 'tools' && typeof showTools === 'function') showTools();\n else if (w === 'plans' && typeof showPlans === 'function') showPlans();\n });\n }\n }\n\n // \u2500\u2500 live refresh: wrap the agent-session refresh so the active console\n // view + the Control nav count update on SSE agent_session_update.\n var origRefresh = window.refreshClaudeSessions;\n window.refreshClaudeSessions = function () {\n var r = origRefresh ? origRefresh.apply(this, arguments) : undefined;\n Promise.resolve(r).then(function () {\n if (active === 'dashboard') renderDashboard();\n else if (active === 'control') renderControl();\n else getJSON('/api/agents/sessions').then(function (d) { updateAgentCount((d && d.sessions) ? d.sessions.length : 0); });\n });\n return r;\n };\n getJSON('/api/agents/sessions').then(function (d) { updateAgentCount((d && d.sessions) ? d.sessions.length : 0); });\n\n // \u2500\u2500 init: honor a deep-link hash, else default to chat \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n var initial = (location.hash || '').replace('#', '');\n showView(views[initial] ? initial : 'chat');\n window.addEventListener('hashchange', function () {\n var h = (location.hash || '').replace('#', '');\n if (views[h] && h !== active) showView(h);\n });\n})();\n</script>\n</body></html>";
29
29
  //# sourceMappingURL=lisa-html.d.ts.map