samagotchi 0.42.0 → 0.43.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.
@@ -27,27 +27,71 @@ module Samagotchi
27
27
  # +name+ and +cmd+ (+hint+, +every_seconds+). A source of that name
28
28
  # already attached stays as it is. One the user removed from the
29
29
  # session (its name, or its URL) isn't attached again (#declined?)
30
- # until the user adds it again.
30
+ # until the user adds it again, or +force+: the user's own explicit
31
+ # choice (a click), which clears the declined marker (by the name,
32
+ # and by the URL).
31
33
  # @return [String, nil] the source's name; nil when declined
32
34
  # @raise [Error]
33
- def attach(url: nil, name: nil, cmd: nil, why: nil, hint: nil, every_seconds: nil)
34
- own = location or raise Error, "this session has no id yet"
35
+ def attach(url: nil, name: nil, cmd: nil, why: nil, hint: nil, every_seconds: nil, force: false)
36
+ own = own_location!
35
37
  source = url ? from_url(url, name: name, why: why) : from_cmd(name: name, cmd: cmd, why: why, hint: hint, every_seconds: every_seconds)
36
- return nil if own.declined?(source.name) || (url && own.declined_hints.include?(source.hint))
38
+ if force
39
+ own.undecline(source.name, hint: url && source.hint)
40
+ elsif own.declined?(source.name) || (url && own.declined_hints.include?(source.hint))
41
+ return nil
42
+ end
37
43
  return source.name if own.source(source.name)
38
44
 
39
- own.add(source.with(scope: own.scope))
40
- source.name
45
+ add(own, source)
41
46
  rescue ContextSources::Invalid, ContextProviders::Invalid => e
42
47
  raise Error, e.message
43
48
  end
44
49
 
45
- # Whether the user removed +name+ from this session (so #attach skips it).
46
- def declined?(name)
47
- own = location
48
- own ? own.declined?(name) : false
50
+ # Marks +name+ (or +url+'s source, through its provider) declined in
51
+ # this session, as the user's removal does: #attach skips it until the
52
+ # user adds it again. An attached source stays (chi context rm).
53
+ # @return [String] the name
54
+ # @raise [Error]
55
+ def decline(url: nil, name: nil)
56
+ own = own_location!
57
+ if url
58
+ resolved = ContextProviders.resolve(url) or raise Error, "no installed bundle resolves #{url}"
59
+ name = name ? ContextSources.check_name!(name) : resolved.name
60
+ hint = resolved.hint
61
+ else
62
+ raise Error, "give url: or name:" if name.to_s.empty?
63
+
64
+ name = ContextSources.check_name!(name)
65
+ end
66
+ own.decline(name, hint)
67
+ name
68
+ rescue ContextSources::Invalid, ContextProviders::Invalid => e
69
+ raise Error, e.message
70
+ end
71
+
72
+ # Whether the user removed +name+ (or +url+) from this session (so
73
+ # #attach skips it).
74
+ def declined?(name = nil, url: nil)
75
+ own = location or return false
76
+ name_declined?(own, name) || (!url.to_s.empty? && own.declined_hints.include?(url))
77
+ end
78
+
79
+ # Records that this plugin offered +name+ (+hint+: its URL; +why+: the
80
+ # reason an attach from the offer gives) here, so a worker restart
81
+ # doesn't offer it again.
82
+ # @return [ContextSources::Offer]
83
+ # @raise [Error]
84
+ def mark_offered(name, hint, why: nil)
85
+ own_location!.mark_offered(name, hint, why: why)
86
+ rescue ContextSources::Invalid => e
87
+ raise Error, e.message
88
+ end
89
+
90
+ # @return [ContextSources::Offer, nil] the offer #mark_offered recorded
91
+ def offered(name)
92
+ location&.offered(name)
49
93
  rescue ContextSources::Invalid
50
- false
94
+ nil
51
95
  end
52
96
 
53
97
  # This session's sources (its own, then its project's).
@@ -69,6 +113,26 @@ module Samagotchi
69
113
 
70
114
  def state_dir = @host.state_dir&.call || Session.default_state_dir
