samagotchi 0.3.0 → 0.5.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -1
  3. data/README.md +29 -2
  4. data/bin/chi +60 -69
  5. data/docs/cli.md +211 -77
  6. data/docs/configuration.md +118 -21
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +89 -7
  10. data/docs/memory.md +40 -0
  11. data/docs/plugins.md +50 -0
  12. data/docs/releasing.md +15 -12
  13. data/docs/sessions.md +20 -18
  14. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  17. data/lib/samagotchi/bridge.rb +20 -12
  18. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  19. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
  24. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  25. data/lib/samagotchi/bundles/system/identity.md +5 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +6 -6
  27. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  28. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  29. data/lib/samagotchi/client.rb +25 -26
  30. data/lib/samagotchi/commands/registry.rb +8 -0
  31. data/lib/samagotchi/config.rb +97 -113
  32. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  33. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  34. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  35. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  36. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  37. data/lib/samagotchi/desktop/macos.rb +59 -8
  38. data/lib/samagotchi/desktop_command.rb +6 -3
  39. data/lib/samagotchi/edit_preview.rb +82 -0
  40. data/lib/samagotchi/engine.rb +236 -443
  41. data/lib/samagotchi/gem_update.rb +89 -0
  42. data/lib/samagotchi/guardrails/approval.rb +26 -4
  43. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  44. data/lib/samagotchi/host_registry.rb +8 -12
  45. data/lib/samagotchi/idle_client.rb +24 -15
  46. data/lib/samagotchi/idle_reminders.rb +2 -2
  47. data/lib/samagotchi/image_store.rb +10 -6
  48. data/lib/samagotchi/kernel_loop.rb +59 -123
  49. data/lib/samagotchi/live_versions.rb +65 -0
  50. data/lib/samagotchi/llm/api_key.rb +41 -0
  51. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  52. data/lib/samagotchi/llm/errors.rb +38 -7
  53. data/lib/samagotchi/llm/http.rb +19 -22
  54. data/lib/samagotchi/llm/openai_chat.rb +22 -26
  55. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  56. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  57. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  58. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  59. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  60. data/lib/samagotchi/model_profile.rb +27 -10
  61. data/lib/samagotchi/note_command.rb +2 -1
  62. data/lib/samagotchi/prompt.rb +4 -2
  63. data/lib/samagotchi/reminder_store.rb +1 -9
  64. data/lib/samagotchi/reply_wait.rb +48 -4
  65. data/lib/samagotchi/self_report.rb +37 -5
  66. data/lib/samagotchi/send_command.rb +190 -17
  67. data/lib/samagotchi/session.rb +4 -2
  68. data/lib/samagotchi/session_commands.rb +38 -8
  69. data/lib/samagotchi/session_manager.rb +19 -53
  70. data/lib/samagotchi/system_prompt.rb +403 -0
  71. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  74. data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
  75. data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
  76. data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
  77. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  78. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  79. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  80. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  81. data/lib/samagotchi/terminal_ui.rb +142 -923
  82. data/lib/samagotchi/text_diff.rb +181 -0
  83. data/lib/samagotchi/thinking.rb +126 -0
  84. data/lib/samagotchi/tool_activity.rb +52 -2
  85. data/lib/samagotchi/tool_runner.rb +37 -1
  86. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  87. data/lib/samagotchi/tools/edit.rb +23 -9
  88. data/lib/samagotchi/tools/execute.rb +3 -3
  89. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  90. data/lib/samagotchi/tools/read.rb +4 -4
  91. data/lib/samagotchi/tools/write.rb +4 -0
  92. data/lib/samagotchi/turn_flow.rb +12 -2
  93. data/lib/samagotchi/update_command.rb +309 -0
  94. data/lib/samagotchi/update_hint.rb +59 -0
  95. data/lib/samagotchi/version.rb +1 -1
  96. data/lib/samagotchi/vision_support.rb +6 -4
  97. data/lib/samagotchi/web/app.rb +173 -38
  98. data/lib/samagotchi/web/lan.rb +99 -0
  99. data/lib/samagotchi/web/message_parts.rb +19 -10
  100. data/lib/samagotchi/web/public/activity.js +10 -0
  101. data/lib/samagotchi/web/public/app.js +135 -78
  102. data/lib/samagotchi/web/public/chat_view.js +8 -1
  103. data/lib/samagotchi/web/public/data.js +2 -0
  104. data/lib/samagotchi/web/public/diff_view.js +58 -0
  105. data/lib/samagotchi/web/public/index.html +185 -18
  106. data/lib/samagotchi/web/public/model_pick.js +136 -0
  107. data/lib/samagotchi/web/public/model_picker.js +224 -0
  108. data/lib/samagotchi/web/public/notify.js +10 -0
  109. data/lib/samagotchi/web/public/question_card.js +3 -1
  110. data/lib/samagotchi/web/public/stage_model.js +110 -0
  111. data/lib/samagotchi/web/public/stage_view.js +580 -0
  112. data/lib/samagotchi/web/public/timing.js +6 -2
  113. data/lib/samagotchi/web/public/turn_events.js +38 -10
  114. data/lib/samagotchi/web/public/turn_model.js +11 -3
  115. data/lib/samagotchi/web/public/turn_view.js +76 -20
  116. data/lib/samagotchi/web/qr.rb +40 -0
  117. data/lib/samagotchi/web/server.rb +101 -11
  118. data/lib/samagotchi/web/token.rb +97 -0
  119. data/lib/samagotchi/worker.rb +5 -4
  120. metadata +38 -3
  121. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -5,17 +5,18 @@
5
5
  <title>Chi</title>
6
6
  <meta name="viewport" content="width=device-width,initial-scale=1">
7
7
  <style>
