samagotchi 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -1
- data/README.md +13 -2
- data/bin/chi +29 -39
- data/docs/cli.md +135 -73
- data/docs/configuration.md +15 -20
- data/docs/hooks.md +1 -1
- data/docs/memory.md +40 -0
- data/docs/plugins.md +50 -0
- data/docs/releasing.md +9 -6
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
- data/lib/samagotchi/bridge/sse_writer.rb +0 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +16 -11
- data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
- data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -8
- data/lib/samagotchi/bundles/system/identity.md +5 -0
- data/lib/samagotchi/bundles/system/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client.rb +16 -20
- data/lib/samagotchi/config.rb +40 -100
- data/lib/samagotchi/engine.rb +50 -354
- data/lib/samagotchi/kernel_loop.rb +33 -44
- data/lib/samagotchi/live_versions.rb +7 -1
- data/lib/samagotchi/llm/errors.rb +17 -0
- data/lib/samagotchi/llm/http.rb +4 -18
- data/lib/samagotchi/llm/openai_chat.rb +17 -0
- data/lib/samagotchi/model_profile.rb +4 -10
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +48 -4
- data/lib/samagotchi/self_report.rb +20 -2
- data/lib/samagotchi/send_command.rb +84 -6
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/session_manager.rb +18 -37
- data/lib/samagotchi/system_prompt.rb +403 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +120 -83
- data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +23 -7
- data/lib/samagotchi/terminal_ui/formatting.rb +32 -22
- data/lib/samagotchi/terminal_ui/input_support.rb +3 -4
- data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
- data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
- data/lib/samagotchi/terminal_ui/surface.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +95 -690
- data/lib/samagotchi/thinking.rb +11 -0
- data/lib/samagotchi/tool_activity.rb +52 -2
- data/lib/samagotchi/tool_runner.rb +3 -0
- data/lib/samagotchi/tools/execute.rb +3 -3
- data/lib/samagotchi/tools/output_guardrails.rb +8 -7
- data/lib/samagotchi/tools/read.rb +4 -4
- data/lib/samagotchi/update_command.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +170 -35
- data/lib/samagotchi/web/lan.rb +99 -0
- data/lib/samagotchi/web/message_parts.rb +15 -11
- data/lib/samagotchi/web/public/activity.js +7 -0
- data/lib/samagotchi/web/public/app.js +99 -54
- data/lib/samagotchi/web/public/chat_view.js +5 -1
- data/lib/samagotchi/web/public/index.html +163 -17
- data/lib/samagotchi/web/public/model_pick.js +136 -0
- data/lib/samagotchi/web/public/model_picker.js +224 -0
- data/lib/samagotchi/web/public/notify.js +10 -0
- data/lib/samagotchi/web/public/stage_model.js +110 -0
- data/lib/samagotchi/web/public/stage_view.js +580 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +9 -5
- data/lib/samagotchi/web/public/turn_model.js +11 -3
- data/lib/samagotchi/web/public/turn_view.js +74 -19
- data/lib/samagotchi/web/qr.rb +40 -0
- data/lib/samagotchi/web/server.rb +101 -11
- data/lib/samagotchi/web/token.rb +97 -0
- metadata +27 -3
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
|
@@ -5,18 +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);
|
|
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
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
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}
|
|
11
11
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
12
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}
|
|
13
13
|
/* Glass: the composer card, the strip cards, the all-sessions cards and the question cards (by selector, so app.js is untouched). */
|
|
14
|
-
#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)}
|
|
15
15
|
#topStrip .card:hover,#allList .card:hover{background:color-mix(in srgb,var(--bg-hover) 75%,transparent);border-color:rgba(255,255,255,.14)}
|
|
16
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)}
|
|
17
|
-
@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))}}
|
|
18
|
-
@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}}
|
|
19
|
-
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}
|
|
20
20
|
body.no-glass .bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}
|
|
21
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}
|
|
22
22
|
button,input,textarea,select{font:inherit}
|
|
@@ -152,7 +152,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
152
152
|
.bubble.thinking>summary::before{content:"▸ ";color:var(--think)}
|
|
153
153
|
.bubble.thinking[open]>summary::before{content:"▾ "}
|
|
154
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}
|
|
155
|
-
/* Turn view (web.
|
|
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. */
|
|
156
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}
|
|
157
157
|
.turn-work>summary{cursor:pointer;color:var(--text-muted);font-size:13px;font-weight:600;user-select:none;list-style:none}
|
|
158
158
|
.turn-work>summary::-webkit-details-marker{display:none}
|
|
@@ -372,16 +372,40 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
|
|
|
372
372
|
#composer #actionBtn{min-width:64px;font-weight:600}
|
|
373
373
|
/* The model picker (start page only): reads as a label, "host:model ▾", left of Start; a native select under a drawn caret. */
|
|
374
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. */
|
|
375
|
-
.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}
|
|
376
|
-
.model-pick
|
|
377
|
-
.
|
|
378
|
-
.model-pick
|
|
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}
|
|
380
|
-
.model-pick
|
|
381
|
-
.model-pick
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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}}
|
|
385
409
|
/* The / autocomplete: a list over the composer card's top edge. It wears the glass edge and shadow, but opaque:
|
|
386
410
|
inside the dock's own backdrop-filter a second blur sees nothing, and the history would show through its text. */
|
|
387
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)}
|
|
@@ -392,6 +416,124 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
|
|
|
392
416
|
#commandList .command-desc{flex:1;min-width:0;font-size:13px;color:var(--text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
393
417
|
#commandList .command-note{font-size:11px;color:var(--text-faint);white-space:nowrap}
|
|
394
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}}
|
|
395
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}
|
|
396
538
|
#infoBar.visible{display:flex}
|
|
397
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}
|
|
@@ -586,13 +728,17 @@ a{color:var(--accent)}
|
|
|
586
728
|
<div id="initRow" role="status" aria-live="polite" hidden></div>
|
|
587
729
|
<div id="composer" style="position:relative">
|
|
588
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>
|
|
589
735
|
<div id="composeGrip" title="Drag to make the message box taller (double-click resets)"></div>
|
|
590
736
|
<div class="compose-col">
|
|
591
737
|
<div id="chips" hidden></div>
|
|
592
738
|
<textarea id="prompt" placeholder="Ask chi anything…" aria-controls="commandList" aria-autocomplete="list" aria-expanded="false"></textarea>
|
|
593
739
|
</div>
|
|
594
740
|
<div class="actions-col">
|
|
595
|
-
<
|
|
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>
|
|
596
742
|
<div id="composeHint">Enter to start · Shift+Enter for a newline</div>
|
|
597
743
|
<button id="cancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Cancel</button>
|
|
598
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
|
+
}
|
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
// summaries (GET /api/events). The page shows an OS notification and a
|
|
3
3
|
// title badge for what it returns, while the tab is not in front.
|
|
4
4
|
|
|
5
|
+
// The bell's state. OS notifications need a secure context: on chi web
|
|
6
|
+
// over http at a LAN address the API is missing or always denied, so the
|
|
7
|
+
// bell is hidden there (the title badge still counts).
|
|
8
|
+
// @return "on" | "off" | "denied" | "unsupported"
|
|
9
|
+
export function notifyState({ hasNotification, secureContext, permission, wanted }) {
|
|
10
|
+
if (!hasNotification || !secureContext) return "unsupported";
|
|
11
|
+
if (permission === "denied") return "denied";
|
|
12
|
+
return wanted && permission === "granted" ? "on" : "off";
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
// A finished turn shorter than this was watched: no "done".
|
|
6
16
|
export const DONE_MIN_SECONDS = 10;
|
|
7
17
|
|