71
115
 
116
+ # Two attaches at once (a double click): the one that lost the race
117
+ # finds the source there and returns its name too.
118
+ def add(own, source)
119
+ own.add(source.with(scope: own.scope))
120
+ source.name
121
+ rescue ContextSources::Invalid
122
+ raise unless own.source(source.name)
123
+
124
+ source.name
125
+ end
126
+
127
+ # An invalid name isn't declined (and doesn't hide the URL's check).
128
+ def name_declined?(own, name)
129
+ !name.nil? && own.declined?(name)
130
+ rescue ContextSources::Invalid
131
+ false
132
+ end
133
+
134
+ def own_location! = location || raise(Error, "this session has no id yet")
135
+
72
136
  def location
73
137
  id = @host.session_id.call
74
138
  id && ContextSources.session_location(id, state_dir: state_dir)
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require_relative "tools/args"
5
5
  require_relative "llm_context_strategy"
6
+ require_relative "tools/memory"
6
7
 
7
8
  module Samagotchi
8
9
  # Tool-declaration constants, protocol constants, and guidance text.
@@ -146,7 +147,7 @@ module Samagotchi
146
147
  },
147
148
  description: {
148
149
  type: "string",
149
- description: "Optional one-line description (at most 200 characters) appended to the managed index line; without content, it changes only that line"
150
+ description: "Optional one-line description (at most #{Tools::MemoryWrite::DESCRIPTION_LIMIT} characters) appended to the managed index line; without content, it changes only that line"
150
151
  },
