samagotchi 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +162 -1
- data/README.md +29 -2
- data/bin/chi +60 -69
- data/docs/cli.md +211 -77
- data/docs/configuration.md +118 -21
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +89 -7
- data/docs/memory.md +40 -0
- data/docs/plugins.md +50 -0
- data/docs/releasing.md +15 -12
- data/docs/sessions.md +20 -18
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
- data/lib/samagotchi/bridge/sse_writer.rb +0 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bridge.rb +20 -12
- data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
- data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/identity.md +5 -0
- data/lib/samagotchi/bundles/system/manifest.yml +6 -6
- data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client.rb +25 -26
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +97 -113
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/engine.rb +236 -443
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +26 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/host_registry.rb +8 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +59 -123
- data/lib/samagotchi/live_versions.rb +65 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +77 -13
- data/lib/samagotchi/llm/errors.rb +38 -7
- data/lib/samagotchi/llm/http.rb +19 -22
- data/lib/samagotchi/llm/openai_chat.rb +22 -26
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +27 -10
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +48 -4
- data/lib/samagotchi/self_report.rb +37 -5
- data/lib/samagotchi/send_command.rb +190 -17
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +19 -53
- data/lib/samagotchi/system_prompt.rb +403 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
- data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
- data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
- data/lib/samagotchi/terminal_ui/surface.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +142 -923
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +126 -0
- data/lib/samagotchi/tool_activity.rb +52 -2
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +3 -3
- data/lib/samagotchi/tools/output_guardrails.rb +8 -7
- data/lib/samagotchi/tools/read.rb +4 -4
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/update_command.rb +309 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +6 -4
- data/lib/samagotchi/web/app.rb +173 -38
- data/lib/samagotchi/web/lan.rb +99 -0
- data/lib/samagotchi/web/message_parts.rb +19 -10
- data/lib/samagotchi/web/public/activity.js +10 -0
- data/lib/samagotchi/web/public/app.js +135 -78
- data/lib/samagotchi/web/public/chat_view.js +8 -1
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/index.html +185 -18
- data/lib/samagotchi/web/public/model_pick.js +136 -0
- data/lib/samagotchi/web/public/model_picker.js +224 -0
- data/lib/samagotchi/web/public/notify.js +10 -0
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/stage_model.js +110 -0
- data/lib/samagotchi/web/public/stage_view.js +580 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +38 -10
- data/lib/samagotchi/web/public/turn_model.js +11 -3
- data/lib/samagotchi/web/public/turn_view.js +76 -20
- data/lib/samagotchi/web/qr.rb +40 -0
- data/lib/samagotchi/web/server.rb +101 -11
- data/lib/samagotchi/web/token.rb +97 -0
- data/lib/samagotchi/worker.rb +5 -4
- metadata +38 -3
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
// The stage view of a running turn (web.view: stage): the active turn pinned
|
|
2
|
+
// above the composer, as the top section of the dock card. It shows only
|
|
3
|
+
// what happens now in fixed slots (a status row, the prompt, a two-line
|
|
4
|
+
// headline, the running tool, a one-line trail of the last calls), the
|
|
5
|
+
// cards and rows that arrive meanwhile in their own capped scroller, the
|
|
6
|
+
// answer, and a chip with one tick per tool call: the "cloud", the turn
|
|
7
|
+
// view's block (turn_view.js) reused as is, expands in place, newest step
|
|
8
|
+
// first while the turn runs. When the turn has ended and the stage is not
|
|
9
|
+
// in use, everything moves into the history in the turn view's order (the
|
|
10
|
+
// hand-off), so a handed-off turn reads like the turn view's and like its
|
|
11
|
+
// reload. Slots are patched in place, never rebuilt from HTML: a selection
|
|
12
|
+
// and annotate survive. The rules are pure in stage_model.js.
|
|
13
|
+
import { createTurnView } from "./turn_view.js";
|
|
14
|
+
import { handOffDue, handOffOrder, inUseFrom, isPlain, liveSlots, placeFor } from "./stage_model.js";
|
|
15
|
+
import { splitSentences } from "./sentences.js";
|
|
16
|
+
import { formatDuration } from "./timing.js";
|
|
17
|
+
|
|
18
|
+
const COLLAPSE_KEY = "chi_stage_collapsed";
|
|
19
|
+
const WATCH_MS = 200;
|
|
20
|
+
// The turn-end read (markdown, answer_display) settles within this, or the
|
|
21
|
+
// hand-off stops waiting for it.
|
|
22
|
+
const SETTLE_CAP_MS = 8000;
|
|
23
|
+
const FOLD_MS = 420;
|
|
24
|
+
const FLASH_MS = 4000;
|
|
25
|
+
const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.continue:not(.resolved)";
|
|
26
|
+
const MARKS = { answered: "✓", canceled: "■", failed: "✕" };
|
|
27
|
+
|
|
28
|
+
function node(tag, cls, parent = null) {
|
|
29
|
+
const el = document.createElement(tag);
|
|
30
|
+
if (cls) el.className = cls;
|
|
31
|
+
if (parent) parent.appendChild(el);
|
|
32
|
+
return el;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The step's newest narration sentence: the end of a finished text is a
|
|
36
|
+
// boundary (its last line may have no period), a live one's not yet.
|
|
37
|
+
function headlineOf(text, done) {
|
|
38
|
+
const { sentences, rest } = splitSentences(text, { atEnd: true });
|
|
39
|
+
const fragment = rest.replace(/\s+/g, " ").trim();
|
|
40
|
+
if (done && fragment) return fragment;
|
|
41
|
+
if (sentences.length) return sentences[sentences.length - 1];
|
|
42
|
+
return fragment.length > 160 ? fragment : "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readCollapsed() {
|
|
46
|
+
try { return localStorage.getItem(COLLAPSE_KEY) === "1"; } catch (_) { return false; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// +historyNearBottom+: the history's own follow check (the hand-off scrolls
|
|
50
|
+
// the history only for a reader at its end). +dockEl+, +centerEl+ and
|
|
51
|
+
// +composerEl+ size the stage's cap.
|
|
52
|
+
export function createStageView({ historyEl, appendToHistory, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, composerEl }) {
|
|
53
|
+
// The dock becomes a clear column: the stage's card, then the composer's
|
|
54
|
+
// (its rows moved into .dock-card, which wears the glass and the focus ring).
|
|
55
|
+
document.body.classList.add("stage-view");
|
|
56
|
+
const card = node("div", "dock-card");
|
|
57
|
+
card.append(...dockEl.children);
|
|
58
|
+
dockEl.appendChild(card);
|
|
59
|
+
const stage = node("section", "turn-stage");
|
|
60
|
+
stage.id = "turnStage";
|
|
61
|
+
stage.hidden = true;
|
|
62
|
+
stage.setAttribute("aria-label", "Running turn");
|
|
63
|
+
dockEl.prepend(stage);
|
|
64
|
+
|
|
65
|
+
const status = node("div", "ts-status", stage);
|
|
66
|
+
const mark = node("span", "ts-mark", status);
|
|
67
|
+
const phaseEl = node("span", "ts-phase", status);
|
|
68
|
+
const stepEl = node("span", "ts-step", status);
|
|
69
|
+
const timingSlot = node("span", "ts-timing", status);
|
|
70
|
+
const miniTool = node("span", "ts-mini", status);
|
|
71
|
+
const defer = node("span", "ts-defer", status);
|
|
72
|
+
defer.textContent = "moves up when you leave";
|
|
73
|
+
const fold = node("button", "ts-fold", status);
|
|
74
|
+
fold.type = "button";
|
|
75
|
+
|
|
76
|
+
const scroller = node("div", "ts-scroll", stage);
|
|
77
|
+
const promptSlot = node("div", "ts-prompt", scroller);
|
|
78
|
+
promptSlot.title = "Show the whole prompt";
|
|
79
|
+
const live = node("div", "ts-live", scroller);
|
|
80
|
+
const headline = node("div", "ts-headline", live);
|
|
81
|
+
headline.tabIndex = 0;
|
|
82
|
+
headline.setAttribute("role", "button");
|
|
83
|
+
headline.title = "Show this step's reasoning";
|
|
84
|
+
const now = node("div", "ts-now", live);
|
|
85
|
+
const trail = node("div", "ts-trail", live);
|
|
86
|
+
const extras = node("div", "ts-extras", scroller);
|
|
87
|
+
const answer = node("div", "ts-answer", scroller);
|
|
88
|
+
const tail = node("div", "ts-tail", scroller);
|
|
89
|
+
const cloud = node("div", "ts-cloud", scroller);
|
|
90
|
+
const chip = node("button", "ts-chip", cloud);
|
|
91
|
+
chip.type = "button";
|
|
92
|
+
const chipLabel = node("span", "ts-chip-label", chip);
|
|
93
|
+
const ticks = node("span", "ts-ticks", chip);
|
|
94
|
+
const cloudBody = node("div", "ts-cloud-body", cloud);
|
|
95
|
+
cloudBody.hidden = true;
|
|
96
|
+
|
|
97
|
+
// The turn in the stage, from turnStarted until the hand-off (or a reset).
|
|
98
|
+
let holding = false;
|
|
99
|
+
let endKind = null;
|
|
100
|
+
let settled = true;
|
|
101
|
+
let settleTimer = null;
|
|
102
|
+
let startedAt = 0;
|
|
103
|
+
let endedAt = 0;
|
|
104
|
+
let thinkingLine = null; // { step, text } from the live step's ticker
|
|
105
|
+
let flash = null; // { text, until } a warn notice in the trail
|
|
106
|
+
let collapsed = readCollapsed();
|
|
107
|
+
let cloudOpen = false;
|
|
108
|
+
let rafPending = false;
|
|
109
|
+
let foldTimer = null;
|
|
110
|
+
let watchTimer = null;
|
|
111
|
+
let quietSince = null;
|
|
112
|
+
let pointerIn = false;
|
|
113
|
+
let lastTouch = 0;
|
|
114
|
+
// What each slot shows, so an unchanged slot is left alone.
|
|
115
|
+
const shown = { headline: null, now: null, trail: null };
|
|
116
|
+
|
|
117
|
+
const view = createTurnView({
|
|
118
|
+
historyEl,
|
|
119
|
+
appendToHistory,
|
|
120
|
+
sawText,
|
|
121
|
+
sessionId,
|
|
122
|
+
// The newest step is on top of the cloud: "near the bottom" is near its top.
|
|
123
|
+
isNearBottom: () => cloudBody.scrollTop < 24,
|
|
124
|
+
follow: () => { cloudBody.scrollTop = 0; },
|
|
125
|
+
// The block goes into the cloud; a plain turn's (text, no thinking, no
|
|
126
|
+
// tool, one step) streams in the answer slot until it turns out to be
|
|
127
|
+
// more (refresh moves it).
|
|
128
|
+
blockHost: (el) => cloudBody.appendChild(el),
|
|
129
|
+
placeAnswer: (pieces, { plain, blockEl }) => {
|
|
130
|
+
if (plain) blockEl.remove();
|
|
131
|
+
answer.append(...pieces);
|
|
132
|
+
showAnswerTop();
|
|
133
|
+
},
|
|
134
|
+
useHold: false,
|
|
135
|
+
newestFirst: true,
|
|
136
|
+
onChange: () => scheduleRefresh(),
|
|
137
|
+
onThinkingLine: (text) => {
|
|
138
|
+
const { turn } = view.current();
|
|
139
|
+
thinkingLine = { step: turn ? turn.gens.length : 0, text };
|
|
140
|
+
scheduleRefresh();
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── slots ────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
function scheduleRefresh() {
|
|
147
|
+
if (rafPending) return;
|
|
148
|
+
rafPending = true;
|
|
149
|
+
requestAnimationFrame(refresh);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function pendingCard() {
|
|
153
|
+
return !!(extras.querySelector(PENDING) || tail.querySelector(PENDING));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function refresh() {
|
|
157
|
+
rafPending = false;
|
|
158
|
+
const { turn, blockEl } = view.current();
|
|
159
|
+
if (!holding || !turn) return;
|
|
160
|
+
const gen = turn.gens[turn.gens.length - 1];
|
|
161
|
+
// Plain once text streams without thinking or tools; until the first
|
|
162
|
+
// chunk the live slots show (a slow first token leaves the height alone).
|
|
163
|
+
const plain = !endKind && isPlain(turn) && !!gen?.text;
|
|
164
|
+
if (blockEl && !endKind) {
|
|
165
|
+
const host = plain ? answer : cloudBody;
|
|
166
|
+
if (blockEl.parentNode !== host) host.appendChild(blockEl);
|
|
167
|
+
}
|
|
168
|
+
// The turn view closes its block at the end; the cloud shows its steps.
|
|
169
|
+
if (blockEl && blockEl.parentNode === cloudBody) blockEl.open = true;
|
|
170
|
+
const narration = gen?.text ? headlineOf(gen.text, gen.textDone || gen.closed) : "";
|
|
171
|
+
const thinking = thinkingLine && thinkingLine.step === turn.gens.length ? thinkingLine.text : "";
|
|
172
|
+
const pending = pendingCard();
|
|
173
|
+
const slots = liveSlots(turn, { narration, thinking, pending, ended: endKind });
|
|
174
|
+
|
|
175
|
+
stage.classList.toggle("plain", plain);
|
|
176
|
+
stage.classList.toggle("ended", !!endKind);
|
|
177
|
+
stage.classList.toggle("waiting", pending);
|
|
178
|
+
stage.dataset.phase = slots.phase;
|
|
179
|
+
mark.textContent = MARKS[slots.phase] || "";
|
|
180
|
+
phaseEl.textContent = slots.phase;
|
|
181
|
+
stepEl.textContent = endKind ? `${slots.step} step${slots.step === 1 ? "" : "s"}` : `step ${slots.step}`;
|
|
182
|
+
|
|
183
|
+
drawHeadline(slots.headline);
|
|
184
|
+
drawNow(slots.tool, gen);
|
|
185
|
+
drawTrail(slots.trail);
|
|
186
|
+
drawTicks(slots.ticks);
|
|
187
|
+
const calls = slots.ticks.length;
|
|
188
|
+
const elapsed = endKind ? formatDuration(endedAt - startedAt) : "";
|
|
189
|
+
chipLabel.textContent = `${slots.step} step${slots.step === 1 ? "" : "s"} · ${calls} tool call${calls === 1 ? "" : "s"}${elapsed ? ` · ${elapsed}` : ""}`;
|
|
190
|
+
miniTool.textContent = collapsed && slots.tool ? `${slots.tool.name} ${slots.tool.title}` : "";
|
|
191
|
+
|
|
192
|
+
live.hidden = plain || !!endKind;
|
|
193
|
+
cloud.hidden = !blockEl || blockEl.parentNode !== cloudBody;
|
|
194
|
+
promptSlot.hidden = !promptSlot.firstChild;
|
|
195
|
+
extras.hidden = !extras.firstChild;
|
|
196
|
+
answer.hidden = !answer.firstChild;
|
|
197
|
+
tail.hidden = !tail.firstChild;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function drawHeadline({ text, thinking }) {
|
|
201
|
+
const key = `${thinking ? "t" : "n"}|${text}`;
|
|
202
|
+
if (shown.headline === key) return;
|
|
203
|
+
shown.headline = key;
|
|
204
|
+
headline.replaceChildren();
|
|
205
|
+
headline.classList.toggle("thinking", thinking);
|
|
206
|
+
if (thinking) {
|
|
207
|
+
const tag = node("span", "ts-think-tag", headline);
|
|
208
|
+
tag.textContent = "thinking";
|
|
209
|
+
}
|
|
210
|
+
headline.appendChild(document.createTextNode(text));
|
|
211
|
+
headline.classList.remove("fresh");
|
|
212
|
+
void headline.offsetWidth;
|
|
213
|
+
headline.classList.add("fresh");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function drawNow(tool, gen) {
|
|
217
|
+
const quiet = gen?.text && !gen.textDone ? "writing…" : "thinking…";
|
|
218
|
+
const key = tool ? `run|${tool.name}|${tool.title}` : `quiet|${quiet}`;
|
|
219
|
+
if (shown.now === key) return;
|
|
220
|
+
shown.now = key;
|
|
221
|
+
now.replaceChildren();
|
|
222
|
+
now.classList.toggle("running", !!tool);
|
|
223
|
+
node("span", tool ? "ts-spin" : "ts-idle", now);
|
|
224
|
+
if (tool) {
|
|
225
|
+
const name = node("span", `ts-tool ${tool.kind}`, now);
|
|
226
|
+
name.textContent = tool.name;
|
|
227
|
+
const title = node("span", "ts-title", now);
|
|
228
|
+
title.textContent = tool.title;
|
|
229
|
+
title.title = tool.title;
|
|
230
|
+
} else {
|
|
231
|
+
const word = node("span", "ts-title quiet", now);
|
|
232
|
+
word.textContent = quiet;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function drawTrail(items) {
|
|
237
|
+
const flashing = flash && flash.until > Date.now() ? flash.text : "";
|
|
238
|
+
const key = JSON.stringify([flashing, items]);
|
|
239
|
+
if (shown.trail === key) return;
|
|
240
|
+
shown.trail = key;
|
|
241
|
+
trail.replaceChildren();
|
|
242
|
+
if (flashing) node("span", "ts-trail-item warn", trail).textContent = `⚠ ${flashing}`;
|
|
243
|
+
for (const item of items) {
|
|
244
|
+
const failed = item.status === "error";
|
|
245
|
+
const el = node("span", `ts-trail-item${failed ? " error" : ""}`, trail);
|
|
246
|
+
el.textContent = `${failed ? "✕" : "✓"} ${item.name} ${item.title}`.trim();
|
|
247
|
+
}
|
|
248
|
+
if (!flashing && !items.length) node("span", "ts-trail-item none", trail).textContent = "no tool calls yet";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function drawTicks(list) {
|
|
252
|
+
list.forEach((tick, i) => {
|
|
253
|
+
let el = ticks.children[i];
|
|
254
|
+
if (!el) {
|
|
255
|
+
el = node("span", "", ticks);
|
|
256
|
+
el.setAttribute("role", "button");
|
|
257
|
+
}
|
|
258
|
+
const cls = `ts-tick ${tick.kind} ${tick.status}`;
|
|
259
|
+
if (el.className !== cls) el.className = cls;
|
|
260
|
+
el.dataset.step = String(tick.step);
|
|
261
|
+
el.title = `step ${tick.step + 1}`;
|
|
262
|
+
});
|
|
263
|
+
while (ticks.children.length > list.length) ticks.lastChild.remove();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// The answer reads from its top (no tail pin in the stage).
|
|
267
|
+
function showAnswerTop() {
|
|
268
|
+
requestAnimationFrame(() => {
|
|
269
|
+
if (answer.firstChild) scroller.scrollTop = Math.max(0, answer.offsetTop - 8);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── collapse, cloud, peek ────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
function applyCollapsed() {
|
|
276
|
+
stage.classList.toggle("collapsed", collapsed);
|
|
277
|
+
scroller.hidden = collapsed;
|
|
278
|
+
fold.textContent = collapsed ? "▴" : "▾";
|
|
279
|
+
fold.setAttribute("aria-label", collapsed ? "Expand the running turn" : "Collapse the running turn to its status");
|
|
280
|
+
fold.setAttribute("aria-expanded", String(!collapsed));
|
|
281
|
+
fitCap();
|
|
282
|
+
scheduleRefresh();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function setCollapsed(value) {
|
|
286
|
+
collapsed = value;
|
|
287
|
+
try { localStorage.setItem(COLLAPSE_KEY, value ? "1" : "0"); } catch (_) {}
|
|
288
|
+
applyCollapsed();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function setCloud(open) {
|
|
292
|
+
cloudOpen = open;
|
|
293
|
+
cloudBody.hidden = !open;
|
|
294
|
+
cloud.classList.toggle("open", open);
|
|
295
|
+
stage.classList.toggle("cloud-open", open);
|
|
296
|
+
chip.setAttribute("aria-expanded", String(open));
|
|
297
|
+
fitCap();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Open the cloud at step +i+ (a hand toggle, as a click on its row), its
|
|
301
|
+
// reasoning too with +thinking+.
|
|
302
|
+
function openStep(i, { thinking = false } = {}) {
|
|
303
|
+
const step = view.step(i);
|
|
304
|
+
if (!step) return;
|
|
305
|
+
if (collapsed) setCollapsed(false);
|
|
306
|
+
setCloud(true);
|
|
307
|
+
if (!step.el.open) step.summary.click();
|
|
308
|
+
const wrap = step.thinkingWrap;
|
|
309
|
+
if (thinking && wrap && !wrap.open) wrap.querySelector(":scope > summary")?.click();
|
|
310
|
+
requestAnimationFrame(() => step.el.scrollIntoView({ block: "nearest" }));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function peek() {
|
|
314
|
+
const { turn } = view.current();
|
|
315
|
+
if (turn?.gens.length) openStep(turn.gens.length - 1, { thinking: true });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
fold.addEventListener("click", (e) => {
|
|
319
|
+
e.stopPropagation();
|
|
320
|
+
setCollapsed(!collapsed);
|
|
321
|
+
});
|
|
322
|
+
status.addEventListener("click", (e) => {
|
|
323
|
+
if (collapsed && !e.target.closest("button, a")) setCollapsed(false);
|
|
324
|
+
});
|
|
325
|
+
chip.addEventListener("click", (e) => {
|
|
326
|
+
const tick = e.target.closest(".ts-tick");
|
|
327
|
+
if (tick) openStep(Number(tick.dataset.step));
|
|
328
|
+
else setCloud(!cloudOpen);
|
|
329
|
+
});
|
|
330
|
+
headline.addEventListener("click", peek);
|
|
331
|
+
headline.addEventListener("keydown", (e) => {
|
|
332
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
333
|
+
e.preventDefault();
|
|
334
|
+
peek();
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
promptSlot.addEventListener("click", (e) => {
|
|
338
|
+
if (e.target.closest("button, a, img")) return;
|
|
339
|
+
if (window.getSelection()?.isCollapsed === false) return;
|
|
340
|
+
promptSlot.classList.toggle("full");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ── the cap: at most ~60% of the page (~80% with the cloud open), and
|
|
344
|
+
// never above the session panel (the dock is absolute: a tall composer or
|
|
345
|
+
// a phone's keyboard would push the stage's top out) ────────────────────
|
|
346
|
+
|
|
347
|
+
function fitCap() {
|
|
348
|
+
if (stage.hidden) return;
|
|
349
|
+
const rest = dockEl.offsetHeight - stage.offsetHeight;
|
|
350
|
+
const room = centerEl.clientHeight - rest - 32;
|
|
351
|
+
const share = window.innerHeight * (cloudOpen ? 0.8 : 0.6);
|
|
352
|
+
stage.style.setProperty("--stage-max", `${Math.max(96, Math.round(Math.min(share, room)))}px`);
|
|
353
|
+
}
|
|
354
|
+
new ResizeObserver(() => fitCap()).observe(centerEl);
|
|
355
|
+
new ResizeObserver(() => fitCap()).observe(composerEl);
|
|
356
|
+
window.addEventListener("resize", fitCap);
|
|
357
|
+
|
|
358
|
+
// ── in use: pointer over it (tracked on the stage itself: :hover goes
|
|
359
|
+
// stale when children change), a touch or wheel in the last 4 s (phones
|
|
360
|
+
// have no hover), keyboard focus or a selection in it ─────────────────
|
|
361
|
+
|
|
362
|
+
stage.addEventListener("pointerenter", (e) => { if (e.pointerType === "mouse") pointerIn = true; });
|
|
363
|
+
stage.addEventListener("pointermove", (e) => { if (e.pointerType === "mouse") pointerIn = true; });
|
|
364
|
+
stage.addEventListener("pointerleave", () => { pointerIn = false; });
|
|
365
|
+
// Scrolling needs the pointer over it, a touch or keyboard focus; the
|
|
366
|
+
// stage's own scrolls (follow, the answer's top) are no use.
|
|
367
|
+
for (const type of ["touchstart", "touchmove", "wheel"]) {
|
|
368
|
+
stage.addEventListener(type, () => { lastTouch = Date.now(); }, { passive: true, capture: true });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function inUse(nowMs) {
|
|
372
|
+
const active = document.activeElement;
|
|
373
|
+
const sel = window.getSelection();
|
|
374
|
+
return inUseFrom({
|
|
375
|
+
pointerIn,
|
|
376
|
+
lastTouchMs: lastTouch,
|
|
377
|
+
// A clicked button keeps the focus in Chrome: only keyboard focus and
|
|
378
|
+
// a field being typed in count.
|
|
379
|
+
focusInside: !!active && stage.contains(active) && active.matches(":focus-visible"),
|
|
380
|
+
selectionInside: !!sel && !sel.isCollapsed && stage.contains(sel.anchorNode),
|
|
381
|
+
nowMs,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function stopWatch() {
|
|
386
|
+
clearInterval(watchTimer);
|
|
387
|
+
watchTimer = null;
|
|
388
|
+
quietSince = null;
|
|
389
|
+
stage.classList.remove("in-use");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function startWatch() {
|
|
393
|
+
stopWatch();
|
|
394
|
+
watchTimer = setInterval(() => {
|
|
395
|
+
if (!holding || !endKind) return stopWatch();
|
|
396
|
+
const nowMs = Date.now();
|
|
397
|
+
const busy = inUse(nowMs);
|
|
398
|
+
stage.classList.toggle("in-use", busy);
|
|
399
|
+
const r = handOffDue({ ended: true, pending: !settled || pendingCard(), inUse: busy, quietSinceMs: quietSince, nowMs });
|
|
400
|
+
quietSince = r.quietSinceMs;
|
|
401
|
+
if (r.due) handOff();
|
|
402
|
+
}, WATCH_MS);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── show, clear, hand off ────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
function cancelFold() {
|
|
408
|
+
clearTimeout(foldTimer);
|
|
409
|
+
foldTimer = null;
|
|
410
|
+
stage.classList.remove("leaving");
|
|
411
|
+
stage.style.height = "";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function show() {
|
|
415
|
+
cancelFold();
|
|
416
|
+
const hidden = stage.hidden;
|
|
417
|
+
stage.hidden = false;
|
|
418
|
+
applyCollapsed();
|
|
419
|
+
if (hidden && !matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
420
|
+
stage.classList.remove("enter");
|
|
421
|
+
void stage.offsetWidth;
|
|
422
|
+
stage.classList.add("enter");
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function emptySlots() {
|
|
427
|
+
for (const slot of [promptSlot, extras, answer, tail, cloudBody, timingSlot, headline, now, trail, ticks]) slot.replaceChildren();
|
|
428
|
+
promptSlot.classList.remove("full");
|
|
429
|
+
shown.headline = shown.now = shown.trail = null;
|
|
430
|
+
thinkingLine = null;
|
|
431
|
+
flash = null;
|
|
432
|
+
setCloud(false);
|
|
433
|
+
stage.classList.remove("plain", "ended", "waiting", "in-use");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function release() {
|
|
437
|
+
stopWatch();
|
|
438
|
+
clearTimeout(settleTimer);
|
|
439
|
+
holding = false;
|
|
440
|
+
endKind = null;
|
|
441
|
+
settled = true;
|
|
442
|
+
view.reset();
|
|
443
|
+
emptySlots();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Everything the stage holds, into the history's end in the turn view's
|
|
447
|
+
// order: prompt, block (collapsed, chronological), extras, answer, timing
|
|
448
|
+
// line, then the end line and what came after the end. The stage folds
|
|
449
|
+
// away while the turn fades up into the history (not with +immediate+:
|
|
450
|
+
// another turn takes the stage at once). The history follows only a
|
|
451
|
+
// reader who was at its end.
|
|
452
|
+
function handOff({ immediate = false } = {}) {
|
|
453
|
+
if (!holding || !endKind) return;
|
|
454
|
+
const { blockEl } = view.current();
|
|
455
|
+
view.chronological();
|
|
456
|
+
if (blockEl) blockEl.open = false;
|
|
457
|
+
const wasAtEnd = historyNearBottom();
|
|
458
|
+
const pieces = handOffOrder({
|
|
459
|
+
prompt: promptSlot.firstElementChild,
|
|
460
|
+
block: blockEl && blockEl.parentNode === cloudBody ? blockEl : null,
|
|
461
|
+
extras: [...extras.children],
|
|
462
|
+
answer: [...answer.children],
|
|
463
|
+
timing: timingSlot.firstElementChild,
|
|
464
|
+
kept: [...tail.children],
|
|
465
|
+
});
|
|
466
|
+
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
467
|
+
const animate = !immediate && !reduce && !document.hidden && !collapsed;
|
|
468
|
+
if (animate) {
|
|
469
|
+
stage.style.height = `${stage.offsetHeight}px`;
|
|
470
|
+
void stage.offsetHeight;
|
|
471
|
+
}
|
|
472
|
+
historyEl.querySelector(":scope > .hint")?.remove();
|
|
473
|
+
for (const el of pieces) {
|
|
474
|
+
historyEl.appendChild(el);
|
|
475
|
+
if (animate) {
|
|
476
|
+
el.classList.add("ts-arrive");
|
|
477
|
+
el.addEventListener("animationend", () => el.classList.remove("ts-arrive"), { once: true });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
release();
|
|
481
|
+
if (!animate) {
|
|
482
|
+
stage.hidden = true;
|
|
483
|
+
if (wasAtEnd) historyEl.scrollTop = historyEl.scrollHeight;
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
stage.classList.add("leaving");
|
|
487
|
+
stage.style.height = "0px";
|
|
488
|
+
if (wasAtEnd) historyEl.scrollTo({ top: historyEl.scrollHeight, behavior: "smooth" });
|
|
489
|
+
foldTimer = setTimeout(() => {
|
|
490
|
+
cancelFold();
|
|
491
|
+
stage.hidden = true;
|
|
492
|
+
if (wasAtEnd) historyEl.scrollTo({ top: historyEl.scrollHeight, behavior: "smooth" });
|
|
493
|
+
}, FOLD_MS);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── the view interface (app.js) ──────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
turnStarted(data) {
|
|
500
|
+
if (holding) {
|
|
501
|
+
if (endKind) handOff({ immediate: true });
|
|
502
|
+
else release();
|
|
503
|
+
}
|
|
504
|
+
holding = true;
|
|
505
|
+
endKind = null;
|
|
506
|
+
settled = true;
|
|
507
|
+
startedAt = Date.parse(data?.started_at) || Date.now();
|
|
508
|
+
emptySlots();
|
|
509
|
+
// The turn's prompt bubble (app.js found or drew it) sits in the prompt
|
|
510
|
+
// line until the hand-off takes it back first.
|
|
511
|
+
if (data?.promptBubble) promptSlot.appendChild(data.promptBubble);
|
|
512
|
+
view.turnStarted(data || {});
|
|
513
|
+
show();
|
|
514
|
+
refresh();
|
|
515
|
+
},
|
|
516
|
+
generationStarted: (data) => view.generationStarted(data),
|
|
517
|
+
chunk: (data) => view.chunk(data),
|
|
518
|
+
generationCompleted: () => view.generationCompleted(),
|
|
519
|
+
toolStarted: (data) => view.toolStarted(data),
|
|
520
|
+
toolCompleted: (data) => view.toolCompleted(data),
|
|
521
|
+
turnEnded(args) {
|
|
522
|
+
const kept = view.turnEnded(args) || [];
|
|
523
|
+
if (!holding) return kept;
|
|
524
|
+
view.chronological();
|
|
525
|
+
endKind = args.kind;
|
|
526
|
+
endedAt = Date.now();
|
|
527
|
+
// A completed turn's end read (markdown, answer_display) is pending
|
|
528
|
+
// until answerReady; the others have none.
|
|
529
|
+
settled = args.kind !== "completed";
|
|
530
|
+
clearTimeout(settleTimer);
|
|
531
|
+
if (!settled) settleTimer = setTimeout(() => { settled = true; }, SETTLE_CAP_MS);
|
|
532
|
+
refresh();
|
|
533
|
+
startWatch();
|
|
534
|
+
return kept;
|
|
535
|
+
},
|
|
536
|
+
notice(data) {
|
|
537
|
+
const placed = view.notice(data);
|
|
538
|
+
if (placed && data.level === "warn") {
|
|
539
|
+
flash = { text: data.line || data.text || "", until: Date.now() + FLASH_MS };
|
|
540
|
+
setTimeout(scheduleRefresh, FLASH_MS + 50);
|
|
541
|
+
}
|
|
542
|
+
return placed;
|
|
543
|
+
},
|
|
544
|
+
steer: (data) => view.steer(data),
|
|
545
|
+
card: (el) => view.card(el),
|
|
546
|
+
flush: () => view.flush(),
|
|
547
|
+
answerReady() {
|
|
548
|
+
settled = true;
|
|
549
|
+
clearTimeout(settleTimer);
|
|
550
|
+
view.answerReady();
|
|
551
|
+
},
|
|
552
|
+
containsLive: (el) => view.containsLive(el),
|
|
553
|
+
reset() {
|
|
554
|
+
cancelFold();
|
|
555
|
+
release();
|
|
556
|
+
stage.hidden = true;
|
|
557
|
+
},
|
|
558
|
+
// The stage's extras while it holds a turn (a card, a queued prompt, a
|
|
559
|
+
// command, a note, the end line); false: the history takes it.
|
|
560
|
+
place(el) {
|
|
561
|
+
const kind = el.matches?.(".bubble.recap") ? "recap" : "row";
|
|
562
|
+
if (placeFor({ live: holding, handingOff: false, kind }) !== "stage") return false;
|
|
563
|
+
const slot = endKind ? tail : extras;
|
|
564
|
+
const stick = slot.scrollHeight - slot.scrollTop - slot.clientHeight < 24;
|
|
565
|
+
slot.appendChild(el);
|
|
566
|
+
slot.hidden = false;
|
|
567
|
+
if (stick || el.matches?.(PENDING)) slot.scrollTop = slot.scrollHeight;
|
|
568
|
+
scheduleRefresh();
|
|
569
|
+
return true;
|
|
570
|
+
},
|
|
571
|
+
// Where the live timing line goes (the status row), null: the history.
|
|
572
|
+
timingHost: () => (holding ? timingSlot : null),
|
|
573
|
+
// A turn is in the stage (running, or ended and not handed off yet):
|
|
574
|
+
// nothing the turn does touches the history's scroll.
|
|
575
|
+
holds: () => holding,
|
|
576
|
+
handOff,
|
|
577
|
+
root: stage,
|
|
578
|
+
scrollEl: scroller,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
@@ -20,11 +20,15 @@ export function turnTimingText(number, durationMs, { running = false, canceled =
|
|
|
20
20
|
return `${turn}${running ? " running" : ""} · ${formatDuration(durationMs)}${canceled ? " · canceled" : ""}`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
//
|
|
23
|
+
// A cancel's reason as the terminal UIs name it too (Formatting::
|
|
24
|
+
// CANCEL_REASONS); anything else as it is.
|
|
25
|
+
const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook" };
|
|
26
|
+
|
|
27
|
+
// The line a canceled turn ends with: "✕ canceled (stopped)", live from
|
|
24
28
|
// turn_canceled's reason and after a re-render from the turn record's
|
|
25
29
|
// cancellation_reason (records saved before it existed: no reason).
|
|
26
30
|
export function cancelLineText(reason) {
|
|
27
|
-
return "\u2715 canceled" + (reason ? ` (${reason})` : "");
|
|
31
|
+
return "\u2715 canceled" + (reason ? ` (${CANCEL_REASONS[reason] || reason})` : "");
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
// A re-rendered turn's cancel line (a .bubble.cancel), or "" when the turn
|
|
@@ -45,10 +45,14 @@ export function reminderText(event) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
// Who a hook's notice is from: the bundle's name for a bundle hook
|
|
48
|
-
// ("known_names.rb (bundle known-names)"),
|
|
48
|
+
// ("known_names.rb (bundle known-names)"), chi's own notice by its word
|
|
49
|
+
// (thinking), else "hook".
|
|
49
50
|
export function hookNoticeLabel(hook) {
|
|
50
|
-
const
|
|
51
|
-
|
|
51
|
+
const name = String(hook || "");
|
|
52
|
+
const m = /\(bundle (.+)\)$/.exec(name);
|
|
53
|
+
if (m) return m[1];
|
|
54
|
+
// chi's own notices name themselves with a bare word (thinking).
|
|
55
|
+
return /^[a-z][a-z0-9-]*$/.test(name) ? name : "hook";
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
// The row a step gets when the loop asks again after an empty answer.
|
|
@@ -56,17 +60,38 @@ export function emptyRetryLine({ attempt, of } = {}) {
|
|
|
56
60
|
return `↻ empty answer, asking again (${attempt}/${of})`;
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
// The live turn's status while the provider is asked again after an error
|
|
64
|
+
// (generation_retrying): "↻ retrying (503) in 4 s, 2/5" (retry 2 of 5).
|
|
65
|
+
export function retryStatusLine({ attempt, max_retries, next_delay, status, error_class } = {}) {
|
|
66
|
+
const why = status || String(error_class || "").split("::").pop();
|
|
67
|
+
const delay = Number(next_delay);
|
|
68
|
+
const when = Number.isFinite(delay) ? ` in ${delay < 1 ? delay.toFixed(1) : Math.round(delay)} s` : "";
|
|
69
|
+
const count = attempt ? `, ${attempt}${max_retries ? `/${max_retries}` : ""}` : "";
|
|
70
|
+
return `↻ retrying${why ? ` (${why})` : ""}${when}${count}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The live turn's status while it waits for plugins' slow setup before its
|
|
74
|
+
// first request (plugin_init_wait).
|
|
75
|
+
export function initWaitLine({ tasks } = {}) {
|
|
76
|
+
const labels = (tasks || []).map((t) => [t?.bundle, t?.label].filter(Boolean).join(": ")).filter(Boolean);
|
|
77
|
+
return `waiting for ${labels.length ? labels.join(" · ") : "plugins"}…`;
|
|
78
|
+
}
|
|
79
|
+
|
|
59
80
|
// A composer line for the worker's command route rather than a prompt.
|
|
60
81
|
export function isCommandLine(text) {
|
|
61
82
|
return /^[/!]/.test(String(text || "").trim());
|
|
62
83
|
}
|
|
63
84
|
|
|
64
|
-
// The terminal's own commands
|
|
65
|
-
// the page shows itself (they never go to the worker, which doesn't
|
|
66
|
-
// them), or null.
|
|
85
|
+
// The terminal's own commands (a button in the web, or none yet): the
|
|
86
|
+
// reply the page shows itself (they never go to the worker, which doesn't
|
|
87
|
+
// know them), or null.
|
|
67
88
|
const WEB_LOCAL_REPLIES = {
|
|
68
89
|
"/archive": "/archive: use the archive button in the session bar",
|
|
90
|
+
"/detach": "/detach: a terminal's command; close the tab to leave, the worker keeps running",
|
|
69
91
|
"/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
|
|
92
|
+
"/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
|
|
93
|
+
"/recap": "/recap: not in the web yet; a recap shows here by itself when you come back to an idle session, and /recap in a terminal (chi --attach) makes one now",
|
|
94
|
+
"/stats": "/stats: not in the web yet; each turn shows its time and the context use, and /stats in a terminal (chi --attach) has the totals",
|
|
70
95
|
};
|
|
71
96
|
|
|
72
97
|
export function webLocalReply(text) {
|
|
@@ -211,12 +236,15 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
|
|
|
211
236
|
closeText();
|
|
212
237
|
const call = { iteration: part.iteration, call_index: part.call_index, tool: part.tool };
|
|
213
238
|
if (part.label) call.label = part.label;
|
|
214
|
-
|
|
239
|
+
const title = part.title ? { title: part.title } : {};
|
|
240
|
+
events.push({ type: "tool_call_started", ...call, params: part.params, ...title });
|
|
215
241
|
if (part.status !== "running") {
|
|
216
|
-
|
|
242
|
+
const completed = {
|
|
217
243
|
type: "tool_call_completed", ...call, output: part.output, output_truncated: !!part.output_truncated,
|
|
218
|
-
activity: { status: part.status, params: part.params },
|
|
219
|
-
}
|
|
244
|
+
activity: { status: part.status, params: part.params, ...title },
|
|
245
|
+
};
|
|
246
|
+
if (part.diff) completed.diff = part.diff;
|
|
247
|
+
events.push(withImages(completed, part.images));
|
|
220
248
|
}
|
|
221
249
|
break;
|
|
222
250
|
}
|