8
- :root{--bg:#0b0d11;--bg-panel:#151821;--bg-inset:#0f1115;--bg-raised:#22262f;--bg-hover:#1b1f27;--border:#1f232b;--border-strong:#2d333f;--text:#e6e8eb;--text-bright:#f0f3f6;--text-2:#c9d1d9;--text-3:#9aa4b2;--text-muted:#8b949e;--text-faint:#6e7681;--accent:#7aa5ff;--accent-strong:#1f6feb;--accent-bg:#1b2336;--accent-ring:rgba(122,165,255,.18);--primary:#2a68d1;--ok:#3fb950;--ok-text:#7ee787;--ok-bg:#1a2e1a;--warn:#d29922;--warn-text:#e3b341;--warn-bright:#f2cc60;--warn-bg:#2b2108;--warn-border:#6b5416;--danger:#f85149;--danger-bg:#da3633;--danger-text:#ff7b72;--danger-bg-dim:#3d1f1f;--think:#8957e5;--think-text:#d2a8ff;--accent-border:#223150;--accent-tint:color-mix(in srgb,var(--accent) 7%,transparent);
9
- --glass-bg:color-mix(in srgb,var(--bg-panel) 70%,transparent);--glass-border:rgba(255,255,255,.08);--glass-highlight:rgba(255,255,255,.05);--glass-shadow:0 8px 30px rgba(0,0,0,.35)}
8
+ :root{--bg:#0b0d11;--bg-panel:#151821;--bg-inset:#0f1115;--bg-raised:#22262f;--bg-hover:#1b1f27;--border:#1f232b;--border-strong:#2d333f;--text:#e6e8eb;--text-bright:#f0f3f6;--text-2:#c9d1d9;--text-3:#9aa4b2;--text-muted:#8b949e;--text-faint:#6e7681;--accent:#7aa5ff;--accent-strong:#1f6feb;--accent-bg:#1b2336;--accent-ring:rgba(122,165,255,.18);--primary:#2a68d1;--ok:#3fb950;--ok-text:#7ee787;--ok-bg:#1a2e1a;--warn:#d29922;--warn-text:#e3b341;--warn-bright:#f2cc60;--warn-bg:#2b2108;--warn-border:#6b5416;--danger:#f85149;--danger-bg:#da3633;--danger-text:#ff7b72;--danger-bg-dim:#3d1f1f;--think:#8957e5;--think-text:#d2a8ff;--accent-border:#223150;--accent-tint:color-mix(in srgb,var(--accent) 7%,transparent);--narr:color-mix(in srgb,var(--accent) 38%,var(--text-muted));
9
+ --glass-bg:color-mix(in srgb,var(--bg-panel) 70%,transparent);--glass-border:rgba(255,255,255,.08);--glass-highlight:rgba(255,255,255,.05);--glass-shadow:0 8px 30px rgba(0,0,0,.35);
10
+ --diff-add-bg:rgba(63,185,80,.14);--diff-add-text:#aff5b4;--diff-del-bg:rgba(248,81,73,.14);--diff-del-text:#ffc1bc}
10
11
  *{box-sizing:border-box;margin:0;padding:0}
11
12
  body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:radial-gradient(1100px 700px at 8% 0%,color-mix(in srgb,var(--accent) 13%,transparent),transparent 70%),radial-gradient(900px 700px at 100% 100%,color-mix(in srgb,var(--think) 9%,transparent),transparent 70%),var(--bg);background-attachment:fixed;color:var(--text);height:100vh;display:flex;flex-direction:column}
12
13
  /* Glass: the composer card, the strip cards, the all-sessions cards and the question cards (by selector, so app.js is untouched). */
13
- #dock,#topStrip .card,#allList .card,.bubble.question,.bubble.plugin-card{background:var(--glass-bg);-webkit-backdrop-filter:blur(18px) saturate(1.35);backdrop-filter:blur(18px) saturate(1.35);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
14
+ #dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,.bubble.question,.bubble.plugin-card{background:var(--glass-bg);-webkit-backdrop-filter:blur(18px) saturate(1.35);backdrop-filter:blur(18px) saturate(1.35);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
14
15
  #topStrip .card:hover,#allList .card:hover{background:color-mix(in srgb,var(--bg-hover) 75%,transparent);border-color:rgba(255,255,255,.14)}
15
16
  #topStrip .card.active,#allList .card.active{background:color-mix(in srgb,var(--accent-bg) 75%,transparent);border-color:color-mix(in srgb,var(--accent) 35%,transparent)}