151
152
  current_model_only: {
152
153
  type: "boolean",
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.42.0"
4
+ VERSION = "0.43.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -20,6 +20,7 @@ require_relative "../session_metrics"
20
20
  require_relative "../session_commands"
21
21
  require_relative "../steer"
22
22
  require_relative "../host_registry"
23
+ require_relative "../llm_context_override"
23
24
  require_relative "../model_catalog"
24
25
  require_relative "../model_profile"
25
26
  require_relative "../sampling_settings"
@@ -458,7 +459,8 @@ module Samagotchi
458
459
  end
459
460
 
460
461
  # Each model's configured sampling as /model words it ("temperature=0.6
461
- # (hosts.work)"), on the models that have some: the picker's tooltip.
462
+ # (hosts.work)"), on the models that have some: the picker's tooltip;
463
+ # and each model's LLM context (LLMContextStrategy::Explained#summary).
462
464
  # A config that can't be read leaves the list as it is.
463
465
  def add_sampling(models, registry)
464
466
  return if registry.nil? || models.empty?
@@ -466,14 +468,23 @@ module Samagotchi
466
468
  settings = ConfigFile.model_settings
467
469
  models.each do |model|
468
470
  target = registry.resolve(model[:name])
469
- summary = SamplingSettings.summary(target, names: registry.lookup_names(model[:name], target: target),
470
- models: settings)
471
+ names = registry.lookup_names(model[:name], target: target)
472
+ summary = SamplingSettings.summary(target, names: names, models: settings)
471
473
  model[:sampling] = summary if summary
474
+ add_llm_context(model, target, names, settings)
472
475
  end
473
476
  rescue StandardError
474
477
  nil
475
478
  end
476
479
 
480
+ # The LLM context +model+ starts under (the start page's llm ctx chip);
481
+ # one that can't be read leaves this model without, not the others.
482
+ def add_llm_context(model, target, names, settings)
483
+ model[:llm_context] = LLMContextStrategy.explain(target, names: names, models: settings).summary(nil)
484
+ rescue StandardError
485
+ nil
486
+ end
487
+
477
488
  # A session's LLM context strategy, apply rule and budget, each with
478
489
  # where it came from (LLMContextStrategy::Explained#summary), as its
479
490
  # worker would resolve them; nil when they can't be.
@@ -563,6 +574,12 @@ module Samagotchi
563
574
  dir, error = scope_dir(body["dir"])
564
575
  return error if error
565
576
 
577
+ # llm_context: the session's own LLM context values before its first
578
+ # turn (the start page's llm ctx chip; chi --llm-context), read
579
+ # before anything spawns so a bad one spawns nothing.
580
+ llm_context, error = request_llm_context(body["llm_context"])
581
+ return error if error
582
+
566
583
  folder = dir ? { working_directory: dir } : {}
567
584
  # model: the model the session starts on (the start page's picker,
568
585
  # spelled as GET /api/models lists it); blank means the default.
@@ -573,6 +590,7 @@ module Samagotchi
573
590
  # until that turn is saved.
574
591
  preview = idle ? body["preview"].to_s.strip : ""
575
592
  folder[:title] = preview unless preview.empty?
593
+ folder[:llm_context] = llm_context if llm_context
576
594
  begin
577
595
  session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
578
596
  rescue ModelProfile::MissingModel => e
@@ -588,6 +606,25 @@ module Samagotchi
588
606
  json_response(201, session_to_json(session).merge(bridge_port: port))
589
607
  end
590
608
 
609
+ # The create request's "llm_context" ({strategy:, apply:, budget:},
610
+ # each a word as /llm-context takes it, "default" for unset) as an
611
+ # override: [override, nil] (nil when absent or all default), or
612
+ # [nil, a 400 response] for anything else.
613
+ def request_llm_context(raw)
614
+ return [nil, nil] if raw.nil?
615
+
616
+ fields = LLMContextOverride::COMMAND_WORDS.invert
617
+ unless raw.is_a?(Hash) && raw.all? { |key, word| fields.key?(key) && word.is_a?(String) }
618
+ return [nil, error_response(400, "invalid_llm_context",
619
+ "llm_context is an object of #{fields.keys.join(", ")} words")]
620
+ end
621
+
622
+ override = LLMContextOverride.update(nil, raw.to_h { |key, word| [fields.fetch(key), word] })
623
+ [override.empty? ? nil : override, nil]
624
+ rescue ArgumentError => e
625
+ [nil, error_response(400, "invalid_llm_context", e.message)]
626
+ end
627
+
591
628
  def handle_show(req, id)
592
629
  # The page's light re-reads: never the whole conversation.
593
630
  return handle_cards_read(id) if req.params["cards"] == "1"
@@ -21,7 +21,10 @@ import {
21
21
  addContext,
22
22
  } from "./data.js";
23
23
  import { createContextChips } from "./context_chips.js";
24
- import { chipHtml as llmContextChipHtml, createLlmContextControl } from "./llm_context_chip.js";
24
+ import {
25
+ chipHtml as llmContextChipHtml, chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_CHAT,
26
+ withChoice,
27
+ } from "./llm_context_chip.js";
25
28
  import { renderStable } from "./stable_html.js";
26
29
  import { createShownTexts } from "./shown_texts.js";
27
30
  import { MODEL_KEY } from "./model_pick.js";
@@ -39,7 +42,7 @@ import { parsePresets } from "./annotate_presets.js";
39
42
  import { createAnnotateBar } from "./annotate_bar.js";
40
43
  import { COPY_SOURCE_ATTR, copySource, copyText, installCopy, showsAnswer } from "./copy.js";
41
44
  import { routeChunk } from "./chunk_router.js";
42
- import { extractCtxPct, savedCtxPct, cardCtxText, generationTokens, lastSpeedText, speedText, tokensTipText } from "./ctx.js";
45
+ import { extractCtxPct, savedCtxPct, cardCtxText, ctxBarText, generationTokens, lastSpeedText, speedText, tokensTipText } from "./ctx.js";
43
46
  import { createQuestionCards } from "./question_cards.js";
44
47
  import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, shownElsewhere, splitSnapshotCards, turnNoticePlace } from "./card.js";
45
48
  import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
@@ -86,6 +89,36 @@ const llmContextControl = createLlmContextControl({
86
89
  running: () => liveTurn.running,
87
90
  run: (line) => runSessionCommand(line),
88
91
  });
