samagotchi 0.79.0 → 0.81.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 +59 -1
- data/README.md +7 -5
- data/docs/architecture.md +9 -1
- data/docs/cli.md +32 -7
- data/docs/configuration.md +10 -4
- data/docs/guardrails.md +26 -3
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +4 -0
- data/docs/internals/engine.md +13 -3
- data/docs/plugins.md +149 -3
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +9 -1
- data/docs/testing.md +51 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
- data/lib/samagotchi/bootstrap/probe.rb +153 -31
- data/lib/samagotchi/bootstrap_command.rb +93 -16
- data/lib/samagotchi/bridge.rb +1 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/continue_offer.rb +5 -5
- data/lib/samagotchi/engine.rb +68 -39
- data/lib/samagotchi/guardrails/approval.rb +6 -4
- data/lib/samagotchi/guardrails/data_readers.rb +214 -0
- data/lib/samagotchi/guardrails/gate.rb +7 -3
- data/lib/samagotchi/guardrails/rules.rb +4 -2
- data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
- data/lib/samagotchi/guardrails/targets.rb +14 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
- data/lib/samagotchi/hooks/registry.rb +44 -3
- data/lib/samagotchi/host_config.rb +8 -8
- data/lib/samagotchi/host_registry.rb +12 -4
- data/lib/samagotchi/idle_client.rb +41 -11
- data/lib/samagotchi/idle_recap.rb +6 -1
- data/lib/samagotchi/idle_target.rb +12 -4
- data/lib/samagotchi/llm/adapter_common.rb +4 -2
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
- data/lib/samagotchi/llm/http.rb +12 -7
- data/lib/samagotchi/llm/usage.rb +9 -3
- data/lib/samagotchi/model_price.rb +16 -0
- data/lib/samagotchi/plugin/card.rb +44 -0
- data/lib/samagotchi/plugin/context.rb +61 -9
- data/lib/samagotchi/plugin/http.rb +154 -0
- data/lib/samagotchi/plugin/http_transport.rb +77 -0
- data/lib/samagotchi/plugin/loader.rb +29 -10
- data/lib/samagotchi/plugin/service.rb +24 -1
- data/lib/samagotchi/question_answer.rb +29 -0
- data/lib/samagotchi/question_desk.rb +20 -19
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/relay_verifier.rb +1 -1
- data/lib/samagotchi/session_metrics/side_totals.rb +110 -0
- data/lib/samagotchi/session_metrics.rb +46 -12
- data/lib/samagotchi/session_observer.rb +3 -0
- data/lib/samagotchi/side_usage.rb +38 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
- data/lib/samagotchi/testing/boundary.rb +156 -0
- data/lib/samagotchi/testing/bundle.rb +349 -0
- data/lib/samagotchi/testing/fake_executable.rb +42 -0
- data/lib/samagotchi/testing/fake_http.rb +137 -0
- data/lib/samagotchi/testing/helpers.rb +56 -0
- data/lib/samagotchi/testing/host.rb +76 -0
- data/lib/samagotchi/testing/minitest.rb +31 -0
- data/lib/samagotchi/testing/recorder.rb +141 -0
- data/lib/samagotchi/testing/recording_api.rb +27 -0
- data/lib/samagotchi/testing/rspec.rb +31 -0
- data/lib/samagotchi/testing/sandbox.rb +165 -0
- data/lib/samagotchi/testing/sessions.rb +77 -0
- data/lib/samagotchi/testing/verdict.rb +28 -0
- data/lib/samagotchi/testing/waiting.rb +36 -0
- data/lib/samagotchi/testing.rb +26 -0
- data/lib/samagotchi/tools/delegate_relay.rb +7 -6
- data/lib/samagotchi/tools/tool_index.rb +3 -0
- data/lib/samagotchi/turn_state.rb +28 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +4 -1
- data/lib/samagotchi/web/public/app.js +47 -23
- data/lib/samagotchi/web/public/ctx.js +32 -6
- data/lib/samagotchi/web/public/images.js +33 -0
- data/lib/samagotchi/web/public/index.html +71 -38
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/level_chip.js +11 -11
- data/lib/samagotchi/web/public/lists_view.js +20 -15
- data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
- data/lib/samagotchi/web/public/model_picker.js +1 -1
- data/lib/samagotchi/web/public/scope.js +3 -3
- data/lib/samagotchi/web/public/stream_handlers.js +7 -0
- data/lib/samagotchi/web/public/strip.js +35 -9
- data/lib/samagotchi/web/public/strip_grip.js +76 -0
- data/lib/samagotchi/web/public/thinking_chip.js +5 -5
- data/lib/samagotchi/web/public/timing.js +6 -1
- data/lib/samagotchi/web/session_summary.rb +2 -0
- metadata +24 -1
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -1798,7 +1798,10 @@ module Samagotchi
|
|
|
1798
1798
|
# model_segments; none with one model, or from an older file).
|
|
1799
1799
|
model_segments: Array(source["model_segments"]),
|
|
1800
1800
|
# The memory indexes the session's prompt holds (the ctx tooltip).
|
|
1801
|
-
memory_index: source["memory_index"]
|
|
1801
|
+
memory_index: source["memory_index"],
|
|
1802
|
+
# The side requests (recap, /btw, ctx.ask_model), apart from the
|
|
1803
|
+
# tokens: the ctx tooltip's own line (nil without any).
|
|
1804
|
+
side: source["side"]
|
|
1802
1805
|
}
|
|
1803
1806
|
end
|
|
1804
1807
|
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
} from "./data.js";
|
|
25
25
|
import { createContextChips } from "./context_chips.js";
|
|
26
26
|
import {
|
|
27
|
-
chipModel as llmContextChipModel, choiceWords, createLlmContextControl,
|
|
27
|
+
chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_SESSION,
|
|
28
28
|
withChoice,
|
|
29
29
|
} from "./llm_context_chip.js";
|
|
30
30
|
import {
|
|
@@ -38,7 +38,7 @@ import { createShownTexts } from "./shown_texts.js";
|
|
|
38
38
|
import { MODEL_KEY } from "./model_pick.js";
|
|
39
39
|
import { emptySessionState, sessionStateFrom } from "./session_state.js";
|
|
40
40
|
import { createModelPicker } from "./model_picker.js";
|
|
41
|
-
import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
|
|
41
|
+
import { admitImages, chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
|
|
42
42
|
import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, cantText, withLiveStatus, withoutWorker } from "./format.js";
|
|
43
43
|
import { createTurnView, turnHistoryNodes } from "./turn_view.js";
|
|
44
44
|
import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
|
|
@@ -66,6 +66,7 @@ import { scopeDir, scopeTemplate } from "./scope.js";
|
|
|
66
66
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
67
67
|
import { easeScrollTo, FOLLOW_MS, shouldFollowScroll } from "./scroll.js";
|
|
68
68
|
import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
|
|
69
|
+
import { createStripGrip } from "./strip_grip.js";
|
|
69
70
|
import { waitingOn } from "./notify.js";
|
|
70
71
|
import { applySessionEvent, inTree, sortedByUpdated } from "./sessions_list.js";
|
|
71
72
|
import { createNotifications } from "./notifications.js";
|
|
@@ -121,7 +122,7 @@ const llmContextControl = createLlmContextControl({
|
|
|
121
122
|
run: (line) => runSessionCommand(line),
|
|
122
123
|
});
|
|
123
124
|
// The start page's LLM context chip, beside the model picker: the picked
|
|
124
|
-
// model's values, and a choice for the next new
|
|
125
|
+
// model's values, and a choice for the next new session only (sent with its
|
|
125
126
|
// create, then dropped; never remembered: a reload or opening a session
|
|
126
127
|
// drops it too, a failed create keeps it).
|
|
127
128
|
const llmContextNewEl = $("#llmContextNew");
|
|
@@ -130,7 +131,7 @@ const newChatLlmSummary = () => (selected || modelPickEl.hidden ? null : modelPi
|
|
|
130
131
|
const llmContextNewControl = createLlmContextControl({
|
|
131
132
|
el: llmContextNewEl,
|
|
132
133
|
summary: newChatLlmSummary,
|
|
133
|
-
kind:
|
|
134
|
+
kind: NEW_SESSION,
|
|
134
135
|
choice: () => newChatLlmContext,
|
|
135
136
|
choose: (next) => {
|
|
136
137
|
newChatLlmContext = next;
|
|
@@ -159,14 +160,14 @@ const reasoningHistoryControl = createReasoningHistoryControl({
|
|
|
159
160
|
run: (line) => runSessionCommand(line),
|
|
160
161
|
});
|
|
161
162
|
// The start page's thinking chip: the picked model's level, and a level
|
|
162
|
-
// for the next new
|
|
163
|
+
// for the next new session only (sent with its create, then dropped).
|
|
163
164
|
const thinkingNewEl = $("#thinkingNew");
|
|
164
165
|
let newChatThinking = null;
|
|
165
166
|
const newChatThinkingSummary = () => (selected || modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
|
|
166
167
|
const thinkingNewControl = createThinkingControl({
|
|
167
168
|
el: thinkingNewEl,
|
|
168
169
|
summary: newChatThinkingSummary,
|
|
169
|
-
kind:
|
|
170
|
+
kind: NEW_SESSION,
|
|
170
171
|
choice: () => newChatThinking,
|
|
171
172
|
choose: (next) => {
|
|
172
173
|
newChatThinking = next;
|
|
@@ -177,7 +178,7 @@ const thinkingNewControl = createThinkingControl({
|
|
|
177
178
|
anchor: "#thinkingNew",
|
|
178
179
|
});
|
|
179
180
|
function renderNewChatThinking() {
|
|
180
|
-
const chip = thinkingChipModel(withThinkingChoice(newChatThinkingSummary(), newChatThinking), { kind:
|
|
181
|
+
const chip = thinkingChipModel(withThinkingChoice(newChatThinkingSummary(), newChatThinking), { kind: NEW_SESSION });
|
|
181
182
|
thinkingNewEl.hidden = !chip;
|
|
182
183
|
if (!chip) return thinkingNewControl.close();
|
|
183
184
|
thinkingNewEl.textContent = chip.text;
|
|
@@ -186,7 +187,7 @@ function renderNewChatThinking() {
|
|
|
186
187
|
}
|
|
187
188
|
// Hidden without the model's values (the list not in, an older server).
|
|
188
189
|
function renderNewChatLlmContext() {
|
|
189
|
-
const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind:
|
|
190
|
+
const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_SESSION });
|
|
190
191
|
llmContextNewEl.hidden = !chip;
|
|
191
192
|
if (!chip) return llmContextNewControl.close();
|
|
192
193
|
llmContextNewEl.textContent = chip.text;
|
|
@@ -205,6 +206,8 @@ let pageEditorUrl = null;
|
|
|
205
206
|
const promptEl = $("#prompt");
|
|
206
207
|
const actionBtn = $("#actionBtn");
|
|
207
208
|
const cancelBtn = $("#cancelBtn");
|
|
209
|
+
const attachBtn = $("#attachBtn");
|
|
210
|
+
const attachInput = $("#attachInput");
|
|
208
211
|
const infoCancelBtn = $("#infoCancelBtn");
|
|
209
212
|
const modelPickEl = $("#modelPick");
|
|
210
213
|
const composeHintEl = $("#composeHint");
|
|
@@ -357,7 +360,7 @@ const sentIds = new Set();
|
|
|
357
360
|
const earlyRestores = new Map();
|
|
358
361
|
|
|
359
362
|
// The page's scope (scope.js): ?dir= is the folder a project view came
|
|
360
|
-
// from (new
|
|
363
|
+
// from (new sessions start there); the server names its project, if any.
|
|
361
364
|
const scope = {
|
|
362
365
|
dir: scopeDir(location.search),
|
|
363
366
|
projectName: document.body.dataset.projectName || null,
|
|
@@ -467,7 +470,7 @@ function followSelectedSession(sess) {
|
|
|
467
470
|
|
|
468
471
|
// The header's scope chip: "<project> · all" in a project view (all links
|
|
469
472
|
// to the same place without ?dir); "all projects" otherwise, with where a
|
|
470
|
-
// new
|
|
473
|
+
// new session starts on the start page.
|
|
471
474
|
function renderScope() {
|
|
472
475
|
const el = $("#scope");
|
|
473
476
|
render(scopeTemplate(scope, { error: scopeError, where: () => ({ hash: location.hash, search: location.search }) }), el);
|
|
@@ -500,7 +503,6 @@ const lists = createListsView({
|
|
|
500
503
|
allViewOpen: () => allViewOpen(),
|
|
501
504
|
openAllView: () => openAllView(),
|
|
502
505
|
leaveAllView: (hash) => leaveAllView(hash),
|
|
503
|
-
setStripHidden: (hidden) => setStripHidden(hidden),
|
|
504
506
|
});
|
|
505
507
|
|
|
506
508
|
// Delete a session (the info bar's button, an all-sessions card's ✕) after
|
|
@@ -544,7 +546,7 @@ function closeAllView() {
|
|
|
544
546
|
leaveAllView(sessionHash(selected));
|
|
545
547
|
}
|
|
546
548
|
}
|
|
547
|
-
// Leave all sessions for a place of our own (a picked session, a new
|
|
549
|
+
// Leave all sessions for a place of our own (a picked session, a new session):
|
|
548
550
|
// replace its history entry, since history.back() would come back later
|
|
549
551
|
// with the session the view was opened from.
|
|
550
552
|
function leaveAllView(hash) {
|
|
@@ -620,7 +622,7 @@ function updateComposerMode() {
|
|
|
620
622
|
syncModelPick();
|
|
621
623
|
if (selected) {
|
|
622
624
|
llmContextNewControl.close();
|
|
623
|
-
// The start page's choice was for a new
|
|
625
|
+
// The start page's choice was for a new session: opening a session drops it.
|
|
624
626
|
if (newChatLlmContext) {
|
|
625
627
|
newChatLlmContext = null;
|
|
626
628
|
renderNewChatLlmContext();
|
|
@@ -633,6 +635,9 @@ function updateComposerMode() {
|
|
|
633
635
|
}
|
|
634
636
|
const running = liveTurn.running && !!selected;
|
|
635
637
|
cancelBtn.style.display = running ? "" : "none";
|
|
638
|
+
// No images for a session a terminal owns (it takes no message from here);
|
|
639
|
+
// on a phone none while a turn runs either: Stop turn needs the row's room.
|
|
640
|
+
attachBtn.hidden = (!!selected && current.owner === "tui") || (running && narrowScreen.matches);
|
|
636
641
|
syncDeliveryLine();
|
|
637
642
|
if (running) {
|
|
638
643
|
setPlaceholder("Running… a message goes in at the next step · Enter to send · Alt+Enter queues", "Steer the turn…");
|
|
@@ -1535,7 +1540,7 @@ async function handleCancel() {
|
|
|
1535
1540
|
|
|
1536
1541
|
// The chi logo: back to the empty start, where the composer makes a new
|
|
1537
1542
|
// session (from all sessions too).
|
|
1538
|
-
// The typed draft stays in the composer: paste in a session, then + new
|
|
1543
|
+
// The typed draft stays in the composer: paste in a session, then + new session
|
|
1539
1544
|
// starts from it (with the model picker). clearSelection leaves #prompt alone.
|
|
1540
1545
|
$("#newBtn").addEventListener("click", () => {
|
|
1541
1546
|
if (allViewOpen()) leaveAllView("");
|
|
@@ -1553,7 +1558,7 @@ $("#newBtn").addEventListener("click", () => {
|
|
|
1553
1558
|
// tooltip; a route that fails leaves the picker hidden and the server's
|
|
1554
1559
|
// default in charge. The search list itself is model_picker.js'; a pick
|
|
1555
1560
|
// sends the focus to the message box.
|
|
1556
|
-
const START_PICK_TITLE = "The model the new
|
|
1561
|
+
const START_PICK_TITLE = "The model the new session starts on";
|
|
1557
1562
|
let startPickTitle = START_PICK_TITLE;
|
|
1558
1563
|
// The fresh link the picker shows (null: the start page, or a session it
|
|
1559
1564
|
// is hidden in), and a link pick's /model still in flight (a send waits).
|
|
@@ -1624,7 +1629,7 @@ function syncModelPick() {
|
|
|
1624
1629
|
modelPickEl.setAttribute("aria-label", "Model this link starts on");
|
|
1625
1630
|
} else {
|
|
1626
1631
|
modelPickEl.title = startPickTitle;
|
|
1627
|
-
modelPickEl.setAttribute("aria-label", "Model for the new
|
|
1632
|
+
modelPickEl.setAttribute("aria-label", "Model for the new session");
|
|
1628
1633
|
}
|
|
1629
1634
|
}
|
|
1630
1635
|
// Leaving a session: the start page's remembered model again (no fetch).
|
|
@@ -1680,9 +1685,14 @@ function clearChips() {
|
|
|
1680
1685
|
setChips([]);
|
|
1681
1686
|
}
|
|
1682
1687
|
|
|
1688
|
+
// A paste's, a drop's or the image button's files: the images the message
|
|
1689
|
+
// may still take become chips (images.js admitImages); a toast says why the
|
|
1690
|
+
// rest stayed out. Whether any went in.
|
|
1683
1691
|
function addImageFiles(files) {
|
|
1684
|
-
|
|
1685
|
-
|
|
1692
|
+
const { take, refused } = admitImages(files, chips.length);
|
|
1693
|
+
if (refused) showToast(refused);
|
|
1694
|
+
if (!take.length) return false;
|
|
1695
|
+
for (const file of take) chips.push({ file, name: chipName(file, chips.length), src: URL.createObjectURL(file) });
|
|
1686
1696
|
renderChips();
|
|
1687
1697
|
focusPrompt();
|
|
1688
1698
|
return true;
|
|
@@ -1695,6 +1705,13 @@ chipsEl.addEventListener("click", (e) => {
|
|
|
1695
1705
|
if (chip?.file && chip.src) URL.revokeObjectURL(chip.src);
|
|
1696
1706
|
renderChips();
|
|
1697
1707
|
});
|
|
1708
|
+
// The image button: the file picker's images join the chips. Its value is
|
|
1709
|
+
// cleared after each pick, so the same file can be picked again.
|
|
1710
|
+
attachBtn.addEventListener("click", () => attachInput.click());
|
|
1711
|
+
attachInput.addEventListener("change", () => {
|
|
1712
|
+
addImageFiles([...attachInput.files]);
|
|
1713
|
+
attachInput.value = "";
|
|
1714
|
+
});
|
|
1698
1715
|
// ── Composer height: grows with the text; the top-edge grip sets a floor ───
|
|
1699
1716
|
let promptFloor = Number(readStored(GRIP_KEY)) || null;
|
|
1700
1717
|
|
|
@@ -2093,15 +2110,24 @@ function stripHiddenSaved() {
|
|
|
2093
2110
|
return saved === null ? readStored("chi_zen") === "1" : saved === "1";
|
|
2094
2111
|
}
|
|
2095
2112
|
// A short window hides the strip by itself, so the stage keeps room for a
|
|
2096
|
-
// card that waits (its question and choices); the
|
|
2097
|
-
//
|
|
2098
|
-
// user's own hide
|
|
2113
|
+
// card that waits (its question and choices); the grip brings it back
|
|
2114
|
+
// until the window is tall again. Not saved: the saved choice is the
|
|
2115
|
+
// user's own hide or show.
|
|
2099
2116
|
const shortWindow = window.matchMedia(SHORT_WINDOW_QUERY);
|
|
2100
2117
|
let stripShownWhileShort = false;
|
|
2118
|
+
// The strip's grip (strip_grip.js): the handle on the edge between the strip
|
|
2119
|
+
// and the chat. It is given the hidden state and the toggle; it speaks its own
|
|
2120
|
+
// title and aria-expanded, refreshed below after every change.
|
|
2121
|
+
const stripGripRefresh = createStripGrip({
|
|
2122
|
+
gripEl: $("#stripGrip"),
|
|
2123
|
+
isHidden: () => document.body.classList.contains("strip-hidden"),
|
|
2124
|
+
setHidden: (hidden) => setStripHidden(hidden),
|
|
2125
|
+
});
|
|
2101
2126
|
function applyStripHidden() {
|
|
2102
2127
|
document.body.classList.toggle("strip-hidden", stripAutoHidden({
|
|
2103
2128
|
saved: stripHiddenSaved(), short: shortWindow.matches, shownWhileShort: stripShownWhileShort,
|
|
2104
2129
|
}));
|
|
2130
|
+
stripGripRefresh();
|
|
2105
2131
|
updateSessionTitle();
|
|
2106
2132
|
}
|
|
2107
2133
|
function setStripHidden(hidden) {
|
|
@@ -2115,8 +2141,6 @@ shortWindow.addEventListener("change", () => {
|
|
|
2115
2141
|
if (!shortWindow.matches) stripShownWhileShort = false;
|
|
2116
2142
|
applyStripHidden();
|
|
2117
2143
|
});
|
|
2118
|
-
$("#stripShow").addEventListener("click", () => setStripHidden(false));
|
|
2119
|
-
$("#stripHideTop").addEventListener("click", () => setStripHidden(true));
|
|
2120
2144
|
|
|
2121
2145
|
// Esc leaves select mode first, then the all-sessions view.
|
|
2122
2146
|
document.addEventListener("keydown", (e) => {
|
|
@@ -142,14 +142,19 @@ export function ctxBarText(pct, windowSource = null) {
|
|
|
142
142
|
// bar's; a /model whose new window didn't resolve, no request since:
|
|
143
143
|
// Engine#announce_switched_context! sent usage null) adds "until the next
|
|
144
144
|
// request", which asks for it again.
|
|
145
|
-
|
|
145
|
+
// +side+ (the timing's side block) adds the side requests' line, in the
|
|
146
|
+
// "No request in this session yet" answer too (a /btw before any turn).
|
|
147
|
+
export function ctxBarTip(tokens, memoryIndex, window, segments = null, side = null) {
|
|
146
148
|
if (num(window?.pct) === null) {
|
|
147
|
-
if (!num(tokens?.prompt_sum) && !num(tokens?.completion_sum))
|
|
149
|
+
if (!num(tokens?.prompt_sum) && !num(tokens?.completion_sum)) {
|
|
150
|
+
const sideText = sideRequestsText(side);
|
|
151
|
+
return `No request in this session yet${sideText ? `\nside requests: ${sideText}` : ""}`;
|
|
152
|
+
}
|
|
148
153
|
const why = window?.afterSwitch ? "Context window unknown until the next request" : "Context window unknown";
|
|
149
|
-
return tokensTipText(tokens, why, memoryIndex, null, segments);
|
|
154
|
+
return tokensTipText(tokens, why, memoryIndex, null, segments, side);
|
|
150
155
|
}
|
|
151
156
|
|
|
152
|
-
return tokensTipText(tokens, undefined, memoryIndex, window, segments);
|
|
157
|
+
return tokensTipText(tokens, undefined, memoryIndex, window, segments, side);
|
|
153
158
|
}
|
|
154
159
|
|
|
155
160
|
// The ctx tooltip's window line ({tokens:, source:, pct:, budget:} the
|
|
@@ -198,6 +203,24 @@ export function modelSegmentCostText(row) {
|
|
|
198
203
|
return [costText(row?.cost_sum), costText(row?.cost_estimate_sum, { estimate: true })].filter(Boolean).join(" + ");
|
|
199
204
|
}
|
|
200
205
|
|
|
206
|
+
// "3 (recap 2, btw 1) · 14.2k in / 380 out · $0.0012 + ~$0.0009": the
|
|
207
|
+
// session's side requests (the timing's side block: the idle recap, /btw and
|
|
208
|
+
// other ctx.ask_model calls, apart from its turns' totals), the kinds in
|
|
209
|
+
// first-seen order (one kind: its name alone), then the model rows' tokens
|
|
210
|
+
// and cost words on the block; "" without any (the line is hidden). As
|
|
211
|
+
// Formatting#side_requests_text (spec/shared/labels_matrix.json side_requests).
|
|
212
|
+
export function sideRequestsText(side) {
|
|
213
|
+
if (!side || typeof side !== "object") return "";
|
|
214
|
+
const requests = num(side.requests) || 0;
|
|
215
|
+
if (requests <= 0) return "";
|
|
216
|
+
const kinds = side.by_kind && typeof side.by_kind === "object" ? Object.entries(side.by_kind) : [];
|
|
217
|
+
const names = kinds.length === 1
|
|
218
|
+
? kinds[0][0]
|
|
219
|
+
: kinds.map(([kind, row]) => `${kind} ${Math.round(num(row?.requests) || 0)}`).join(", ");
|
|
220
|
+
const head = names ? `${Math.round(requests)} (${names})` : String(Math.round(requests));
|
|
221
|
+
return [head, modelSegmentTokensText(side), modelSegmentCostText(side)].filter(Boolean).join(" · ");
|
|
222
|
+
}
|
|
223
|
+
|
|
201
224
|
// "switch prefill 3.0k uncached prompt ≈$0.0060": what the switch to the
|
|
202
225
|
// row's model prefilled (its cost an estimate from the configured price,
|
|
203
226
|
// left out without one); "" for none.
|
|
@@ -246,14 +269,16 @@ function modelSegmentLines(segments) {
|
|
|
246
269
|
// totals. A delegated session keeps its own metrics, so these are this
|
|
247
270
|
// session's only.
|
|
248
271
|
export function tokensTipText(tokens, title = "Context used after the last turn", memoryIndex = null, window = null,
|
|
249
|
-
segments = null) {
|
|
272
|
+
segments = null, side = null) {
|
|
250
273
|
const memory = memoryIndexText(memoryIndex);
|
|
251
274
|
const memoryLine = memory ? `\nmemory index: ${memory}` : "";
|
|
275
|
+
const sideText = sideRequestsText(side);
|
|
276
|
+
const sideLine = sideText ? `side requests: ${sideText}` : "";
|
|
252
277
|
const windowText = ctxWindowText(window);
|
|
253
278
|
if (windowText) title = `${title}\n${windowText}`;
|
|
254
279
|
const prompt = num(tokens?.prompt_sum) || 0;
|
|
255
280
|
const completion = num(tokens?.completion_sum) || 0;
|
|
256
|
-
if (!prompt && !completion) return `${title}${memoryLine}`;
|
|
281
|
+
if (!prompt && !completion) return `${title}${sideLine ? `\n${sideLine}` : ""}${memoryLine}`;
|
|
257
282
|
const cached = num(tokens.cached_sum) || 0;
|
|
258
283
|
const reasoning = num(tokens.reasoning_sum) || 0;
|
|
259
284
|
const parts = [`in ${count(prompt)}`];
|
|
@@ -262,6 +287,7 @@ export function tokensTipText(tokens, title = "Context used after the last turn"
|
|
|
262
287
|
const lines = [title, ...modelSegmentLines(segments), `tokens: ${parts.join(" · ")}`];
|
|
263
288
|
const cost = costLine(tokens);
|
|
264
289
|
if (cost) lines.push(cost);
|
|
290
|
+
if (sideLine) lines.push(sideLine);
|
|
265
291
|
lines.push("this session only, all requests");
|
|
266
292
|
return `${lines.join("\n")}${memoryLine}`;
|
|
267
293
|
}
|
|
@@ -15,6 +15,39 @@ export function imageFiles(dataTransfer) {
|
|
|
15
15
|
return files.filter((f) => /^image\//.test(f?.type || ""));
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// The server's caps, mirrored so the composer refuses at once what a send
|
|
19
|
+
// would (spec/web/images_spec.rb pins both): ImageStore::MAX_TURN_REFS
|
|
20
|
+
// images a message, Web::App::MAX_IMAGE_UPLOAD_BYTES a file.
|
|
21
|
+
export const MAX_TURN_IMAGES = 20;
|
|
22
|
+
export const MAX_IMAGE_MB = 20;
|
|
23
|
+
const MAX_IMAGE_BYTES = MAX_IMAGE_MB * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
function filesText(n) {
|
|
26
|
+
return n === 1 ? "1 file" : `${n} files`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// What the composer takes of +files+ (a paste's, a drop's or the image
|
|
30
|
+
// button's picks) while it holds +held+ chips: images only, none over
|
|
31
|
+
// MAX_IMAGE_MB, and no more than MAX_TURN_IMAGES in all. +refused+ says
|
|
32
|
+
// why the rest stayed out, for a toast ("" when none did).
|
|
33
|
+
export function admitImages(files, held = 0) {
|
|
34
|
+
const take = [];
|
|
35
|
+
let notImage = 0;
|
|
36
|
+
let tooBig = 0;
|
|
37
|
+
let overCap = 0;
|
|
38
|
+
for (const file of files || []) {
|
|
39
|
+
if (!/^image\//.test(file?.type || "")) notImage += 1;
|
|
40
|
+
else if ((file.size || 0) > MAX_IMAGE_BYTES) tooBig += 1;
|
|
41
|
+
else if (held + take.length >= MAX_TURN_IMAGES) overCap += 1;
|
|
42
|
+
else take.push(file);
|
|
43
|
+
}
|
|
44
|
+
const why = [];
|
|
45
|
+
if (overCap) why.push(`${filesText(overCap)} not added: at most ${MAX_TURN_IMAGES} images a message`);
|
|
46
|
+
if (tooBig) why.push(`${filesText(tooBig)} not added: over ${MAX_IMAGE_MB} MB`);
|
|
47
|
+
if (notImage) why.push(`${filesText(notImage)} not added: not an image`);
|
|
48
|
+
return { take, refused: why.join(" · ") };
|
|
49
|
+
}
|
|
50
|
+
|
|
18
51
|
// A pasted screenshot is often called "image.png": number it so the chips
|
|
19
52
|
// (and what the model reads) tell them apart.
|
|
20
53
|
export function chipName(file, index) {
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
--diff-add-bg:rgba(26,127,55,.12);--diff-add-text:#116329;--diff-del-bg:rgba(207,34,46,.10);--diff-del-text:#82071e;
|
|
18
18
|
--code-text:#1f2328;--code-keyword:#cf222e;--code-string:#0a3069;--code-comment:#57606a;--code-constant:#0550ae;--code-func:#8250df;--code-type:#953800;
|
|
19
19
|
--card-hover-border:rgba(31,35,40,.18);--on-solid:#fff;--on-warn:#1f2328;--text-hover:#0d1117;--badge-bg:rgba(31,35,40,.06);--badge-ring:rgba(31,35,40,.15);--shadow-strong:rgba(31,35,40,.18);--backdrop:rgba(13,17,23,.85);--toast-shadow:0 6px 24px rgba(31,35,40,.14)}}
|
|
20
|
-
/* Icon masks shared by the phone info bar
|
|
20
|
+
/* Icon masks shared by the phone info bar, the all view's cards and the composer's image button (the stroke only shapes the mask; the icon takes currentColor). */
|
|
21
|
+
:root{--icon-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='10.5' rx='1.5'/%3E%3Ccircle cx='5.75' cy='6.25' r='1.1'/%3E%3Cpath d='m2.5 12.5 3.75-3.75 2.5 2.5 1.75-1.75 3 3'/%3E%3C/svg%3E")}
|
|
21
22
|
:root{--icon-archive:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M6.5 9h3'/%3E%3C/svg%3E");--icon-unarchive:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M8 11.5V8.25M6.5 9.75 8 8.25l1.5 1.5'/%3E%3C/svg%3E")}
|
|
22
23
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
23
24
|
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}
|
|
@@ -590,18 +591,26 @@ body.has-session #composer{flex-wrap:wrap;row-gap:6px}
|
|
|
590
591
|
#lightbox[hidden]{display:none}
|
|
591
592
|
#lightbox img{max-width:96vw;max-height:94vh;border-radius:6px}
|
|
592
593
|
#composer textarea{flex:1 1 auto;min-height:72px;resize:none;background:transparent;border:0;color:var(--text);padding:2px;border-radius:0;font-size:15px;line-height:1.45}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
594
|
+
/* An edge grip: the thin handle on an edge between two panes (the composer's,
|
|
595
|
+
the latest-sessions strip's). The pill is the handle's look, a 36×4 px bar
|
|
596
|
+
centred on the edge, drawn on hover, while dragging or focused; the id
|
|
597
|
+
rules hold each grip's own box: height and margins (they depend on the flex
|
|
598
|
+
gap around it: none for the composer's, main's 16 px for the strip's) and
|
|
599
|
+
its own states. */
|
|
600
|
+
.edge-grip{position:relative;z-index:2;cursor:ns-resize;touch-action:none}
|
|
601
|
+
.edge-grip::after{content:"";position:absolute;left:50%;top:3px;width:36px;height:4px;margin-left:-18px;border-radius:2px;background:var(--border-strong);opacity:0;transition:opacity .12s ease}
|
|
602
|
+
.edge-grip:hover::after,.edge-grip.dragging::after{opacity:1}
|
|
603
|
+
.edge-grip:hover::after,.edge-grip.dragging::after{background:var(--accent)}
|
|
604
|
+
.edge-grip:focus-visible{outline:none}
|
|
605
|
+
.edge-grip:focus-visible::after{opacity:1;box-shadow:0 0 0 2px var(--accent-ring)}
|
|
606
|
+
#composeGrip{height:10px;margin:-5px 0}
|
|
607
|
+
#composeGrip:has(+ #composer:hover)::after{opacity:1}
|
|
597
608
|
/* Folded (composer_grip.js, composer_fold.js): the grip is the handle on the info bar's top edge, drawn always, wider so it reads as a control;
|
|
598
609
|
draft = the accent, waiting = the attention colour; .will-fold = a drag past the fold threshold (released it hides the box). */
|
|
599
610
|
body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin-left:-24px}
|
|
600
611
|
#composeGrip.draft::after{background:var(--accent)}
|
|
601
612
|
#composeGrip.waiting::after{background:var(--warn)}
|
|
602
613
|
#composeGrip.will-fold::after{opacity:1;background:var(--accent)}
|
|
603
|
-
#composeGrip:focus-visible{outline:none}
|
|
604
|
-
#composeGrip:focus-visible::after{opacity:1;box-shadow:0 0 0 2px var(--accent-ring)}
|
|
605
614
|
/* A phone has no hover: the pill is always there and the hit zone is taller, ~96 px wide and centred (full width would cover the
|
|
606
615
|
info bar's buttons while folded, the stage card's bottom in the stage view); it reaches 7 px below the edge, the info bar's
|
|
607
616
|
border and padding, so it never covers a button of the bar. */
|
|
@@ -619,6 +628,12 @@ body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin
|
|
|
619
628
|
#composer .actions-col button{white-space:nowrap;font-size:12px;padding:0 12px;height:28px;min-height:28px;line-height:28px}
|
|
620
629
|
#composer .actions-col .split{order:3;display:flex;gap:4px;margin-left:auto}
|
|
621
630
|
#composer #actionBtn{min-width:64px;font-weight:600}
|
|
631
|
+
/* The image button (app.js: a click opens #attachInput, the hidden file picker; a phone's offers the camera and the photo library):
|
|
632
|
+
an icon just left of Start / Send, quiet like the model picker; hidden in a session a terminal owns, and on a phone while a turn runs. */
|
|
633
|
+
#composer .actions-col .attach-btn{order:2;flex:none;width:28px;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-muted)}
|
|
634
|
+
#composer .actions-col .attach-btn::before{content:"";display:block;width:16px;height:16px;margin:auto;background:currentColor;-webkit-mask:var(--icon-image) center/contain no-repeat;mask:var(--icon-image) center/contain no-repeat}
|
|
635
|
+
#composer .actions-col .attach-btn:hover{filter:none;color:var(--text);background:color-mix(in srgb,var(--bg-hover) 75%,transparent)}
|
|
636
|
+
#composer .actions-col .attach-btn[hidden]{display:none}
|
|
622
637
|
/* In a session the composer is one row: the text field (one line, growing with its text; fitPrompt sets its height)
|
|
623
638
|
and Cancel · Send at its bottom right. The key hint is in the placeholder there, so its row goes. */
|
|
624
639
|
body.has-session #composer{flex-direction:row;align-items:flex-end;padding:10px}
|
|
@@ -636,12 +651,12 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
|
|
|
636
651
|
/* A fresh link's (body.fresh-link): the session composer is one row and .actions-col is content-sized there, so a px cap keeps the text field's room and Send in view. :not([hidden]) keeps a failed /api/models' hide (this selector outranks the [hidden] one). */
|
|
637
652
|
body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){display:inline-block;flex:0 1 auto;min-width:0;max-width:200px}
|
|
638
653
|
@media(max-width:600px){body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){max-width:100px}}
|
|
639
|
-
/* The new
|
|
654
|
+
/* The new session's LLM context chip, after the picker: as quiet as it; accent once a choice is set (the info bar chip's .own). */
|
|
640
655
|
#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}
|
|
641
656
|
#composer .actions-col .llmctx-new:hover{filter:none;color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
|
|
642
657
|
#composer .actions-col .llmctx-new.own{color:var(--accent);border-color:var(--accent-border)}
|
|
643
658
|
#composer .actions-col .llmctx-new[hidden],body.has-session #composer .actions-col .llmctx-new{display:none}
|
|
644
|
-
/* The new
|
|
659
|
+
/* The new session's thinking chip, after it: shorter text, so a smaller share of the row. */
|
|
645
660
|
#composer .actions-col .think-new{max-width:min(25%,140px)}
|
|
646
661
|
/* Phones (the key hint is gone there): the chips keep their whole text and the row wraps, Start staying right (its margin-left:auto) on the last line; only a model name wider than the row ellipsizes. */
|
|
647
662
|
@media(max-width:600px){#composer .actions-col .model-pick{flex:0 1 auto;min-width:0;max-width:100%}#composer .actions-col .llmctx-new,#composer .actions-col .think-new{flex:0 0 auto;max-width:100%}}
|
|
@@ -975,8 +990,7 @@ body.has-session #notice{display:none}
|
|
|
975
990
|
#newBtn .brand-new{font-size:12px;color:var(--text-muted);display:none}
|
|
976
991
|
body.has-session #newBtn .brand-new{display:inline}
|
|
977
992
|
#newBtn:hover .brand-new{color:var(--text-2)}
|
|
978
|
-
.
|
|
979
|
-
/* The scope chip: "<project> · all", or "all projects" and where a new chat starts (start page only). */
|
|
993
|
+
/* The scope chip: "<project> · all", or "all projects" and where a new session starts (start page only). */
|
|
980
994
|
#scope{display:inline-flex;align-items:center;gap:8px;min-width:0;font-size:12px;color:var(--text-muted)}
|
|
981
995
|
#scope[hidden]{display:none}
|
|
982
996
|
#scope .scope-name{flex-shrink:0;padding:2px 9px;border:1px solid var(--border-strong);border-radius:999px;color:var(--text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40vw}
|
|
@@ -989,7 +1003,7 @@ body.has-session #newBtn .brand-new{display:inline}
|
|
|
989
1003
|
body.has-session #scope .scope-where{display:none}
|
|
990
1004
|
/* A phone has no room for both; the hidden strip's pill (count, live) wins. */
|
|
991
1005
|
@media(max-width:600px){body.strip-hidden #scope .scope-where{display:none}}
|
|
992
|
-
/* A phone's top bar stays one row: "chi +" (the rest of "+ new
|
|
1006
|
+
/* A phone's top bar stays one row: "chi +" (the rest of "+ new session" goes),
|
|
993
1007
|
tighter gaps, nothing wraps, and the "← project" link cuts with an
|
|
994
1008
|
ellipsis before the pill does. */
|
|
995
1009
|
@media(max-width:600px){
|
|
@@ -998,11 +1012,9 @@ body.has-session #scope .scope-where{display:none}
|
|
|
998
1012
|
#newBtn .brand-new-word{display:none}
|
|
999
1013
|
#newBtn .brand-new,#scope .scope-back{white-space:nowrap}
|
|
1000
1014
|
#scope .scope-back{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
1001
|
-
#
|
|
1015
|
+
#stripStatus{padding:3px 8px}
|
|
1002
1016
|
#newBtn .brand-new{font-size:16px}
|
|
1003
1017
|
}
|
|
1004
|
-
/* The ▾ glyph draws tiny at the pill's 11 px: at 20 px it is about as tall
|
|
1005
|
-
as the pill text's capitals (phone and desktop alike). */
|
|
1006
1018
|
/* The bell (notifications): quiet at the top bar's right end; filled in the accent when on, struck through when the browser blocks it. */
|
|
1007
1019
|
/* The open session's title, when no strip card in sight shows it (session_title.js): the bar's middle, cut with an ellipsis; the whole first message in its tooltip. */
|
|
1008
1020
|
#sessionTitle{flex:1;min-width:0;font-size:13px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
@@ -1014,10 +1026,10 @@ body.has-session #scope .scope-where{display:none}
|
|
|
1014
1026
|
@media(max-width:600px){
|
|
1015
1027
|
#topBar{flex-wrap:wrap;row-gap:2px}
|
|
1016
1028
|
#sessionTitle{flex-basis:100%;order:10;font-size:12px}
|
|
1017
|
-
#scope,#
|
|
1018
|
-
#
|
|
1029
|
+
#scope,#stripStatus{flex:1 1 0;min-width:0;max-width:max-content}
|
|
1030
|
+
#stripStatus{flex-grow:1000;overflow:hidden;text-overflow:ellipsis}
|
|
1019
1031
|
#scope .scope-name{flex-shrink:1;min-width:0}
|
|
1020
|
-
#
|
|
1032
|
+
#stripStatus:has(.live,.waiting) .pill-count{display:none}
|
|
1021
1033
|
}
|
|
1022
1034
|
#notifyBtn{margin-left:auto;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;min-height:0;background:transparent;border:1px solid transparent;border-radius:999px;color:var(--text-muted)}
|
|
1023
1035
|
#notifyBtn[hidden]{display:none}
|
|
@@ -1028,7 +1040,6 @@ body.has-session #scope .scope-where{display:none}
|
|
|
1028
1040
|
#notifyBtn[data-state="on"] .bell{fill:currentColor;fill-opacity:.22}
|
|
1029
1041
|
#notifyBtn[data-state="denied"]{color:var(--text-faint)}
|
|
1030
1042
|
#notifyBtn[data-state="denied"] .slash{display:inline}
|
|
1031
|
-
#stripShow .pill-arrow{font-size:20px;line-height:0;vertical-align:-3px}
|
|
1032
1043
|
.card .folder{font-family:ui-monospace,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;color:var(--text-3)}
|
|
1033
1044
|
.card .parent{font-family:ui-monospace,monospace;white-space:nowrap;color:var(--text-3)}
|
|
1034
1045
|
#infoBar .meta a.parent{text-decoration:none}
|
|
@@ -1098,22 +1109,42 @@ body.no-glass :is(#familyPop,#chainPop){background:var(--bg-panel);-webkit-backd
|
|
|
1098
1109
|
.scope-error{grid-column:1/-1;font-size:13px;color:var(--warn-text)}
|
|
1099
1110
|
.strip-toggle{font-size:11px;padding:3px 10px;background:transparent;color:var(--text-muted);border:1px solid var(--border);border-radius:999px}
|
|
1100
1111
|
.strip-toggle:hover{color:var(--text-2);border-color:var(--border-strong)}
|
|
1101
|
-
#
|
|
1102
|
-
#
|
|
1103
|
-
#
|
|
1104
|
-
#
|
|
1105
|
-
#
|
|
1106
|
-
|
|
1107
|
-
#stripHide:hover{text-decoration:underline;color:var(--text-2)}
|
|
1108
|
-
/* A narrow window's strip scrolls sideways, which puts its own "hide ▴" out of sight: the top bar has it instead, where the pill comes back, with a finger-sized target. */
|
|
1109
|
-
#stripHideTop{display:none;white-space:nowrap;flex-shrink:0}
|
|
1112
|
+
#stripStatus{display:none;white-space:nowrap;flex-shrink:0}
|
|
1113
|
+
#stripStatus .live{color:var(--ok-text);white-space:nowrap}
|
|
1114
|
+
#stripStatus .live-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:4px;vertical-align:1px}
|
|
1115
|
+
#stripStatus .waiting{color:var(--warn-text);white-space:nowrap}
|
|
1116
|
+
#stripStatus .waiting-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--warn);margin-right:4px;vertical-align:1px}
|
|
1117
|
+
/* A narrow window's strip scrolls sideways, and the grip's own target there is thin: the status pill gets a finger-sized one. */
|
|
1110
1118
|
@media(max-width:900px){
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1119
|
+
#stripStatus{min-height:30px;padding:4px 12px;font-size:12px}
|
|
1120
|
+
}
|
|
1121
|
+
body.strip-hidden #stripStatus{display:block}
|
|
1122
|
+
body.strip-hidden #topStrip, body.all-view #stripStatus{display:none}
|
|
1123
|
+
/* The strip's grip (strip_grip.js, app.js): the handle on the edge between the
|
|
1124
|
+
strip and the chat, the composer's twin (the shared .edge-grip look, its own
|
|
1125
|
+
10 px box). Its box is centred on #center's top edge: main's 16 px gap stands
|
|
1126
|
+
on both sides because the box's height and margins add up to -26 px, so the
|
|
1127
|
+
distances stay what they were without it (strip → chat 16 px; hidden, bar →
|
|
1128
|
+
chat 10 px). While the strip shows, the pill is drawn on a hover of the strip
|
|
1129
|
+
or of the grip itself, as the composer's is of its box; hidden, it is the
|
|
1130
|
+
always-on handle that brings the strip back (48 px, as the folded composer's,
|
|
1131
|
+
since it is a control then and not only a hint). */
|
|
1132
|
+
#stripGrip{height:10px;margin:-5px 0 -21px}
|
|
1133
|
+
#topStrip:hover + #stripGrip::after{opacity:1}
|
|
1134
|
+
#stripGrip.will-toggle::after{opacity:1;background:var(--accent)}
|
|
1135
|
+
body.strip-hidden #stripGrip::after{opacity:1;width:48px;margin-left:-24px}
|
|
1136
|
+
body.all-view #stripGrip{display:none}
|
|
1137
|
+
/* A phone has no hover: the pill is always drawn, and the hit zone is 28 px
|
|
1138
|
+
tall, ~96 px wide and centred on the edge (main's gap plus the box: the
|
|
1139
|
+
margins add up to -(28+16)) — the mirror of the composer's phone grip: it
|
|
1140
|
+
reaches 7 px over its own side (below the cards; it never covers one) and
|
|
1141
|
+
21 px into the transcript, with the pill on #center's top edge. The grip sits
|
|
1142
|
+
under the strip, outside its sideways scroller, so a swipe on the cards stays
|
|
1143
|
+
a scroll (touch-action:none is the grip's box, not the strip's). */
|
|
1144
|
+
@media(max-width:600px){
|
|
1145
|
+
#stripGrip{width:96px;height:28px;margin:-7px auto -37px}
|
|
1146
|
+
#stripGrip::after{top:5px;opacity:1}
|
|
1114
1147
|
}
|
|
1115
|
-
body.strip-hidden #stripShow{display:block}
|
|
1116
|
-
body.strip-hidden #topStrip, body.all-view #stripShow{display:none}
|
|
1117
1148
|
#allTile{flex:1;flex-direction:column;justify-content:center;align-items:flex-start;text-align:left;color:var(--text-2);font-size:13px;font-weight:600;width:100%}
|
|
1118
1149
|
#allTile .count{font-size:11px;font-weight:400;color:var(--text-muted)}
|
|
1119
1150
|
#allView{display:none;flex:1;min-height:0;flex-direction:column;gap:12px}
|
|
@@ -1213,14 +1244,14 @@ a{color:var(--accent)}
|
|
|
1213
1244
|
<body>
|
|
1214
1245
|
<main>
|
|
1215
1246
|
<div id="topBar">
|
|
1216
|
-
<button type="button" id="newBtn" class="brand" title="New
|
|
1247
|
+
<button type="button" id="newBtn" class="brand" title="New session"><span class="brand-name">chi</span><span class="brand-new">+<span class="brand-new-word"> new session</span></span></button>
|
|
1217
1248
|
<div id="scope" hidden></div>
|
|
1218
|
-
<button type="button" class="strip-toggle" id="
|
|
1219
|
-
<button type="button" class="strip-toggle" id="stripHideTop" title="Hide the latest sessions"><span class="pill-arrow" aria-hidden="true">▴</span> hide</button>
|
|
1249
|
+
<button type="button" class="strip-toggle" id="stripStatus" title="All sessions, with search"><span class="pill-count">sessions</span></button>
|
|
1220
1250
|
<span id="sessionTitle" hidden></span>
|
|
1221
1251
|
<button type="button" id="notifyBtn" aria-label="Notifications" aria-pressed="false" hidden><svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><path class="bell" d="M8 1.75a4 4 0 0 0-4 4v2.6L2.75 10.6v.9h10.5v-.9L12 8.35v-2.6a4 4 0 0 0-4-4Z"/><path class="clapper" d="M6.5 13a1.5 1.5 0 0 0 3 0"/><path class="slash" d="M2.5 2.5l11 11"/></svg></button>
|
|
1222
1252
|
</div>
|
|
1223
1253
|
<section id="topStrip" aria-label="Latest sessions" class="loading"><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div></section>
|
|
1254
|
+
<div id="stripGrip" class="edge-grip" tabindex="0" role="button" aria-controls="topStrip" aria-label="Latest sessions" title="Drag up or click to hide the latest sessions"></div>
|
|
1224
1255
|
<section id="center">
|
|
1225
1256
|
<div id="stage">
|
|
1226
1257
|
<div id="markdownWarning" class="markdown-warning" hidden></div>
|
|
@@ -1231,7 +1262,7 @@ a{color:var(--accent)}
|
|
|
1231
1262
|
<div id="lightbox" hidden><img alt=""></div>
|
|
1232
1263
|
<div id="dock">
|
|
1233
1264
|
<div id="initRow" role="status" aria-live="polite" hidden></div>
|
|
1234
|
-
<div id="composeGrip" tabindex="0" aria-label="Message box" title="Drag to make the message box taller (double-click resets)"></div>
|
|
1265
|
+
<div id="composeGrip" class="edge-grip" tabindex="0" aria-label="Message box" title="Drag to make the message box taller (double-click resets)"></div>
|
|
1235
1266
|
<div id="composer" style="position:relative">
|
|
1236
1267
|
<div id="commandList" role="listbox" aria-label="Commands" hidden></div>
|
|
1237
1268
|
<div id="modelPanel" hidden>
|
|
@@ -1244,11 +1275,13 @@ a{color:var(--accent)}
|
|
|
1244
1275
|
<textarea id="prompt" placeholder="Ask chi anything…" aria-controls="commandList" aria-autocomplete="list" aria-expanded="false"></textarea>
|
|
1245
1276
|
</div>
|
|
1246
1277
|
<div class="actions-col">
|
|
1247
|
-
<button type="button" id="modelPick" class="model-pick" title="The model the new
|
|
1278
|
+
<button type="button" id="modelPick" class="model-pick" title="The model the new session starts on" aria-label="Model for the new session" aria-haspopup="listbox" aria-expanded="false" hidden><span class="model-pick-name"></span></button>
|
|
1248
1279
|
<button type="button" id="thinkingNew" class="llmctx-new think-new" aria-haspopup="dialog" hidden></button>
|
|
1249
1280
|
<button type="button" id="llmContextNew" class="llmctx-new" aria-haspopup="dialog" hidden></button>
|
|
1250
1281
|
<div id="composeHint">Enter to start · Shift+Enter for a newline</div>
|
|
1251
1282
|
<button id="cancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
|
|
1283
|
+
<button type="button" id="attachBtn" class="attach-btn" title="Add images (or paste or drop them here)" aria-label="Add images" hidden></button>
|
|
1284
|
+
<input type="file" id="attachInput" accept="image/*" multiple hidden>
|
|
1252
1285
|
<div class="split">
|
|
1253
1286
|
<button id="actionBtn">Start</button>
|
|
1254
1287
|
</div>
|
|
@@ -68,7 +68,7 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
|
|
|
68
68
|
const ctxTip = ctxBarTip(tokens, state.timing.memoryIndex,
|
|
69
69
|
{ tokens: state.ctxWindow, source: state.ctxWindowSource, pct: state.ctxPct,
|
|
70
70
|
budget: state.llmContext?.budget_tokens, afterSwitch: state.ctxUnknownAfterSwitch },
|
|
71
|
-
state.timing.modelSegments);
|
|
71
|
+
state.timing.modelSegments, state.timing.side);
|
|
72
72
|
const speedTextNow = lastSpeedText(tokens);
|
|
73
73
|
const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
|
|
74
74
|
const sessionDuration = sessionDurationNow(state.timing, running);
|