diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index f52c02a..c7eab89 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -102,11 +102,19 @@ jobs: # --overwrite is not optional. Without it, and with a release directory already # present, mix release prompts, gets no stdin, and exits 0 having built nothing. + # Slice 013: on Linux, mdex's NIF is built from source for Burrito's musl ERTS, linked + # through Zig (scripts/zig-cc-musl; config/config.exs names the three settings and + # NOTES.md finding 14 the measurement). rustup installed the musl standard library from + # rust-toolchain.toml above. macOS and Windows load the precompiled artifact for their + # native ERTS; the smoke step below is what says whether it loaded. - name: Build the Burrito binary shell: bash env: BURRITO_TARGET: ${{ matrix.target }} MIX_ENV: prod + MDEX_NATIVE_BUILD: ${{ runner.os == 'Linux' && '1' || '' }} + TRINITY_NIF_TARGET: ${{ runner.os == 'Linux' && 'x86_64-unknown-linux-musl' || '' }} + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: ${{ runner.os == 'Linux' && format('{0}/scripts/zig-cc-musl', github.workspace) || '' }} run: | set -euo pipefail # `mix deps.get` above runs in the default env. On the Windows runner the prod build @@ -188,6 +196,8 @@ jobs: sleep 3 ours > ps-after.txt grep -q '^TRINITY_SMOKE_PORT=[0-9][0-9]*$' smoke.log + # Slice 013: the renderer's NIF loaded and rendered in this binary (exit 3 otherwise). + grep -q '^TRINITY_SMOKE_MARKDOWN=ok$' smoke.log echo "--- ours, before ---"; cat ps-before.txt echo "--- ours, after ----"; cat ps-after.txt diff ps-before.txt ps-after.txt @@ -202,6 +212,9 @@ jobs: if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_PORT=\d+$')) { throw "no port line" } + if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_MARKDOWN=ok$')) { + throw "the markdown renderer did not render in this binary (slice 013)" + } # AC1's property on a runner, and AC6's per-OS cold start, which used to be skipped. # `bash` on every OS, Windows included: the job that never curled was the one gap in @@ -233,7 +246,14 @@ jobs: [ -n "${PORT:-}" ] && break sleep 0.5 done - test -n "${PORT:-}" + # No endpoint line in two minutes says nothing on its own either: runs 35512430836 + # (slice/011), 35515556177 (slice/012) and 35521749862 all failed here on macOS and + # Windows with an empty step log. The log the binary wrote goes to stderr first. + if [ -z "${PORT:-}" ]; then + echo "no 'Running TrinityWeb.Endpoint' line in serve.log after 120 s" >&2 + echo "--- serve.log ---" >&2; tail -60 serve.log >&2 + exit 1 + fi # Bounded. The first version of this loop had no limit and the Windows runner sat in # it until the run was cancelled by hand: a step that hangs reports nothing and # fails nothing, which is the same shape as a workflow that never fires. @@ -252,6 +272,13 @@ jobs: echo "HTTP $code on port $PORT" echo "COLD_START_MS=$elapsed" | tee -a "$GITHUB_ENV" echo "### ${{ matrix.name }}: cold start to first HTTP 200: **${elapsed} ms**" >> "$GITHUB_STEP_SUMMARY" + # A non-200 says nothing on its own: run 35518054546 (slice 013) printed "HTTP 500" + # and nothing else, and the log that would have named the exception was never + # read. The body and the tail of serve.log go to stderr before the step fails. + if [ "$code" != "200" ]; then + echo "--- body of GET / ---" >&2; curl -sS "http://127.0.0.1:$PORT/" | head -c 2000 >&2; echo >&2 + echo "--- serve.log ---" >&2; tail -60 serve.log >&2 + fi test "$code" = "200" # Stopping it, and Windows needs its own verb. `kill` from Git-bash does not stop a # native Windows process, so the backgrounded .exe outlived the step and the job hung diff --git a/.sobelow-skips b/.sobelow-skips index 71b14d2..1e54748 100644 --- a/.sobelow-skips +++ b/.sobelow-skips @@ -1,5 +1,5 @@ -Config.CSP: Missing Content-Security-Policy,lib/trinity_web/router.ex:12,2DBC9F9 -Traversal.FileModule: Directory Traversal in `File.mkdir_p`,lib/trinity/data_dir/lock.ex:57,35AB9EC -Traversal.FileModule: Directory Traversal in `File.read`,lib/trinity/data_dir/lock.ex:143,55D5ADB -Traversal.FileModule: Directory Traversal in `File.rm`,lib/trinity/data_dir/lock.ex:82,21D88E2 -Traversal.FileModule: Directory Traversal in `File.write`,lib/trinity/data_dir/lock.ex:132,1C42814 +Config.CSP: Missing Content-Security-Policy,lib/trinity_web/router.ex:14,1DC4768 +Traversal.FileModule: Directory Traversal in `File.mkdir_p`,lib/trinity/data_dir/lock.ex:60,4ECF02C +Traversal.FileModule: Directory Traversal in `File.read`,lib/trinity/data_dir/lock.ex:146,5EFB666 +Traversal.FileModule: Directory Traversal in `File.rm`,lib/trinity/data_dir/lock.ex:85,7D2B826 +Traversal.FileModule: Directory Traversal in `File.write`,lib/trinity/data_dir/lock.ex:135,42D2817 diff --git a/.sobelow-skips.reasons b/.sobelow-skips.reasons index 38747f7..3e18106 100644 --- a/.sobelow-skips.reasons +++ b/.sobelow-skips.reasons @@ -9,8 +9,8 @@ # exactly that. Re-run mix sobelow --mark-skip-all and rekey this file when it happens. # # format: -2DBC9F9 Missing CSP on the browser pipeline. The scaffold ships no Content-Security-Policy, and setting one needs LiveView nonce handling, which belongs with the UI rather than the toolchain. Slice 013 owns it. Skipped rather than fixed blind: a CSP written without the UI it protects is a guess. -35AB9EC Slice 010, Trinity.DataDir.Lock.acquire/2: the directory is the application's own data directory from config or Trinity.Paths, never a request or a model output; the lock has to create it before the Repo does. Low confidence traversal by the tool's own label. -55D5ADB Slice 010, Trinity.DataDir.Lock read/1: reads /LOCK, a path this module composes from the data directory and a constant file name; no caller supplies it. -21D88E2 Slice 010, Trinity.DataDir.Lock.release/2: removes /LOCK only when the file carries this process's own token; same composed path as the read. -1C42814 Slice 010, Trinity.DataDir.Lock take_over/2: rewrites /LOCK after the recorded holder's pid is found dead; same composed path, same constant name. +1DC4768 Slice 013, the browser pipeline: the policy IS set, by TrinityWeb.Plugs.ContentSecurityPolicy with a nonce per request, placed before put_secure_browser_headers. sobelow recognises only a header map given to put_secure_browser_headers, so it keeps reporting the pipeline. test/trinity_web/plugs/content_security_policy_test.exs asserts the header and the nonce on every browser response. Rekeyed from 2DBC9F9 (router.ex:12, slice 000, "slice 013 owns it") when the plug moved the line to 14. +4ECF02C Rekeyed at slice 013 (the moduledoc grew three lines for the macOS and Windows liveness check). Slice 010, Trinity.DataDir.Lock.acquire/2: the directory is the application's own data directory from config or Trinity.Paths, never a request or a model output; the lock has to create it before the Repo does. Low confidence traversal by the tool's own label. +5EFB666 Rekeyed at slice 013 (the moduledoc grew three lines for the macOS and Windows liveness check). Slice 010, Trinity.DataDir.Lock read/1: reads /LOCK, a path this module composes from the data directory and a constant file name; no caller supplies it. +7D2B826 Rekeyed at slice 013 (the moduledoc grew three lines for the macOS and Windows liveness check). Slice 010, Trinity.DataDir.Lock.release/2: removes /LOCK only when the file carries this process's own token; same composed path as the read. +42D2817 Rekeyed at slice 013 (the moduledoc grew three lines for the macOS and Windows liveness check). Slice 010, Trinity.DataDir.Lock take_over/2: rewrites /LOCK after the recorded holder's pid is found dead; same composed path, same constant name. diff --git a/REUSE.toml b/REUSE.toml index 70a0864..44888a2 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -11,7 +11,7 @@ SPDX-PackageDownloadLocation = "https://github.com/ScriptKittyOS/Trinity" # the gap is recorded rather than glossed. [[annotations]] -path = ["mix.lock", "src-tauri/Cargo.lock", "**/*.md", "**/*.json", "**/*.tsv", "**/*.cff", "**/*.toml", ".tool-versions", ".gitignore", ".sobelow-skips", ".sobelow-skips.reasons", "priv/name_digests.txt", ".formatter.exs"] +path = ["mix.lock", "src-tauri/Cargo.lock", "**/*.md", "**/*.json", "**/*.tsv", "**/*.cff", "**/*.toml", ".tool-versions", ".gitignore", ".sobelow-skips", ".sobelow-skips.reasons", "priv/name_digests.txt", ".formatter.exs", "slices/**/proof/**"] precedence = "aggregate" SPDX-FileCopyrightText = "Sudo Apt Holdings LLC" SPDX-License-Identifier = "Apache-2.0" diff --git a/ROADMAP.md b/ROADMAP.md index bea1b92..655e4d4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,7 +36,7 @@ standards register names the rows that ask for them. | 010 | Core domain + persistence (Ecto/SQLite, schemas, Repo owner) | 1 Core loop | M | 000 | approved | | 011 | LLM provider layer (req_llm behind `Trinity.LLM` behaviour) | 1 Core loop | M | 010 | approved | | 012 | Session process + agent loop (gen_statem, DynamicSupervisor, rehydration) | 1 Core loop | L | 010, 011 | approved | -| 013 | LiveView chat UI with streaming | 1 Core loop | M | 012 | planned | +| 013 | LiveView chat UI with streaming | 1 Core loop | M | 012 | done | | 020 | Tool protocol + registry | 2 Tools | M | 012 | planned | | 021 | Permission gate + approval UI (M2 fingerprint-bound, M7) | 2 Tools | M | 020, 013 | planned | | 022 | Core tools: filesystem, web fetch/search, shell (MuonTrap) | 2 Tools | L | 021 | planned | diff --git a/VERSIONS.md b/VERSIONS.md index 804641f..85aa401 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -133,7 +133,9 @@ never pin a version hex marks as retired or vulnerable. | `ex_tauri` | ~> 0.2 | ✅ in `mix.lock` | ⚠️ Declares `otp_release: "~> 27.0"`, and Slice 000's probe refutes the reason it gives: OTP 28 macOS universal returns 200 and OTP 27 returns 404. Whether it runs on the pinned OTP is Slice 001's first measurement. ⚠️ 439 downloads all-time, so the ADR-0004 fallback matrix carries real weight. | | `nostrum` | ~> 0.10 | 🔍 not yet a dependency | Discord. ⚠️ No release in roughly 18 months. R11's trigger already fires. Check intents and components against the current gateway before Slice 072. | | `telegex` | **not pinned** | 🔍 not a single package | Telegram. ⚠️ The latest release on hex is a release candidate, roughly two years old, and this file's own rule forbids pinning an `-rc`. Alternative: ex_gram. Slice 071 decides with the measurement. | -| `phoenix_streamdown` | **not pinned** | 🔍 not a single package | Streaming markdown renderer for LiveView. ⚠️ Pre-release, and this file's own rule forbids pinning an `-rc`; a beta is the same category. Verify at Slice 013; fallback: earmark or mdex with chunk buffering. | +| `phoenix_streamdown` | not used (measured at Slice 013, 2026-09-20) | 🔍 not a single package | Streaming markdown renderer for LiveView. Measured at Slice 013: 1.0.0-beta.4 (2026-05-03) was still the latest release with no stable behind it, and this file's own rule forbids a pre-release. It is 801 lines over `mdex`, whose own `streaming: true` option completes fragments; the rest is a rendering rule Slice 013 keeps anyway. The row stays so the decision is visible where a reader would look for the package. | +| `mdex` | ~> 0.13 | ✅ in `mix.lock` | Markdown renderer for the chat (`TrinityWeb.Markdown`), streaming fragments completed by its `streaming: true` option, raw HTML omitted (`unsafe: false`) and the default sanitizer on top. Added at Slice 013 after the measurement in its NOTES.md: `earmark` 1.4.49 is retired on hex with an open XSS advisory (EEF-CVE-2026-48591), which the gate refuses. ⚠️ A Rust NIF (`mdex_native`): the first in the bundle. Measured at Slice 013 (NOTES finding 13): neither precompiled artifact loads in Burrito's musl ERTS on Linux, so the linux package builds it from source for musl with Zig as the linker (`rustler` below, `scripts/zig-cc-musl`, the three settings in config/config.exs); macOS and Windows load the precompiled artifact. The `--smoke` path prints whether it rendered, and the `package` workflow reads that line on every target. | +| `rustler` | ~> 0.38 | ✅ in `mix.lock` | Build time only (`runtime: false`): what `rustler_precompiled` needs to compile `mdex_native` from source when `MDEX_NATIVE_BUILD=1`, which the linux package sets (owner decision 2026-09-20, Slice 013 NOTES finding 14). Nothing in the tree calls it. | ### Dev and quality diff --git a/assets/css/app.css b/assets/css/app.css index da837e5..a82481c 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -20,76 +20,98 @@ themes: false; } -/* daisyUI theme plugin. - We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired - on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */ +/* Trinity design tokens, decided at slice 013 (SLICE.md: "the design language is decided here"). + Every later surface (the approval card at 021, the memory panel at 030, the skills list at + 040, tasks at 050, gateways at 070, activity and cost at 090, settings at 100) takes these + rather than inventing its own. Two daisyUI themes carry the palette; the @theme block below + carries the fonts, the two extra text sizes and the radii. Dark is the default. + + Fonts: a stack, no bundled file (NOTES.md, "Design language"): Ubuntu where the OS has it, + Comfortaa where installed, then the system font. Slice 100 decides what the packaged app + ships once its target list is fixed. */ +@theme { + --font-sans: "Ubuntu", "Comfortaa", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: "Ubuntu Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + /* Type scale: Tailwind's default steps, plus the two the chat reads most. */ + --text-ui: 0.9375rem; + --text-ui--line-height: 1.5rem; + --text-meta: 0.75rem; + --text-meta--line-height: 1rem; + /* Radii: panels, fields, pills. daisyUI's --radius-box/field/selector below match them. */ + --radius-panel: 1rem; + --radius-field: 0.75rem; + --radius-pill: 9999px; +} + +/* The dark theme: the default. Deep slate surfaces, a teal primary, violet secondary, amber accent. */ @plugin "daisyui/packages/bundle/daisyui-theme" { name: "dark"; - default: false; + default: true; prefersdark: true; color-scheme: "dark"; - --color-base-100: oklch(30.33% 0.016 252.42); - --color-base-200: oklch(25.26% 0.014 253.1); - --color-base-300: oklch(20.15% 0.012 254.09); - --color-base-content: oklch(97.807% 0.029 256.847); - --color-primary: oklch(58% 0.233 277.117); - --color-primary-content: oklch(96% 0.018 272.314); - --color-secondary: oklch(58% 0.233 277.117); - --color-secondary-content: oklch(96% 0.018 272.314); - --color-accent: oklch(60% 0.25 292.717); - --color-accent-content: oklch(96% 0.016 293.756); - --color-neutral: oklch(37% 0.044 257.287); - --color-neutral-content: oklch(98% 0.003 247.858); - --color-info: oklch(58% 0.158 241.966); - --color-info-content: oklch(97% 0.013 236.62); - --color-success: oklch(60% 0.118 184.704); - --color-success-content: oklch(98% 0.014 180.72); - --color-warning: oklch(66% 0.179 58.318); - --color-warning-content: oklch(98% 0.022 95.277); - --color-error: oklch(58% 0.253 17.585); - --color-error-content: oklch(96% 0.015 12.422); - --radius-selector: 0.25rem; - --radius-field: 0.25rem; - --radius-box: 0.5rem; - --size-selector: 0.21875rem; - --size-field: 0.21875rem; - --border: 1.5px; - --depth: 1; + --color-base-100: oklch(23% 0.016 262); + --color-base-200: oklch(19% 0.015 262); + --color-base-300: oklch(29% 0.018 262); + --color-base-content: oklch(94% 0.008 262); + --color-primary: oklch(75% 0.13 195); + --color-primary-content: oklch(18% 0.03 195); + --color-secondary: oklch(72% 0.12 300); + --color-secondary-content: oklch(18% 0.03 300); + --color-accent: oklch(80% 0.14 80); + --color-accent-content: oklch(22% 0.05 80); + --color-neutral: oklch(33% 0.02 262); + --color-neutral-content: oklch(94% 0.008 262); + --color-info: oklch(72% 0.14 230); + --color-info-content: oklch(18% 0.03 230); + --color-success: oklch(72% 0.15 160); + --color-success-content: oklch(18% 0.03 160); + --color-warning: oklch(78% 0.16 75); + --color-warning-content: oklch(22% 0.05 75); + --color-error: oklch(66% 0.2 22); + --color-error-content: oklch(98% 0.01 22); + --radius-selector: 9999px; + --radius-field: 0.75rem; + --radius-box: 1rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 0; --noise: 0; } +/* The light theme: the same hues on paper-white surfaces. */ @plugin "daisyui/packages/bundle/daisyui-theme" { name: "light"; - default: true; + default: false; prefersdark: false; color-scheme: "light"; - --color-base-100: oklch(98% 0 0); - --color-base-200: oklch(96% 0.001 286.375); - --color-base-300: oklch(92% 0.004 286.32); - --color-base-content: oklch(21% 0.006 285.885); - --color-primary: oklch(70% 0.213 47.604); - --color-primary-content: oklch(98% 0.016 73.684); - --color-secondary: oklch(55% 0.027 264.364); - --color-secondary-content: oklch(98% 0.002 247.839); - --color-accent: oklch(0% 0 0); - --color-accent-content: oklch(100% 0 0); - --color-neutral: oklch(44% 0.017 285.786); - --color-neutral-content: oklch(98% 0 0); - --color-info: oklch(62% 0.214 259.815); - --color-info-content: oklch(97% 0.014 254.604); - --color-success: oklch(70% 0.14 182.503); - --color-success-content: oklch(98% 0.014 180.72); - --color-warning: oklch(66% 0.179 58.318); - --color-warning-content: oklch(98% 0.022 95.277); - --color-error: oklch(58% 0.253 17.585); - --color-error-content: oklch(96% 0.015 12.422); - --radius-selector: 0.25rem; - --radius-field: 0.25rem; - --radius-box: 0.5rem; - --size-selector: 0.21875rem; - --size-field: 0.21875rem; - --border: 1.5px; - --depth: 1; + --color-base-100: oklch(99% 0.002 262); + --color-base-200: oklch(96% 0.004 262); + --color-base-300: oklch(91% 0.006 262); + --color-base-content: oklch(24% 0.02 262); + --color-primary: oklch(52% 0.12 195); + --color-primary-content: oklch(98% 0.01 195); + --color-secondary: oklch(52% 0.14 300); + --color-secondary-content: oklch(98% 0.01 300); + --color-accent: oklch(62% 0.15 75); + --color-accent-content: oklch(98% 0.01 75); + --color-neutral: oklch(90% 0.006 262); + --color-neutral-content: oklch(24% 0.02 262); + --color-info: oklch(55% 0.16 230); + --color-info-content: oklch(98% 0.01 230); + --color-success: oklch(55% 0.15 160); + --color-success-content: oklch(98% 0.01 160); + --color-warning: oklch(62% 0.16 75); + --color-warning-content: oklch(98% 0.01 75); + --color-error: oklch(58% 0.22 22); + --color-error-content: oklch(98% 0.01 22); + --radius-selector: 9999px; + --radius-field: 0.75rem; + --radius-box: 1rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 0; --noise: 0; } @@ -104,4 +126,28 @@ /* Make LiveView wrapper divs transparent for layout */ [data-phx-session], [data-phx-teleported-src] { display: contents } -/* This file is for your main application CSS */ +/* Rendered markdown (TrinityWeb.Markdown) inside a message. Preflight strips the browser's + defaults, so the block elements get theirs back here, in the theme's colours. */ +.md > * + * { margin-top: 0.5rem; } +.md p { line-height: 1.6; } +.md h1, .md h2, .md h3 { font-weight: 600; line-height: 1.3; margin-top: 0.75rem; } +.md h1 { font-size: 1.25rem; } +.md h2 { font-size: 1.125rem; } +.md h3 { font-size: 1rem; } +.md ul { list-style: disc; padding-left: 1.25rem; } +.md ol { list-style: decimal; padding-left: 1.25rem; } +.md li + li { margin-top: 0.125rem; } +.md a { color: var(--color-primary); text-decoration: underline; text-underline-offset: 2px; } +.md code { font-family: var(--font-mono); font-size: 0.875em; background: var(--color-base-300); padding: 0.1em 0.35em; border-radius: 0.375rem; } +.md pre { background: var(--color-base-300); border-radius: var(--radius-field); padding: 0.75rem 1rem; overflow-x: auto; } +.md pre code { background: none; padding: 0; font-size: 0.85em; } +.md blockquote { border-left: 3px solid var(--color-secondary); padding-left: 0.75rem; opacity: 0.85; } +.md table { border-collapse: collapse; font-size: 0.9em; } +.md th, .md td { border: 1px solid var(--color-base-300); padding: 0.25rem 0.6rem; text-align: left; } +.md th { background: var(--color-base-300); font-weight: 600; } +.md hr { border: 0; border-top: 1px solid var(--color-base-300); } +.md strong { font-weight: 600; } + +/* The streaming cursor at the end of an in-progress message. */ +.md-streaming::after { content: "▍"; color: var(--color-primary); animation: trinity-blink 1s steps(2) infinite; } +@keyframes trinity-blink { to { opacity: 0; } } diff --git a/assets/js/app.js b/assets/js/app.js index 9456a75..dd02f12 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -27,13 +27,14 @@ import {LiveSocket} from "phoenix_live_view" import {hooks as colocatedHooks} from "phoenix-colocated/trinity" import topbar from "../vendor/topbar" import { TauriHook } from "../vendor/ex_tauri" +import { Composer, Shortcuts, ScrollToBottom, LocalTime } from "./hooks" const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: {_csrf_token: csrfToken}, - hooks: { TauriHook,...colocatedHooks}, + hooks: { TauriHook, Composer, Shortcuts, ScrollToBottom, LocalTime, ...colocatedHooks}, }) // Show progress bar on live navigation and form submits diff --git a/assets/js/hooks.js b/assets/js/hooks.js new file mode 100644 index 0000000..ac11358 --- /dev/null +++ b/assets/js/hooks.js @@ -0,0 +1,90 @@ +/* SPDX-FileCopyrightText: Sudo Apt Holdings LLC */ +/* SPDX-License-Identifier: Apache-2.0 */ +// The chat's four hooks (slice 013). Each is a few lines and does one thing the server +// cannot: read a key with its modifiers, keep the message list pinned to the bottom, show a +// time in the viewer's zone, or tell Enter from Shift+Enter. + +// Enter sends, Shift+Enter breaks the line; the server clears the box after a send. +export const Composer = { + mounted() { + this.el.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { + e.preventDefault() + this.el.form.requestSubmit() + } + }) + this.el.addEventListener("input", () => this.grow()) + this.handleEvent("composer:clear", () => { + this.el.value = "" + this.grow() + this.el.focus() + }) + this.el.focus() + }, + updated() { + if (!this.el.disabled) this.el.focus() + }, + grow() { + this.el.style.height = "auto" + this.el.style.height = Math.min(this.el.scrollHeight, 192) + "px" + }, +} + +// Ctrl/Cmd+K opens a new session, Esc cancels the turn. Nothing else reaches the server. +export const Shortcuts = { + mounted() { + this.onKey = (e) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + e.preventDefault() + this.pushEvent("new_session", {}) + } else if (e.key === "Escape") { + this.pushEvent("cancel", {}) + } + } + window.addEventListener("keydown", this.onKey) + }, + destroyed() { + window.removeEventListener("keydown", this.onKey) + }, +} + +// The message list follows new content unless the reader has scrolled up to read. +export const ScrollToBottom = { + mounted() { + this.pinned = true + this.el.addEventListener("scroll", () => { + const gap = this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight + this.pinned = gap < 48 + }) + this.scroll() + this.observer = new MutationObserver(() => this.scroll()) + this.observer.observe(this.el, {childList: true, subtree: true, characterData: true}) + }, + updated() { + this.scroll() + }, + destroyed() { + if (this.observer) this.observer.disconnect() + }, + scroll() { + if (this.pinned) this.el.scrollTop = this.el.scrollHeight + }, +} + +// A