92
+ // The start page's LLM context chip, beside the model picker: the picked
93
+ // model's values, and a choice for the next new chat only (sent with its
94
+ // create, then dropped; never remembered: a reload or opening a session
95
+ // drops it too, a failed create keeps it).
96
+ const llmContextNewEl = $("#llmContextNew");
97
+ let newChatLlmContext = null;
98
+ const newChatLlmSummary = () => (modelPickEl.hidden ? null : modelPicker.chosenRow()?.llm_context || null);
99
+ const llmContextNewControl = createLlmContextControl({
100
+ el: llmContextNewEl,
101
+ escapeHtml,
102
+ summary: newChatLlmSummary,
103
+ kind: NEW_CHAT,
104
+ choice: () => newChatLlmContext,
105
+ choose: (next) => {
106
+ newChatLlmContext = next;
107
+ renderNewChatLlmContext();
108
+ promptEl.focus();
109
+ },
110
+ id: "llmContextNewPopover",
111
+ anchor: "#llmContextNew",
112
+ });
113
+ // Hidden without the model's values (the list not in, an older server).
114
+ function renderNewChatLlmContext() {
115
+ const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_CHAT });
116
+ llmContextNewEl.hidden = !chip;
117
+ if (!chip) return llmContextNewControl.close();
118
+ llmContextNewEl.textContent = chip.text;
119
+ llmContextNewEl.title = chip.title;
120
+ llmContextNewEl.classList.toggle("own", chip.own);
121
+ }
89
122
  const markdownWarningEl = $("#markdownWarning");
90
123
  // What the page renders that a notice may stand in for (the server's
91
124
  // Web::Capabilities, next to markdown_warning); a notice it names isn't
@@ -908,7 +941,15 @@ function updateComposerMode() {
908
941
  hadSession = !hadSession;
909
942
  fitPrompt();
910
943
  }
911
- if (selected) modelPicker.close();
944
+ if (selected) {
945
+ modelPicker.close();
946
+ llmContextNewControl.close();
947
+ // The start page's choice was for a new chat: opening a session drops it.
948
+ if (newChatLlmContext) {
949
+ newChatLlmContext = null;
950
+ renderNewChatLlmContext();
951
+ }
952
+ }
912
953
  const running = liveTurn.running && !!selected;
913
954
  cancelBtn.style.display = running ? "" : "none";
914
955
  if (running) {
@@ -1121,7 +1162,10 @@ function renderSessionView(data) {
1121
1162
  // next turn (a running turn's replay below streams over it).
1122
1163
  const savedPct = savedCtxPct(data.timing?.context);
1123
1164
  if (savedPct !== null) current.ctxPct = savedPct;
1124
- if (data.timing?.context?.window_tokens) current.ctxWindow = data.timing.context.window_tokens;
1165
+ if (data.timing?.context?.window_tokens) {
1166
+ current.ctxWindow = data.timing.context.window_tokens;
1167
+ current.ctxWindowSource = data.timing.context.window_source || null;
1168
+ }
1125
1169
  current.firstPreview = s.first_preview || previewForCard(s);
1126
1170
  current.usedMemories = s.used_memory_names || [];
1127
1171
  current.preloadedMemories = s.preloaded_memory_names || [];
@@ -1766,9 +1810,10 @@ function updateInfoBar() {
1766
1810
  if (stopBtn) stopBtn.style.display = canStopSession({ owner: current.owner, streaming: !!streamControl }) ? "" : "none";
1767
1811
  const idShort = selected.slice(0,8);
1768
1812
  const memText = current.usedMemories.length ? current.usedMemories.join(", ") : "—";
1769
- const ctxText = current.ctxPct !== null ? `ctx ${Math.round(current.ctxPct)}%` : "";
1813
+ const ctxText = ctxBarText(current.ctxPct, current.ctxWindowSource);
1770
1814
  const tokens = current.timing.tokens;
1771
- const ctxTip = tokensTipText(tokens, undefined, current.timing.memoryIndex);
1815
+ const ctxTip = tokensTipText(tokens, undefined, current.timing.memoryIndex,
1816
+ { tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct });
1772
1817
  const speedTextNow = lastSpeedText(tokens);
1773
1818
  const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
1774
1819
  const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
@@ -1939,7 +1984,10 @@ const streamHandlers = {
1939
1984
  }
1940
1985
  },
1941
1986
  generation_started: (data) => {
1942
- if (data?.context_window_tokens) current.ctxWindow = data.context_window_tokens;
1987
+ if (data?.context_window_tokens) {
1988
+ current.ctxWindow = data.context_window_tokens;
1989
+ current.ctxWindowSource = data.context_window_source || null;
1990
+ }
1943
1991
  liveTurn.timing.setNote("");
1944
1992
  turnDom.generationStarted(data || {});
1945
1993
  },
@@ -2226,7 +2274,11 @@ async function handleSendTurn() {
2226
2274
  }
2227
2275
  actionBtn.disabled = true;
2228
2276
  try {
2229
- const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt });
2277
+ const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt,
2278
+ llm_context: choiceWords(newChatLlmContext) });
2279
+ // The choice was for this chat only.
2280
+ newChatLlmContext = null;
2281
+ renderNewChatLlmContext();
2230
2282
  addCreatedSession(s);