16
- @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){#dock,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel)}.bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel))}.bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel))}}
17
- @media (prefers-reduced-transparency:reduce){#dock,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}.bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}.bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}}
18
- body.no-glass #dock,body.no-glass #topStrip .card,body.no-glass #allList .card,body.no-glass #allHead input{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}
17
+ @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){#dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel)}.bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel))}.bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel))}}
18
+ @media (prefers-reduced-transparency:reduce){#dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}.bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}.bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}}
19
+ body.no-glass #dock,body.no-glass .turn-stage,body.no-glass .dock-card,body.no-glass #topStrip .card,body.no-glass #allList .card,body.no-glass #allHead input{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}
19
20
  body.no-glass .bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}
20
21
  body.no-glass .bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}
21
22
  button,input,textarea,select{font:inherit}
@@ -130,9 +131,15 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
130
131
  .activity-status,.activity-tool{flex-shrink:0;white-space:nowrap}
131
132
  .activity-params{min-width:0}
132
133
  .activity-output{flex:1 1 100%;min-width:0}
134
+ /* An edit/write's change: a closed "diff +3 −1" under the output, lined up with it. */
135
+ .activity-diff{flex:1 1 100%;min-width:0;margin:2px 0 2px 60px}
136
+ .activity-diff>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
137
+ .activity-diff>summary:hover{color:var(--text-2)}
138
+ .activity-diff[open]>summary{margin-bottom:4px}
133
139
  @media(max-width:600px){
134
140
  .activity-params{flex:1 1 0}
135
141
  .activity-output{margin-left:0}
142
+ .activity-diff{margin-left:0}
136
143
  }
137
144
  .bubble.thinking{border-left:3px solid var(--think);background:color-mix(in srgb,var(--think) 6%,var(--bg-panel));padding:8px 12px}
138
145
  .bubble.thinking:not([open]),.bubble.activity:not([open]){padding:3px 10px;background:transparent;border-color:transparent;align-self:flex-start;border-radius:6px;transition:background .12s ease}
@@ -145,7 +152,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
145
152
  .bubble.thinking>summary::before{content:"▸ ";color:var(--think)}
146
153
  .bubble.thinking[open]>summary::before{content:"▾ "}
147
154
  .thinking-body{margin-top:6px;font-size:13px;color:var(--text-3);white-space:pre-wrap;word-break:break-word;font-style:italic;max-height:240px;overflow:auto}
148
- /* Turn view (web.turn_view): one block per turn, its steps as collapsed rows, the live one open at the bottom. */
155
+ /* Turn view (web.view: turn, and inside the stage): one block per turn, its steps as collapsed rows, the live one open at the bottom. */
149
156
  .turn-work{border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;background:var(--bg-panel);padding:8px 12px;font-size:14px}
150
157
  .turn-work>summary{cursor:pointer;color:var(--text-muted);font-size:13px;font-weight:600;user-select:none;list-style:none}
151
158
  .turn-work>summary::-webkit-details-marker{display:none}
@@ -253,6 +260,20 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
253
260
  .approval-where,.approval-why{font-size:12px;color:var(--text-muted);word-break:break-all}
254
261
  .approval-why{color:var(--warn-text)}
255
262
  .approval-label{color:var(--text-faint)}
263
+ /* A diff (diff_view.js): the approval card's dry run and a tool row's change. */
264
+ .approval-diff{margin:0 0 6px}
265
+ .diff{margin:0;padding:4px 0;background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;font-size:12px;line-height:1.45;overflow:auto;max-height:420px}
266
+ .diff code{display:block;min-width:max-content}
267
+ .diff-line{display:block;padding:0 10px;white-space:pre;color:var(--text-2)}
268
+ .diff-add{background:var(--diff-add-bg);color:var(--diff-add-text)}
269
+ .diff-del{background:var(--diff-del-bg);color:var(--diff-del-text)}
270
+ .diff-hunk{color:var(--text-faint)}
271
+ .diff-meta{color:var(--text-faint);font-style:italic}
272
+ .diff-note{font-size:12px;color:var(--text-muted);margin:0 0 4px}
273
+ .diff-note.warn{color:var(--warn-text)}
274
+ .diff-more{margin-top:4px}
275
+ .diff-more>summary{cursor:pointer;font-size:12px;color:var(--accent);width:max-content}
276
+ .diff-more[open]>summary{display:none}
256
277
  .bubble.guardrail-warning{border-left:3px solid var(--danger);background:color-mix(in srgb,var(--danger) 8%,var(--bg-panel));color:var(--danger-text);font-size:13px;padding:8px 12px;white-space:pre-wrap}
257
278
  .bubble.hook-notice{border-left:3px solid var(--text-faint);background:var(--bg-panel);color:var(--text-3);font-size:13px;padding:8px 12px;white-space:pre-wrap}
258
279
  .bubble.hook-notice.warn{border-left-color:var(--warn);color:var(--warn-text)}
@@ -351,16 +372,40 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
351
372
  #composer #actionBtn{min-width:64px;font-weight:600}
352
373
  /* The model picker (start page only): reads as a label, "host:model ▾", left of Start; a native select under a drawn caret. */
353
374
  /* A select is as wide as its widest option: a hidden copy of the chosen name (data-value) sizes the box to that name instead, and the select fills it. */
354
- .model-pick{order:0;position:relative;display:inline-block;max-width:min(50%,380px);height:28px;border-radius:8px;overflow:hidden;color:var(--text-muted);font-size:12px;transition:background .12s ease,color .12s ease}
355
- .model-pick[hidden],body.has-session .model-pick{display:none}
356
- .model-pick:hover,.model-pick:focus-within{background:color-mix(in srgb,var(--bg-hover) 75%,transparent);color:var(--text-2)}
357
- .model-pick::before{content:attr(data-value);display:block;visibility:hidden;padding:0 22px 0 8px;white-space:pre;line-height:28px}
358
- .model-pick::after{content:"";position:absolute;right:9px;top:50%;width:5px;height:5px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:translateY(-70%) rotate(45deg);pointer-events:none}
359
- .model-pick select{position:absolute;inset:0;appearance:none;-webkit-appearance:none;width:100%;height:28px;padding:0 22px 0 8px;border:0;border-radius:8px;background:transparent;color:inherit;font-size:12px;line-height:28px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;color-scheme:dark}
360
- .model-pick select:focus{outline:none}
361
- .model-pick select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
362
- .model-pick option{color:var(--text);background:var(--bg-panel)}
363
- @media(max-width:600px){.model-pick{max-width:60%}}
375
+ #composer .actions-col .model-pick{order:0;position:relative;display:inline-block;max-width:min(50%,380px);height:28px;min-height:28px;padding:0 22px 0 8px;border-radius:8px;overflow:hidden;background:transparent;color:var(--text-muted);font-size:12px;line-height:28px;text-align:left;text-overflow:ellipsis;transition:background .12s ease,color .12s ease}
376
+ #composer .actions-col .model-pick:hover,#composer .actions-col .model-pick[aria-expanded="true"]{filter:none;background:color-mix(in srgb,var(--bg-hover) 75%,transparent);color:var(--text-2)}
377
+ #composer .actions-col .model-pick[hidden],body.has-session #composer .actions-col .model-pick{display:none}
378
+ .model-pick-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
379
+ .model-pick::after{content:"";position:absolute;right:9px;top:50%;width:5px;height:5px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:translateY(-70%) rotate(45deg);pointer-events:none;transition:transform .12s ease}
380
+ .model-pick[aria-expanded="true"]::after{transform:translateY(-20%) rotate(225deg)}
381
+ @media(max-width:600px){#composer .actions-col .model-pick{max-width:60%}}
382
+ /* The model search: opaque like #commandList (a list over the conversation
383
+ must stay readable), above the composer, or below it (.down) when the
384
+ start page leaves no room above, or over the composer (.over) when
385
+ neither side has it. max-height is set at open. */
386
+ #modelPanel{position:absolute;left:10px;width:min(560px,calc(100% - 20px));bottom:calc(100% + 8px);max-height:360px;border-radius:12px;z-index:21;display:flex;flex-direction:column;overflow:hidden;background:var(--bg-panel);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
387
+ #modelPanel.down{bottom:auto;top:calc(100% + 8px)}
388
+ #modelPanel.over{bottom:0}
389
+ #modelPanel[hidden],body.has-session #modelPanel{display:none}
390
+ #modelPanel .model-search{flex:none;display:flex;align-items:center;gap:8px;padding:0 12px;border-bottom:1px solid var(--border)}
391
+ #modelPanel .model-search svg{flex:none;width:14px;height:14px;color:var(--text-faint)}
392
+ #modelSearch{flex:1;min-width:0;height:38px;background:transparent;border:0;color:var(--text);font-size:13px}
393
+ #modelSearch::placeholder{color:var(--text-faint)}
394
+ #modelSearch:focus,#modelSearch:focus-visible{outline:none}
395
+ #modelList{flex:1 1 auto;min-height:0;overflow-y:auto;padding:4px;display:flex;flex-direction:column;gap:1px}
396
+ #modelList .model-group{position:sticky;top:-4px;z-index:1;padding:8px 10px 4px;background:var(--bg-panel);font-size:10.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text-faint)}
397
+ #modelList .model-group span{font-weight:400;letter-spacing:0;text-transform:none}
398
+ #modelList .model-option{flex:none;scroll-margin:28px 0 4px;display:flex;align-items:baseline;gap:10px;padding:6px 10px 6px 24px;border-radius:8px;cursor:pointer;min-width:0;position:relative}
399
+ #modelList .model-option.active{background:color-mix(in srgb,var(--accent-bg) 85%,transparent)}
400
+ #modelList .model-option.chosen::before{content:"";position:absolute;left:10px;top:50%;width:4px;height:8px;border-right:1.5px solid var(--accent);border-bottom:1.5px solid var(--accent);transform:translateY(-65%) rotate(45deg)}
401
+ #modelList .model-id{flex:1;min-width:0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;line-height:1.4;color:var(--text-2);overflow-wrap:anywhere}
402
+ #modelList .model-option.chosen .model-id{color:var(--accent)}
403
+ #modelList .model-host,#modelList .model-dot{color:var(--text-faint)}
404
+ #modelList mark{background:none;color:var(--accent);font-weight:600}
405
+ #modelList .model-option.chosen mark{color:var(--text-bright)}
406
+ #modelList .model-note{flex:none;font-size:11px;color:var(--text-faint)}
407
+ #modelList .model-empty{padding:10px;font-size:13px;color:var(--text-faint)}
408
+ @media(max-width:600px){#modelPanel{left:6px;width:calc(100% - 12px)}#modelSearch{font-size:16px}#modelList .model-option{padding-top:8px;padding-bottom:8px}}
364
409
  /* The / autocomplete: a list over the composer card's top edge. It wears the glass edge and shadow, but opaque:
365
410
  inside the dock's own backdrop-filter a second blur sees nothing, and the history would show through its text. */
366
411
  #commandList{position:absolute;left:10px;width:min(640px,calc(100% - 20px));bottom:calc(100% + 8px);max-height:min(260px,40vh);overflow-y:auto;border-radius:12px;padding:4px;z-index:20;display:flex;flex-direction:column;gap:1px;background:var(--bg-panel);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
@@ -371,6 +416,124 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
371
416
  #commandList .command-desc{flex:1;min-width:0;font-size:13px;color:var(--text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
372
417
  #commandList .command-note{font-size:11px;color:var(--text-faint);white-space:nowrap}
373
418
  @media(max-width:600px){#commandList{left:6px;width:calc(100% - 12px)}#commandList .command-option{flex-wrap:wrap;row-gap:0;padding:8px 10px}#commandList .command-desc{flex-basis:100%;order:3}}
419
+ /* The stage view (web.view: stage, stage_view.js): the running turn as the dock card's top section, pinned above the composer.
420
+ Fixed slots (status row, prompt line, a two-line headline, the tool row, a one-line trail) never change height while the
421
+ turn runs; cards and rows that arrive meanwhile go in their own capped scroller (extras); the cloud is the turn view's
422
+ block behind a chip of ticks. --stage-max (stage_view.js fitCap) keeps it inside the session panel. */
423
+ /* D2: its own glass card above the composer card (as a section of one card the composer's focus ring wrapped the turn too):
424
+ in the stage view #dock is a clear column holding the stage and .dock-card (the composer and its footer, stage_view.js). */
425
+ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;border:0;box-shadow:none;-webkit-backdrop-filter:none;backdrop-filter:none;gap:10px}
426
+ .dock-card{display:flex;flex-direction:column;border-radius:16px;position:relative;transition:border-color .12s ease,box-shadow .12s ease}
427
+ .dock-card:focus-within{border-color:color-mix(in srgb,var(--accent) 70%,transparent);box-shadow:0 0 0 3px var(--accent-ring),var(--glass-shadow)}
428
+ .turn-stage{display:flex;flex-direction:column;min-height:0;max-height:var(--stage-max,60vh);border-left:3px solid var(--accent);border-radius:14px;overflow:hidden}
429
+ .turn-stage[hidden]{display:none}
430
+ .turn-stage.ended{border-left-color:var(--border-strong)}
431
+ .turn-stage[data-phase="answered"]{border-left-color:var(--ok)}
432
+ .turn-stage[data-phase="canceled"],.turn-stage[data-phase="waiting for you"]{border-left-color:var(--warn)}
433
+ .turn-stage[data-phase="failed"]{border-left-color:var(--danger)}
434
+ .turn-stage.enter{animation:ts-rise .35s cubic-bezier(.3,.7,.2,1)}
435
+ @keyframes ts-rise{from{opacity:0;transform:translateY(14px)}}
436
+ .turn-stage.leaving{transition:height .4s cubic-bezier(.3,.7,.2,1),opacity .3s ease;opacity:0}
437
+ .ts-arrive{animation:ts-arrive .5s cubic-bezier(.3,.7,.2,1)}
438
+ @keyframes ts-arrive{from{opacity:0;transform:translateY(60px)}}
439
+ .ts-status{flex:none;display:flex;align-items:center;gap:8px;min-width:0;padding:8px 8px 0 14px;font-size:12px;color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap}
440
+ .turn-stage.collapsed .ts-status{padding-bottom:8px;cursor:pointer}
441
+ .ts-mark{flex:none;width:7px;height:7px;border-radius:50%;background:var(--accent);animation:step-pulse 1.6s ease-in-out infinite}
442
+ .turn-stage.ended .ts-mark{width:auto;height:auto;background:none;animation:none;font-size:12px;line-height:1}
443
+ .turn-stage[data-phase="answered"] .ts-mark,.turn-stage[data-phase="answered"] .ts-phase{color:var(--ok)}
444
+ .turn-stage[data-phase="canceled"] .ts-mark,.turn-stage[data-phase="canceled"] .ts-phase,.turn-stage[data-phase="waiting for you"] .ts-phase{color:var(--warn-text)}
445
+ .turn-stage[data-phase="failed"] .ts-mark,.turn-stage[data-phase="failed"] .ts-phase{color:var(--danger-text)}
446
+ .turn-stage[data-phase="waiting for you"] .ts-mark{background:var(--warn)}
447
+ .ts-phase{color:var(--accent);font-weight:600}
448
+ .ts-timing{min-width:0;overflow:hidden;text-overflow:ellipsis}
449
+ .ts-timing .turn-timing{margin:0;font-size:12px;color:var(--text-muted);display:inline}
450
+ .ts-mini{min-width:0;overflow:hidden;text-overflow:ellipsis;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-2)}
451
+ .ts-defer{color:var(--text-faint);font-style:italic;opacity:0;transition:opacity .2s}
452
+ .turn-stage.ended.in-use .ts-defer{opacity:1}
453
+ .ts-fold{margin-left:auto;flex:none;width:28px;height:24px;padding:0;border-radius:6px;background:transparent;color:var(--text-muted);font-size:14px}
454
+ .ts-fold:hover:not(:disabled){filter:none;background:var(--bg-hover);color:var(--text)}
455
+ .ts-scroll{position:relative;flex:1 1 auto;min-height:0;overflow:auto;overflow-anchor:none;display:flex;flex-direction:column;gap:8px;padding:8px 14px 10px;scrollbar-width:thin}
456
+ .ts-scroll[hidden],.ts-scroll>[hidden]{display:none}
457
+ /* Flex column children with overflow:hidden would shrink to nothing. */
458
+ .ts-scroll>*{flex-shrink:0}
459
+ /* The prompt line: the turn's own bubble, one line; a click shows it whole. */
460
+ .ts-prompt{cursor:pointer;min-width:0}
461
+ .ts-prompt>.bubble.user{display:flex;gap:6px;align-items:baseline;max-width:none;margin:0;padding:0;background:transparent;border:0;border-radius:0;font-size:13px;line-height:1.45;color:var(--text-muted)}
462
+ .ts-prompt>.bubble.user::before{content:"you";flex:none;color:var(--accent);font-weight:600}
463
+ /* Another client's prompt says whose it is (its origin label) instead. */
464
+ .ts-prompt>.bubble.user:has(>.origin-label)::before{content:none}
465
+ .ts-prompt>.bubble.user>.copy-btn{display:none}
466
+ .ts-prompt:not(.full) .user-message{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:1;line-clamp:1;overflow:hidden;white-space:normal}
467
+ .ts-prompt:not(.full) .user-message .thumbs{display:none}
468
+ .ts-prompt:not(.full) .user-message blockquote{display:inline;border:0;padding:0;margin:0}
469
+ /* The live slots: fixed heights (two headline lines, one tool row, one trail line). */
470
+ .ts-live{display:flex;flex-direction:column;gap:8px}
471
+ .ts-headline{height:42px;font-size:14px;line-height:21px;color:var(--narr);overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;cursor:pointer;border-radius:6px}
472
+ .ts-headline:hover{background:var(--bg-hover)}
473
+ .ts-headline:not(.thinking)::before{content:"\203A";color:var(--accent);font-weight:700;margin-right:7px}
474
+ .ts-headline.thinking{font-size:13.5px;font-style:italic;color:var(--text-muted)}
475
+ .ts-think-tag{font-style:normal;font-size:12.5px;color:var(--think-text);margin-right:6px}
476
+ .ts-headline.fresh{animation:ts-fade .3s ease}
477
+ @keyframes ts-fade{from{opacity:.35}}
478
+ .ts-now{height:32px;display:flex;align-items:center;gap:8px;min-width:0;padding:0 9px;border-radius:8px;background:var(--accent-tint);font-size:13px}
479
+ .ts-spin{flex:none;width:11px;height:11px;border:2px solid var(--warn);border-right-color:transparent;border-radius:50%;animation:init-spin .8s linear infinite}
480
+ .ts-idle{flex:none;width:11px;height:11px;border:2px solid var(--border-strong);border-radius:50%}
481
+ .ts-tool{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;font-weight:600;color:var(--accent)}
482
+ .ts-tool.exec{color:var(--think-text)}
483
+ .ts-tool.edit{color:var(--ok-text)}
484
+ .ts-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-2)}
485
+ .ts-title.quiet{font-family:inherit;font-style:italic;color:var(--text-faint)}
486
+ .ts-trail{height:18px;display:flex;gap:10px;overflow:hidden;font-size:12px;line-height:18px;color:var(--text-faint);white-space:nowrap}
487
+ .ts-trail-item{flex:none;max-width:36ch;overflow:hidden;text-overflow:ellipsis}
488
+ .ts-trail-item.error{color:var(--danger-text)}
489
+ .ts-trail-item.warn{color:var(--warn-text)}
490
+ .ts-trail-item.none{font-style:italic}
491
+ /* A card waits: the tool row and trail give it their room. */
492
+ .turn-stage.waiting .ts-now,.turn-stage.waiting .ts-trail{display:none}
493
+ /* Rows that arrive during the turn (cards, queued prompts, commands, notes) and after its end (the end line): capped, own scroll. */
494
+ .ts-extras,.ts-tail{display:flex;flex-direction:column;gap:8px;max-height:calc(var(--stage-max,60vh) * .4);overflow:auto;scrollbar-width:thin}
495
+ .turn-stage.waiting .ts-extras{max-height:calc(var(--stage-max,60vh) * .7)}
496
+ .ts-extras>*,.ts-tail>*{flex-shrink:0}
497
+ .ts-extras>.bubble.user{max-width:none;margin:0;padding:4px 10px;font-size:13px;line-height:1.45;background:transparent;color:var(--text-2)}
498
+ .ts-extras>.bubble.user>.copy-btn{display:none}
499
+ .ts-extras>.bubble.user .user-message{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;white-space:normal}
500
+ /* Inside the dock's own blur a second backdrop-filter sees nothing: the cards are opaque here (as #commandList is). */
501
+ .turn-stage .bubble.question,.turn-stage .bubble.plugin-card{-webkit-backdrop-filter:none;backdrop-filter:none;box-shadow:none}
502
+ .turn-stage .bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel))}
503
+ .turn-stage .bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel))}
504
+ .turn-stage .bubble.plugin-card.warn{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel))}
505
+ .ts-answer{display:flex;flex-direction:column;gap:8px}
506
+ .ts-answer>.bubble.output{border-left:0;background:transparent;border:0;padding:0}
507
+ .ts-answer>.bubble.output>.copy-btn{top:0;right:0}
508
+ /* The cloud: one chip (steps, calls, a tick per call), the turn view's block behind it. */
509
+ .ts-cloud{display:flex;flex-direction:column;gap:6px}
510
+ .ts-chip{display:flex;align-items:center;gap:10px;flex-wrap:nowrap;width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:9px;background:transparent;color:var(--text-muted);text-align:left}
511
+ .ts-chip:hover:not(:disabled){filter:none;background:var(--bg-hover)}
512
+ .ts-chip-label{font-size:12.5px;font-weight:600;white-space:nowrap;font-variant-numeric:tabular-nums}
513
+ .ts-chip-label::before{content:"\25B8 ";color:var(--accent)}
514
+ .ts-cloud.open .ts-chip-label::before{content:"\25BE "}
515
+ /* One row whatever the count (the stage's height stays): the newest ticks show, the oldest are cut on the left. */
516
+ .ts-ticks{display:flex;flex-wrap:nowrap;justify-content:flex-end;overflow:hidden;gap:2px;flex:0 1 auto;min-width:0}
517
+ .ts-tick{flex:none}
518
+ .ts-tick{width:5px;height:12px;border-radius:1.5px;background:var(--text-faint);opacity:.55;cursor:pointer}
519
+ .ts-tick.exec{background:var(--think);opacity:.85}
520
+ .ts-tick.edit{background:var(--ok);opacity:.85}
521
+ .ts-tick.other{background:var(--accent);opacity:.7}
522
+ .ts-tick.error{background:var(--danger);opacity:1}
523
+ .ts-tick.running{background:var(--warn);opacity:1;animation:ts-blink 1s ease-in-out infinite}
524
+ @keyframes ts-blink{50%{opacity:.35}}
525
+ .ts-cloud-body{max-height:calc(var(--stage-max,60vh) * .62);overflow:auto;scrollbar-width:thin;border-left:1px solid var(--border);margin-left:6px;padding-left:10px}
526
+ .ts-cloud-body[hidden]{display:none}
527
+ .turn-stage .turn-work{border:0;background:transparent;padding:0;border-radius:0}
528
+ .turn-stage .turn-work>summary{display:none}
529
+ .turn-stage .ts-cloud-body .gen:first-of-type{margin-top:0;border-top:0}
530
+ @media(max-width:600px){
531
+ .ts-status{padding-left:10px}
532
+ .ts-scroll{padding:8px 10px 10px}
533
+ .ts-defer{display:none}
534
+ .ts-trail-item{max-width:24ch}
535
+ }
536
+ @media (prefers-reduced-motion:reduce){.turn-stage.enter,.ts-arrive,.ts-headline.fresh,.ts-tick.running,.ts-mark{animation:none}.turn-stage.leaving{transition:none}}
374
537
  #infoBar{display:flex;padding:8px 14px 10px;border-top:1px solid var(--glass-border);background:transparent;border-radius:0 0 16px 16px;color:var(--text-2);gap:10px;align-items:flex-start;flex-wrap:wrap;flex-shrink:0;transition:opacity 280ms ease}