2231
2283
  await select(s.id);
2232
2284
  } catch (e) {
@@ -2439,7 +2491,10 @@ const modelPicker = createModelPicker({
2439
2491
  list: $("#modelList"),
2440
2492
  composer: $("#composer"),
2441
2493
  center: $("#center"),
2442
- onPick: () => promptEl.focus(),
2494
+ onPick: () => {
2495
+ renderNewChatLlmContext();
2496
+ promptEl.focus();
2497
+ },
2443
2498
  });
2444
2499
  async function loadModels() {
2445
2500
  let payload;
@@ -2453,6 +2508,7 @@ async function loadModels() {
2453
2508
  if (!modelPicker.setModels(payload, stored)) return;
2454
2509
  modelPickEl.title = payload.warning ? `The model the new chat starts on · ${payload.warning}` : "The model the new chat starts on";
2455
2510
  modelPickEl.hidden = false;
2511
+ renderNewChatLlmContext();
2456
2512
  }
2457
2513
  // The picker's choice for a create request; nothing (the server's default)
2458
2514
  // while the list is not in.
@@ -91,14 +91,39 @@ export function memoryIndexText(memoryIndex) {
91
91
  return `~${countK(total)} tokens in this session's prompt (${scopes.map(([scope, tokens]) => `${scope} ${countK(tokens)}`).join(", ")})`;
92
92
  }
93
93
 
94
+ // The info bar's ctx: "ctx 12%", "ctx ~12%" when the window is chi's
95
+ // default (no server, model list or setting gave one, so the percent is of
96
+ // a guess); "" without a pct.
97
+ export function ctxBarText(pct, windowSource = null) {
98
+ if (num(pct) === null) return "";
99
+ return `ctx ${windowSource === "default" ? "~" : ""}${Math.round(pct)}%`;
100
+ }
101
+
102
+ // The ctx tooltip's window line ({tokens:, source:, pct:} the session's
103
+ // window, where it came from and the meter's value): "context: ~41.0k of
104
+ // 128.0k tokens (server)", as /stats names the source; "" without a window.
105
+ export function ctxWindowText(window) {
106
+ const tokens = num(window?.tokens);
107
+ if (!tokens || tokens <= 0) return "";
108
+ const pct = num(window.pct);
109
+ const used = pct === null ? "" : `~${countK((pct / 100) * tokens)} of `;
110
+ const source = window.source === "default"
111
+ ? "chi's default, a guess: set window_tokens for this model"
112
+ : window.source;
113
+ return `context: ${used}${countK(tokens)} tokens${source ? ` (${source})` : ""}`;
114
+ }
115
+
94
116
  // The ctx tooltip (info bar and card): +title+, then the session's tokens
95
117
  // (in summed over every request, cached with its share, out with the
96
118
  // reasoning part) and its cost, when it has any, and the memory indexes
97
- // its prompt holds (+memoryIndex+, the snapshot's memory_index). A
119
+ // its prompt holds (+memoryIndex+, the snapshot's memory_index); with
120
+ // +window+ (ctxWindowText's) the window line under the title. A
98
121
  // delegated session keeps its own metrics, so these are this session's only.
99
- export function tokensTipText(tokens, title = "Context used after the last turn", memoryIndex = null) {
122
+ export function tokensTipText(tokens, title = "Context used after the last turn", memoryIndex = null, window = null) {
100
123
  const memory = memoryIndexText(memoryIndex);
101
124
  const memoryLine = memory ? `\nmemory index: ${memory}` : "";
125
+ const windowText = ctxWindowText(window);
126
+ if (windowText) title = `${title}\n${windowText}`;
102
127
  const prompt = num(tokens?.prompt_sum) || 0;
103
128
  const completion = num(tokens?.completion_sum) || 0;
104
129
  if (!prompt && !completion) return `${title}${memoryLine}`;
@@ -30,9 +30,12 @@ export function listSessions(sort, order, { dir, ...opts } = {}) {
30
30
  // as a turn (with its images, once they are uploaded). dir: the folder the
31
31
  // chat starts in; model: the model it starts on (as GET /api/models spells
32
32
  // it); either left out means the server's default. preview: that message,
33
- // which names the session until its turn is saved.
34
- export function createIdleSession({ dir, model, preview, ...opts } = {}) {
35
- const body = { idle: true, ...(dir ? { dir } : {}), ...(model ? { model } : {}), ...(preview ? { preview } : {}) };
33
+ // which names the session until its turn is saved. llm_context: the
34
+ // session's own LLM context words ({strategy, apply, budget}), when the
35
+ // start page's chip set some.
36
+ export function createIdleSession({ dir, model, preview, llm_context: llmContext, ...opts } = {}) {
37
+ const body = { idle: true, ...(dir ? { dir } : {}), ...(model ? { model } : {}), ...(preview ? { preview } : {}),
38
+ ...(llmContext ? { llm_context: llmContext } : {}) };
36
39
  return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
37
40
  }
38
41
 
@@ -491,6 +491,13 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
491
491
  .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}
492
492
  .model-pick[aria-expanded="true"]::after{transform:translateY(-20%) rotate(225deg)}
493
493
  @media(max-width:600px){#composer .actions-col .model-pick{max-width:60%}}
494
+ /* The new chat's LLM context chip, after the picker: as quiet as it; accent once a choice is set (the info bar chip's .own). */
495
+ #composer .actions-col .llmctx-new{order:0;flex:0 1 auto;min-width:0;max-width:min(40%,260px);height:24px;min-height:24px;line-height:22px;padding:0 6px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--text-muted);font-size:11px;font-weight:400;overflow:hidden;text-overflow:ellipsis}
496
+ #composer .actions-col .llmctx-new:hover{filter:none;color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
497
+ #composer .actions-col .llmctx-new.own{color:var(--accent);border-color:var(--accent-border)}
498
+ #composer .actions-col .llmctx-new[hidden],body.has-session #composer .actions-col .llmctx-new{display:none}
499
+ /* Phones: picker · chip · Start on one row (the key hint is gone there); names cut short rather than wrapping, the chip first. */
500
+ @media(max-width:600px){#composer .actions-col:has(.llmctx-new:not([hidden])){flex-wrap:nowrap}#composer .actions-col .model-pick{flex:0 1 auto;min-width:0}#composer .actions-col .llmctx-new{max-width:45%;flex-shrink:4}}
494
501
  /* The model search: opaque like #commandList (a list over the conversation
495
502
  must stay readable), above the composer, or below it (.down) when the
496
503
  start page leaves no room above, or over the composer (.over) when
@@ -1005,6 +1012,7 @@ a{color:var(--accent)}
1005
1012
  </div>
1006
1013
  <div class="actions-col">
1007
1014
  <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>
1015
+ <button type="button" id="llmContextNew" class="llmctx-new" aria-haspopup="dialog" hidden></button>
1008
1016
  <div id="composeHint">Enter to start · Shift+Enter for a newline</div>
1009
1017
  <button id="cancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Cancel</button>
1010
1018
  <div class="split">