375
538
  #infoBar.visible{display:flex}
376
539
  #infoBar .info{flex:1;min-width:0;font-size:13px;line-height:1.4;word-break:break-word;display:flex;flex-direction:column;gap:2px}
@@ -565,13 +728,17 @@ a{color:var(--accent)}
565
728
  <div id="initRow" role="status" aria-live="polite" hidden></div>
566
729
  <div id="composer" style="position:relative">
567
730
  <div id="commandList" role="listbox" aria-label="Commands" hidden></div>
731
+ <div id="modelPanel" hidden>
732
+ <label class="model-search"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14" stroke-linecap="round"/></svg><input id="modelSearch" type="text" placeholder="Search models" autocomplete="off" spellcheck="false" role="combobox" aria-label="Search models" aria-autocomplete="list" aria-controls="modelList" aria-expanded="false"></label>
733
+ <div id="modelList" role="listbox" aria-label="Models"></div>
734
+ </div>
568
735
  <div id="composeGrip" title="Drag to make the message box taller (double-click resets)"></div>
569
736
  <div class="compose-col">
570
737
  <div id="chips" hidden></div>
571
738
  <textarea id="prompt" placeholder="Ask chi anything…" aria-controls="commandList" aria-autocomplete="list" aria-expanded="false"></textarea>
572
739
  </div>
573
740
  <div class="actions-col">
574
- <label id="modelPick" class="model-pick" title="The model the new chat starts on" hidden><select id="modelSelect" aria-label="Model for the new chat"></select></label>
741
+ <button type="button" id="modelPick" class="model-pick" title="The model the new chat starts on" aria-label="Model for the new chat" aria-haspopup="listbox" aria-expanded="false" hidden><span class="model-pick-name"></span></button>
575
742
  <div id="composeHint">Enter to start · Shift+Enter for a newline</div>
576
743
  <button id="cancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Cancel</button>
577
744
  <div class="split">
@@ -21,3 +21,139 @@ export function pickModel(names, defaultName, stored) {
21
21
  return defaultName || names[0] || "";
22
22
  }
23
23
 
24
+
25
+ export const RECENT_KEY = "chi_model_recent";
26
+
27
+ // The payload's models as rows: {name, host, id, isDefaultHost}. The
28
+ // default host's names are bare (name == id); the server's own default,
29
+ // unshifted when no host lists it, has no host and goes with them, shown
30
+ // by its name.
31
+ export function modelRows(payload) {
32
+ const list = Array.isArray(payload?.models) ? payload.models : [];
33
+ return list
34
+ .filter((m) => m && String(m.name || ""))
35
+ .map((m) => {
36
+ const name = String(m.name);
37
+ const host = m.host ? String(m.host) : "";
38
+ const id = host ? String(m.id || name) : name;
39
+ return { name, host, id, isDefaultHost: !host || name === id };
40
+ });
41
+ }
42
+
43
+ const byId = (a, b) => a.id.localeCompare(b.id, "en", { sensitivity: "base" });
44
+
45
+ // The rows under their hosts, [{host, isDefault, rows}]: the default host
46
+ // first, the others in the server's order, ids A-Z inside a host.
47
+ export function groupRows(rows) {
48
+ const groups = [];
49
+ const find = (key) => groups.find((g) => g.key === key);
50
+ for (const row of rows) {
51
+ const key = row.isDefaultHost ? "" : row.host;
52
+ let group = find(key);
53
+ if (!group) {
54
+ group = { key, host: row.host, isDefault: row.isDefaultHost, rows: [] };
55
+ if (row.isDefaultHost) groups.unshift(group); else groups.push(group);
56
+ }
57
+ if (!group.host && row.host) group.host = row.host;
58
+ group.rows.push(row);
59
+ }
60
+ return groups.map(({ host, isDefault, rows: rs }) => ({
61
+ host: host || "default", isDefault, rows: [...rs].sort(byId),
62
+ }));
63
+ }
64
+
65
+ const SEPARATORS = "/-:._ ";
66
+ const atSegmentStart = (text, i) => i === 0 || SEPARATORS.includes(text[i - 1]);
67
+
68
+ // One word against one field (both lowercased): {sub, cost, marks} or null.
69
+ // A substring is best, at a segment start better still; else, for words of
70
+ // 3+ characters, a subsequence taken greedily from the leftmost start,
71
+ // costing more per gap.
72
+ function matchWord(word, text) {
73
+ let best = null;
74
+ for (let i = text.indexOf(word); i >= 0; i = text.indexOf(word, i + 1)) {
75
+ const cost = atSegmentStart(text, i) ? 0 : 2;
76
+ if (!best || cost < best.cost) best = { sub: true, cost, marks: [[i, i + word.length]] };
77
+ if (cost === 0) break;
78
+ }
79
+ if (best || word.length < 3) return best;
80
+ const marks = [];
81
+ let gaps = 0;
82
+ let at = text.indexOf(word[0]);
83
+ if (at < 0) return null;
84
+ for (let k = 0; k < word.length; k++) {
85
+ const i = k === 0 ? at : text.indexOf(word[k], at + 1);
86
+ if (i < 0) return null;
87
+ if (k > 0) gaps += i - at - 1;
88
+ const last = marks[marks.length - 1];
89
+ if (last && last[1] === i) last[1] = i + 1; else marks.push([i, i + 1]);
90
+ at = i;
91
+ }
92
+ return { sub: false, cost: 10 + gaps, marks };
93
+ }
94
+
95
+ function mergeMarks(marks) {
96
+ const sorted = [...marks].sort((a, b) => a[0] - b[0]);
97
+ const out = [];
98
+ for (const [a, b] of sorted) {
99
+ const last = out[out.length - 1];
100
+ if (last && a <= last[1]) last[1] = Math.max(last[1], b); else out.push([a, b]);
101
+ }
102
+ return out;
103
+ }
104
+
105
+ const better = (a, b) => !b || (a.sub && !b.sub) || (a.sub === b.sub && a.cost < b.cost);
106
+
107
+ // The rows matching every word of +query+ against the host or the shown id,
108
+ // ranked: substring-only rows first, then the summed cost, the shorter id,
109
+ // the rows' own order. Each {row, hostMarks, idMarks} carries [start, end)
110
+ // ranges into its field; a default-host row's host is not shown, so it
111
+ // gets none. [] for a blank query.
112
+ export function matchModels(rows, query) {
113
+ const words = String(query || "").toLowerCase().split(/\s+/).filter(Boolean);
114
+ if (!words.length) return [];
115
+ const found = [];
116
+ rows.forEach((row, index) => {
117
+ const host = row.host.toLowerCase();
118
+ const id = row.id.toLowerCase();
119
+ const hostMarks = [];
120
+ const idMarks = [];
121
+ let sub = true;
122
+ let cost = 0;
123
+ for (const word of words) {
124
+ const onId = matchWord(word, id);
125
+ const onHost = matchWord(word, host);
126
+ const pick = onId && !better(onHost || { sub: false, cost: Infinity }, onId) ? onId : onHost;
127
+ if (!pick) return;
128
+ (pick === onId ? idMarks : hostMarks).push(...pick.marks);
129
+ sub = sub && pick.sub;
130
+ cost += pick.cost;
131
+ }
132
+ found.push({
133
+ row, index, sub, cost,
134
+ hostMarks: row.isDefaultHost ? [] : mergeMarks(hostMarks),
135
+ idMarks: mergeMarks(idMarks),
136
+ });
137
+ });
138
+ found.sort((a, b) => (b.sub - a.sub) || (a.cost - b.cost) ||
139
+ (a.row.id.length - b.row.id.length) || (a.index - b.index));
140
+ return found.map(({ row, hostMarks, idMarks }) => ({ row, hostMarks, idMarks }));
141
+ }
142
+
143
+ // The recent list after picking +name+: it first, no other copy (any case),
144
+ // at most +max+.
145
+ export function pushRecent(list, name, max = 5) {
146
+ const prior = (Array.isArray(list) ? list : []).filter((n) => typeof n === "string" && n);
147
+ if (!name) return prior.slice(0, max);
148
+ const want = name.toLowerCase();
149
+ return [name, ...prior.filter((n) => n.toLowerCase() !== want)].slice(0, max);
150
+ }
151
+
152
+ // The rows for the recent names still offered, newest first.
153
+ export function recentRows(rows, list) {
154
+ if (!Array.isArray(list)) return [];
155
+ return list
156
+ .filter((n) => typeof n === "string")
157
+ .map((n) => rows.find((r) => r.name.toLowerCase() === n.toLowerCase()))
158
+ .filter(Boolean);
159
+ }
@@ -0,0 +1,224 @@
1
+ // The start page's model picker: a quiet button with the chosen name that
2
+ // opens a searchable list (#modelPanel) over the conversation, like the /
3
+ // list. The rows and the matching are model_pick.js'; this is the DOM.
4
+ //
5
+ // The panel is a child of #composer, not of the button (an input can't sit
6
+ // in a button, and the button clips). It opens upward, or downward when
7
+ // #center (overflow hidden, and #dock's backdrop-filter traps position:
8
+ // fixed) leaves too little room above, over the composer when neither side
9
+ // has it. The search input keeps the focus:
10
+ // ↑/↓ PageUp/PageDown move, ⏎ picks, Esc and Tab close without a change.
11
+
12
+ import { escapeHtml } from "./format.js";
13
+ import { moveSelection } from "./command_complete.js";
14
+ import {
15
+ MODEL_KEY, RECENT_KEY, groupRows, matchModels, modelRows, pickModel, pushRecent, recentRows,
16
+ } from "./model_pick.js";
17
+
18
+ const PAGE = 8;
19
+ const MAX_HEIGHT = 360;
20
+ const MIN_ROOM = 200;
21
+ const GAP = 8;
22
+
23
+ // The text with the [start, end) ranges in <mark>, escaped slice by slice.
24
+ function markedHtml(text, marks) {
25
+ let html = "";
26
+ let at = 0;
27
+ for (const [a, b] of marks || []) {
28
+ html += `${escapeHtml(text.slice(at, a))}<mark>${escapeHtml(text.slice(a, b))}</mark>`;
29
+ at = b;
30
+ }
31
+ return html + escapeHtml(text.slice(at));
32
+ }
33
+
34
+ function readRecent() {
35
+ try {
36
+ const list = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]");
37
+ return Array.isArray(list) ? list : [];
38
+ } catch (_) {
39
+ return [];
40
+ }
41
+ }
42
+
43
+ // +onPick+ runs after a pick (the panel is closed by then).
44
+ export function createModelPicker({ button, panel, input, list, composer, center, onPick }) {
45
+ let rows = [];
46
+ let defaultName = "";
47
+ let chosen = "";
48
+ let shown = []; // the option rows in list order
49
+ let active = 0;
50
+
51
+ const isOpen = () => !panel.hidden;
52
+
53
+ function setChosen(name) {
54
+ chosen = name;
55
+ button.querySelector(".model-pick-name").textContent = name;
56
+ }
57
+
58
+ function rowHtml(row, i, { hostMarks = [], idMarks = [], showHost = !row.isDefaultHost } = {}) {
59
+ const host = showHost ? `<span class="model-host">${markedHtml(row.host, hostMarks)}</span><span class="model-dot"> · </span>` : "";
60
+ const note = row.name === defaultName ? `<span class="model-note">default</span>` : "";
61
+ const cls = `model-option${i === active ? " active" : ""}${row.name === chosen ? " chosen" : ""}`;
62
+ return `<div class="${cls}" role="option" id="model-option-${i}" aria-selected="${i === active}" data-index="${i}">` +
63
+ `<span class="model-id">${host}${markedHtml(row.id, idMarks)}</span>${note}</div>`;
64
+ }
65
+
66
+ function render() {
67
+ const query = input.value.trim();
68
+ let html = "";
69
+ shown = [];
70
+ const add = (row, opts) => { html += rowHtml(row, shown.length, opts); shown.push(row); };
71
+ if (query) {
72
+ for (const m of matchModels(rows, query)) add(m.row, { hostMarks: m.hostMarks, idMarks: m.idMarks });
73
+ if (!shown.length) html = `<div class="model-empty" role="presentation">Nothing matches</div>`;
74
+ } else {
75
+ const recent = recentRows(rows, readRecent());
76
+ const head = (label, note = "") =>
77
+ `<div class="model-group" role="presentation">${escapeHtml(label)}${note ? ` <span>${escapeHtml(note)}</span>` : ""}</div>`;
78
+ if (recent.length) {
79
+ html += head("Recent");
80
+ recent.forEach((row) => add(row));
81
+ }
82
+ for (const group of groupRows(rows)) {
83
+ html += head(group.host, group.isDefault ? "default host" : "");
84
+ group.rows.forEach((row) => add(row, { showHost: false }));
85
+ }
86
+ }
87
+ if (active >= shown.length) active = 0;
88
+ list.innerHTML = html;
89
+ showActive();
90
+ }
91
+
92
+ function showActive() {
93
+ list.querySelectorAll(".model-option.active").forEach((el) => {
94
+ el.classList.remove("active");
95
+ el.setAttribute("aria-selected", "false");
96
+ });
97
+ const el = list.querySelector(`#model-option-${active}`);
98
+ if (!el) return input.removeAttribute("aria-activedescendant");
99
+ el.classList.add("active");
100
+ el.setAttribute("aria-selected", "true");
101
+ input.setAttribute("aria-activedescendant", el.id);
102
+ el.scrollIntoView({ block: "nearest" });
103
+ }
104
+
105
+ // Upward by default; downward when the room above #composer inside
106
+ // #center is short and the room below is not; over the composer itself
107
+ // (its bottom edge) when neither has the room, as in a short window,
108
+ // where the start page centres the dock and leaves little on either side.
109
+ function place() {
110
+ const box = center.getBoundingClientRect();
111
+ const at = composer.getBoundingClientRect();
112
+ const up = at.top - box.top - GAP * 2;
113
+ const down = box.bottom - at.bottom - GAP * 2;
114
+ const side = up >= MIN_ROOM ? "up" : down >= MIN_ROOM ? "down" : "over";
115
+ const room = { up, down, over: at.bottom - box.top - GAP }[side];
116
+ panel.classList.toggle("down", side === "down");
117
+ panel.classList.toggle("over", side === "over");
118
+ const cap = window.matchMedia("(max-width:600px)").matches ? Math.min(MAX_HEIGHT, window.innerHeight / 2) : MAX_HEIGHT;
119
+ panel.style.maxHeight = `${Math.max(0, Math.min(cap, room))}px`;
120
+ }
121
+
122
+ function open() {
123
+ if (isOpen() || button.hidden) return;
124
+ input.value = "";
125
+ active = 0;
126
+ panel.hidden = false;
127
+ button.setAttribute("aria-expanded", "true");
128
+ input.setAttribute("aria-expanded", "true");
129
+ place();
130
+ render();
131
+ // Land on the chosen row: its Recent copy when it has one, so the list
132
+ // opens at its top.
133
+ const at = shown.findIndex((r) => r.name === chosen);
134
+ if (at >= 0) { active = at; showActive(); }
135
+ input.focus();
136
+ }
137
+
138
+ function close({ refocus = false } = {}) {
139
+ if (!isOpen()) return;
140
+ panel.hidden = true;
141
+ list.innerHTML = "";
142
+ shown = [];
143
+ button.setAttribute("aria-expanded", "false");
144
+ input.setAttribute("aria-expanded", "false");
145
+ input.removeAttribute("aria-activedescendant");
146
+ if (refocus) button.focus();
147
+ }
148
+
149
+ function pick(index) {
150
+ const row = shown[index];
151
+ if (!row) return;
152
+ setChosen(row.name);
153
+ try {
154
+ localStorage.setItem(MODEL_KEY, row.name);
155
+ localStorage.setItem(RECENT_KEY, JSON.stringify(pushRecent(readRecent(), row.name)));
156
+ } catch (_) {}
157
+ close();
158
+ onPick?.(row.name);
159
+ }
160
+
161
+ button.addEventListener("click", () => (isOpen() ? close() : open()));
162
+ button.addEventListener("keydown", (e) => {
163
+ if (e.isComposing || e.key !== "ArrowDown" || isOpen()) return;
164
+ e.preventDefault();
165
+ open();
166
+ });
167
+ input.addEventListener("input", () => { active = 0; render(); });
168
+ input.addEventListener("keydown", (e) => {
169
+ if (e.isComposing) return;
170
+ const count = shown.length;
171
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
172
+ active = moveSelection(active, count, e.key === "ArrowDown" ? 1 : -1);
173
+ showActive();
174
+ } else if (e.key === "PageDown" || e.key === "PageUp") {
175
+ active = Math.max(0, Math.min(count - 1, active + (e.key === "PageDown" ? PAGE : -PAGE)));
176
+ showActive();
177
+ } else if (e.key === "Enter") {
178
+ pick(active);
179
+ } else if (e.key === "Escape") {
180
+ e.stopPropagation();
181
+ close({ refocus: true });
182
+ } else if (e.key === "Tab") {
183
+ close();
184
+ return; // the focus moves on as it would
185
+ } else {
186
+ return;
187
+ }
188
+ e.preventDefault();
189
+ });
190
+ // Keep the focus in the search box: the pick happens on click.
191
+ list.addEventListener("pointerdown", (e) => e.preventDefault());
192
+ list.addEventListener("mousemove", (e) => {
193
+ const option = e.target.closest(".model-option");
194
+ if (!option || Number(option.dataset.index) === active) return;
195
+ active = Number(option.dataset.index);
196
+ showActive();
197
+ });
198
+ list.addEventListener("click", (e) => {
199
+ const option = e.target.closest(".model-option");
200
+ if (option) pick(Number(option.dataset.index));
201
+ });
202
+ document.addEventListener("pointerdown", (e) => {
203
+ if (isOpen() && !panel.contains(e.target) && !button.contains(e.target)) close();
204
+ });
205
+ window.addEventListener("resize", () => { if (isOpen()) place(); });
206
+
207
+ return {
208
+ // GET /api/models' payload in: the rows, the preselected name (the
209
+ // stored choice while offered, else the server's default). False when
210
+ // the payload offers nothing (the button stays as it was).
211
+ setModels(payload, stored) {
212
+ const next = modelRows(payload);
213
+ if (!next.length) return false;
214
+ close();
215
+ rows = next;
216
+ defaultName = String(payload.default || "");
217
+ setChosen(pickModel(rows.map((r) => r.name), payload.default, stored || chosen));
218
+ return true;
219
+ },
220
+ chosen: () => chosen,
221
+ close,
222
+ isOpen,
223
+ };
224
+ }