samagotchi 0.2.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 +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Plugins' slow setup (chi.init: an MCP server's first start, a model
|
|
2
|
+
// download) as a small row above the composer: a spinner line per running
|
|
3
|
+
// task ("mcp · Starting MCP server chrome (first run, saving its tools)…"),
|
|
4
|
+
// a "✓ chrome ready, 3 tools" line when one is done, which fades. A failed
|
|
5
|
+
// one leaves the row: its warn card says why. No DOM here, so node --test
|
|
6
|
+
// covers it; app.js draws the row.
|
|
7
|
+
import { escapeHtml as esc } from "./format.js";
|
|
8
|
+
|
|
9
|
+
// How long a done line stays before it fades out.
|
|
10
|
+
export const DONE_MS = 2500;
|
|
11
|
+
|
|
12
|
+
// @returns the row's state: task id → { bundle, label, status: running|done, summary }
|
|
13
|
+
export function newInitTasks() {
|
|
14
|
+
return new Map();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Replace the state with the tasks a snapshot says still run (a page that
|
|
18
|
+
// joins late, a resync); done lines go.
|
|
19
|
+
// @param list [{ bundle, id, label }]
|
|
20
|
+
export function seedInitTasks(tasks, list) {
|
|
21
|
+
tasks.clear();
|
|
22
|
+
for (const t of list || []) {
|
|
23
|
+
if (t && t.id) tasks.set(t.id, { bundle: t.bundle, label: t.label, status: "running" });
|
|
24
|
+
}
|
|
25
|
+
return tasks;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A plugin_init_started / plugin_init_finished event.
|
|
29
|
+
// @returns the id of a task that is now done (for its fade), else null
|
|
30
|
+
export function applyInitEvent(tasks, type, data) {
|
|
31
|
+
if (!data || !data.id) return null;
|
|
32
|
+
if (type === "plugin_init_started") {
|
|
33
|
+
tasks.set(data.id, { bundle: data.bundle, label: data.label, status: "running" });
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (type !== "plugin_init_finished") return null;
|
|
37
|
+
if (!data.ok) {
|
|
38
|
+
tasks.delete(data.id);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
tasks.set(data.id, { bundle: data.bundle, label: data.label, status: "done", summary: data.summary || null });
|
|
42
|
+
return data.id;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Drop a done line (its fade ended); a task started again under the id stays.
|
|
46
|
+
export function dropDone(tasks, id) {
|
|
47
|
+
if (tasks.get(id)?.status === "done") tasks.delete(id);
|
|
48
|
+
return tasks;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// @returns the row's inner HTML ("" with nothing to show)
|
|
52
|
+
export function initRowHtml(tasks) {
|
|
53
|
+
return [...tasks.entries()].map(([id, t]) => {
|
|
54
|
+
const bundle = `<span class="init-bundle">${esc(t.bundle)}</span>`;
|
|
55
|
+
if (t.status === "done") {
|
|
56
|
+
return `<div class="init-task done" data-id="${esc(id)}"><span class="init-mark" aria-hidden="true">✓</span>${bundle}<span class="init-text">${esc(t.summary || `${t.label}: done`)}</span></div>`;
|
|
57
|
+
}
|
|
58
|
+
return `<div class="init-task running" data-id="${esc(id)}"><span class="init-spin" aria-hidden="true"></span>${bundle}<span class="init-text">${esc(t.label)}…</span></div>`;
|
|
59
|
+
}).join("");
|
|
60
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The start page's model picker: pure helpers over GET /api/models' payload.
|
|
2
|
+
|
|
3
|
+
export const MODEL_KEY = "chi_model";
|
|
4
|
+
|
|
5
|
+
// The names to offer, in the server's order: the default host's models
|
|
6
|
+
// bare, the other hosts' as host:model. [] for a payload with none.
|
|
7
|
+
export function modelNames(payload) {
|
|
8
|
+
const list = Array.isArray(payload?.models) ? payload.models : [];
|
|
9
|
+
return list.map((m) => String(m?.name || "")).filter(Boolean);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The name to preselect: the remembered one when it is still offered (any
|
|
13
|
+
// case), else the server's default, else the first offered, else "".
|
|
14
|
+
export function pickModel(names, defaultName, stored) {
|
|
15
|
+
const want = String(stored || "").trim().toLowerCase();
|
|
16
|
+
if (want) {
|
|
17
|
+
const kept = names.find((n) => n.toLowerCase() === want);
|
|
18
|
+
if (kept) return kept;
|
|
19
|
+
}
|
|
20
|
+
if (defaultName && names.includes(defaultName)) return defaultName;
|
|
21
|
+
return defaultName || names[0] || "";
|
|
22
|
+
}
|
|
23
|
+
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// What a question card shows, apart from the DOM. An approval (kind
|
|
2
|
+
// "approval", from the tool guardrails) gets its own view: the tool, the
|
|
3
|
+
// command or paths, where and why. Its options end in "Deny"; the answer's
|
|
4
|
+
// last option denies. A page that doesn't know approvals still shows the
|
|
5
|
+
// plain question text, which says the same.
|
|
6
|
+
|
|
7
|
+
export function isApproval(pq) {
|
|
8
|
+
return !!pq && pq.kind === "approval";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// @return {null | {tool, what, isCommand, where, why}}
|
|
12
|
+
export function approvalView(pq) {
|
|
13
|
+
if (!isApproval(pq)) return null;
|
|
14
|
+
const a = pq.approval || {};
|
|
15
|
+
const paths = Array.isArray(a.paths) ? a.paths : [];
|
|
16
|
+
const command = a.command || null;
|
|
17
|
+
let where = a.cwd || "";
|
|
18
|
+
if (a.repo_root) {
|
|
19
|
+
const repo = String(a.repo_root).split("/").filter(Boolean).pop() || a.repo_root;
|
|
20
|
+
where += ` (repo ${repo}${a.branch ? `, branch ${a.branch}` : ""})`;
|
|
21
|
+
} else if (where) {
|
|
22
|
+
where += " (not in a repo)";
|
|
23
|
+
}
|
|
24
|
+
const who = a.rule ? [`rule ${a.rule}`, a.source].filter(Boolean).join(", ") : (a.source || "hook");
|
|
25
|
+
const reason = a.reason ? String(a.reason) : "(no reason given)";
|
|
26
|
+
return {
|
|
27
|
+
// A plugin tool's label ("chrome: screenshot"), as its row shows it.
|
|
28
|
+
tool: a.label || a.tool || "",
|
|
29
|
+
what: command || paths.join("\n"),
|
|
30
|
+
isCommand: !!command,
|
|
31
|
+
where,
|
|
32
|
+
why: `${reason} (${who})`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The line a resolved card shows.
|
|
37
|
+
export function resultText(pq, { answer = null, cancelled = false, reason = "" } = {}) {
|
|
38
|
+
const approval = isApproval(pq);
|
|
39
|
+
if (cancelled) {
|
|
40
|
+
const label = approval ? "Denied" : "Cancelled";
|
|
41
|
+
return reason ? `${label} (${reason})` : label;
|
|
42
|
+
}
|
|
43
|
+
const sel = answer && Array.isArray(answer.selected) ? answer.selected : [];
|
|
44
|
+
const freeform = answer && answer.freeform ? String(answer.freeform) : "";
|
|
45
|
+
if (approval) {
|
|
46
|
+
const options = Array.isArray(pq.options) ? pq.options : [];
|
|
47
|
+
const denied = !sel.length || sel[0] === options[options.length - 1];
|
|
48
|
+
if (denied) return freeform ? `Denied: ${freeform}` : "Denied";
|
|
49
|
+
return `Allowed: ${sel[0]}`;
|
|
50
|
+
}
|
|
51
|
+
const parts = [];
|
|
52
|
+
if (sel.length) parts.push(sel.join(", "));
|
|
53
|
+
if (freeform) parts.push(freeform);
|
|
54
|
+
return parts.length ? `Answered: ${parts.join(" · ")}` : "Answered";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Shorten a line of text for the card's summary (the collapsed one-liner).
|
|
58
|
+
export function truncate(text, n = 80) {
|
|
59
|
+
const s = String(text ?? "");
|
|
60
|
+
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The card's summary line: while pending its header, else a label (the
|
|
64
|
+
// question itself is in the body, so it isn't repeated here); once resolved
|
|
65
|
+
// what was asked and the result, "Which file? → README.md" (an approval
|
|
66
|
+
// asks its tool and command: "execute: rm -rf tmp → Denied").
|
|
67
|
+
export function summaryText(pq, { answer = null, cancelled = false, reason = "" } = {}) {
|
|
68
|
+
if (cancelled || answer) {
|
|
69
|
+
const result = resultText(pq, { answer, cancelled, reason }).replace(/^Answered: /, "");
|
|
70
|
+
const asked = summaryAsked(pq);
|
|
71
|
+
return truncate(asked ? `${truncate(asked, 60)} → ${result}` : result, 120);
|
|
72
|
+
}
|
|
73
|
+
const label = isApproval(pq) ? `Approve ${pq.approval?.label || pq.approval?.tool || "call"}?` : "Question";
|
|
74
|
+
return truncate(pq.header || label);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function summaryAsked(pq) {
|
|
78
|
+
const view = approvalView(pq);
|
|
79
|
+
if (view) {
|
|
80
|
+
const what = String(view.what || "").split("\n")[0];
|
|
81
|
+
return [view.tool, what].filter(Boolean).join(": ");
|
|
82
|
+
}
|
|
83
|
+
return String(pq.question || pq.header || "").split("\n")[0];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Whether the answer allowed the call (the card turns green) or denied it.
|
|
87
|
+
export function approvalAllowed(pq, answer) {
|
|
88
|
+
if (!isApproval(pq)) return null;
|
|
89
|
+
return resultText(pq, { answer }).startsWith("Allowed");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// What a new question does to the card on the page: "same" (the card
|
|
93
|
+
// already shows it: a replay), "keep" (the card is resolved, so it stays in
|
|
94
|
+
// the history and the new one goes under it), "replace" (the card is still
|
|
95
|
+
// pending, or there is none).
|
|
96
|
+
export function nextCardAction({ cardId = null, pendingId = null } = {}, incoming) {
|
|
97
|
+
if (cardId != null && String(cardId) === String(incoming?.id)) return "same";
|
|
98
|
+
if (cardId != null && pendingId == null) return "keep";
|
|
99
|
+
return "replace";
|
|
100
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The page's place in its URL hash: #/s/<session id> for an open session,
|
|
2
|
+
// #/sessions for the all-sessions view, nothing for the empty start.
|
|
3
|
+
export const ALL_SESSIONS_HASH = "#/sessions";
|
|
4
|
+
|
|
5
|
+
export function sessionHash(id) {
|
|
6
|
+
return id ? `#/s/${encodeURIComponent(id)}` : "";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function sessionIdFromHash(hash) {
|
|
10
|
+
const m = /^#\/s\/([^/?#]+)$/.exec(hash || "");
|
|
11
|
+
if (!m) return null;
|
|
12
|
+
try {
|
|
13
|
+
return decodeURIComponent(m[1]) || null;
|
|
14
|
+
} catch (_) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The page's scope: ?dir=<folder> shows that folder's project (`chi web` in
|
|
2
|
+
// a repo opens it so); no dir shows every session. The hash routes
|
|
3
|
+
// (#/s/<id>, #/sessions) work in either.
|
|
4
|
+
|
|
5
|
+
// The ?dir= folder in a location.search string, or null.
|
|
6
|
+
export function scopeDir(search) {
|
|
7
|
+
const dir = new URLSearchParams(search || "").get("dir");
|
|
8
|
+
return dir && dir.trim() ? dir : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// The same place in the all view: the page without ?dir, keeping the hash.
|
|
12
|
+
// +fromDir+ (the project view's folder) rides along as ?from=, so the all
|
|
13
|
+
// view can link back to it.
|
|
14
|
+
export function allScopeHref(hash, fromDir = null) {
|
|
15
|
+
return `/${fromDir ? `?from=${dirParam(fromDir)}` : ""}${hash || ""}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A folder's project view, keeping the hash.
|
|
19
|
+
export function projectScopeHref(dir, hash) {
|
|
20
|
+
return `/?dir=${dirParam(dir)}${hash || ""}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A folder for the query string, its slashes left readable (as chi web
|
|
24
|
+
// prints ?dir=).
|
|
25
|
+
function dirParam(dir) {
|
|
26
|
+
return encodeURIComponent(dir).replace(/%2F/g, "/");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The folder a card shows when the scope doesn't make it obvious: every
|
|
30
|
+
// card in the all view, and in a project view a card from another folder
|
|
31
|
+
// than the project's own (a worktree). "" when it is obvious.
|
|
32
|
+
export function cardFolder(workingDirectory, projectName) {
|
|
33
|
+
const folder = String(workingDirectory || "").replace(/\/+$/, "").split("/").pop() || "";
|
|
34
|
+
if (!folder) return "";
|
|
35
|
+
return folder === projectName ? "" : folder;
|
|
36
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Pure sticky-follow predicate for the conversation viewport.
|
|
2
|
+
// A user "follows" the live stream only while the visible gap between the
|
|
3
|
+
// bottom of the viewport and the bottom of the content is within the
|
|
4
|
+
// threshold. Once they scroll up to read, the gap grows past the threshold
|
|
5
|
+
// and the app must stop auto-scrolling (no yank-back on each stream frame).
|
|
6
|
+
export const SCROLL_FOLLOW_PX = 64;
|
|
7
|
+
|
|
8
|
+
export function shouldFollowScroll(
|
|
9
|
+
{ scrollHeight, scrollTop, clientHeight },
|
|
10
|
+
thresholdPx = SCROLL_FOLLOW_PX,
|
|
11
|
+
) {
|
|
12
|
+
return scrollHeight - scrollTop - clientHeight <= thresholdPx;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Run a DOM change that grows the content and keep a pinned view pinned:
|
|
16
|
+
// the follow decision is taken before the change, as the gap measured after
|
|
17
|
+
// several rows land at once (a turn's start adds the answer bubble and the
|
|
18
|
+
// live timing row) is already past the threshold.
|
|
19
|
+
export function keepFollowing(el, mutate, thresholdPx = SCROLL_FOLLOW_PX) {
|
|
20
|
+
const follow = shouldFollowScroll(el, thresholdPx);
|
|
21
|
+
const result = mutate();
|
|
22
|
+
if (follow) el.scrollTop = el.scrollHeight;
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Sentence scanning for the live views (the thinking ticker, the narration
|
|
2
|
+
// box). Pure — no DOM, no timers.
|
|
3
|
+
//
|
|
4
|
+
// Sentence boundary: [.!?…] optionally followed by closing quotes/brackets,
|
|
5
|
+
// then whitespace; a newline is also a boundary. The end of the input counts
|
|
6
|
+
// as a boundary only when the caller says so (`atEnd`): a ticker shows the
|
|
7
|
+
// newest sentence and corrects itself next frame, an append-only box cannot
|
|
8
|
+
// take "version 3." back once "version 3.5" streams in. Whitespace is
|
|
9
|
+
// collapsed to single spaces. Abbreviations ("e.g. ") split wrongly —
|
|
10
|
+
// accepted for a live view. A bare list number ("1.", "118.") at the start
|
|
11
|
+
// of a line is a marker, not a sentence: the boundary after it is skipped,
|
|
12
|
+
// so the line reads "118. …".
|
|
13
|
+
export const DEFAULT_MAX_LEN = 160;
|
|
14
|
+
|
|
15
|
+
// A boundary: the punctuation (group 1) then a whitespace char (or the end
|
|
16
|
+
// of input), or a bare newline (a boundary on its own). Matched on the raw
|
|
17
|
+
// text so a newline needs no preceding punctuation.
|
|
18
|
+
const BOUNDARY = /([.!?…][)"'\]]*)(\s|$)|\n/g;
|
|
19
|
+
const BOUNDARY_NOT_AT_END = /([.!?…][)"'\]]*)(\s)|\n/g;
|
|
20
|
+
const LIST_MARKER = /^\d{1,4}\.$/;
|
|
21
|
+
|
|
22
|
+
function collapse(s) {
|
|
23
|
+
return s.replace(/\s+/g, " ").trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Every complete sentence in +raw+ (collapsed, blanks skipped), the trailing
|
|
27
|
+
// raw fragment and the index where it starts.
|
|
28
|
+
export function splitSentences(text, { atEnd = false } = {}) {
|
|
29
|
+
const raw = String(text ?? "");
|
|
30
|
+
const re = atEnd ? BOUNDARY : BOUNDARY_NOT_AT_END;
|
|
31
|
+
const sentences = [];
|
|
32
|
+
let start = 0;
|
|
33
|
+
let m;
|
|
34
|
+
re.lastIndex = 0;
|
|
35
|
+
while ((m = re.exec(raw)) !== null) {
|
|
36
|
+
const end = m.index + (m[1] ? m[1].length : 0);
|
|
37
|
+
const candidate = raw.slice(start, end);
|
|
38
|
+
// A list number is no sentence: keep reading from the same start.
|
|
39
|
+
if (LIST_MARKER.test(candidate.trim())) continue;
|
|
40
|
+
const collapsed = collapse(candidate);
|
|
41
|
+
// Skip an empty sentence (a blank line between two newlines).
|
|
42
|
+
if (collapsed) sentences.push(collapsed);
|
|
43
|
+
start = m.index + m[0].length;
|
|
44
|
+
}
|
|
45
|
+
return { sentences, rest: raw.slice(start), restStart: start };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A feed over an accumulating text: `feed(text)` returns the sentences
|
|
49
|
+
// completed since the last call (the end of the text is no boundary; a
|
|
50
|
+
// trailing fragment longer than maxLen is cut as a sentence — code-like runs
|
|
51
|
+
// without punctuation); `flush(text)` returns what is left, the remainder as
|
|
52
|
+
// one collapsed sentence, and consumes it; `reset()` starts over. A text
|
|
53
|
+
// shorter than what was consumed (a reset upstream) resyncs and yields [].
|
|
54
|
+
export function createSentenceFeed({ maxLen = DEFAULT_MAX_LEN } = {}) {
|
|
55
|
+
let consumed = 0;
|
|
56
|
+
|
|
57
|
+
function scan(text, atEnd) {
|
|
58
|
+
const raw = String(text ?? "");
|
|
59
|
+
if (raw.length < consumed) { consumed = raw.length; return null; }
|
|
60
|
+
const r = splitSentences(raw.slice(consumed), { atEnd });
|
|
61
|
+
consumed += r.restStart;
|
|
62
|
+
return { ...r, total: raw.length };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
feed(text) {
|
|
67
|
+
const r = scan(text, false);
|
|
68
|
+
if (!r) return [];
|
|
69
|
+
const fragment = collapse(r.rest);
|
|
70
|
+
if (fragment.length > maxLen) {
|
|
71
|
+
r.sentences.push(fragment);
|
|
72
|
+
consumed = r.total;
|
|
73
|
+
}
|
|
74
|
+
return r.sentences;
|
|
75
|
+
},
|
|
76
|
+
flush(text) {
|
|
77
|
+
const r = scan(text, true);
|
|
78
|
+
if (!r) return [];
|
|
79
|
+
const fragment = collapse(r.rest);
|
|
80
|
+
if (fragment) r.sentences.push(fragment);
|
|
81
|
+
consumed = r.total;
|
|
82
|
+
return r.sentences;
|
|
83
|
+
},
|
|
84
|
+
reset() {
|
|
85
|
+
consumed = 0;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The page's session list as a projection of the hub's events
|
|
2
|
+
// (GET /api/events): a pure reducer over the list the page holds.
|
|
3
|
+
|
|
4
|
+
// @param list the current list (never mutated)
|
|
5
|
+
// @param type "snapshot" | "session" | "session_gone"
|
|
6
|
+
// @param data the frame's data
|
|
7
|
+
// @return the next list; the same array when nothing changed, so a caller
|
|
8
|
+
// can skip a redraw
|
|
9
|
+
export function applySessionEvent(list, type, data = {}) {
|
|
10
|
+
if (type === "snapshot") return Array.isArray(data.sessions) ? data.sessions.slice() : [];
|
|
11
|
+
if (type === "session") {
|
|
12
|
+
const s = data.session;
|
|
13
|
+
if (!s || !s.id) return list;
|
|
14
|
+
const i = list.findIndex((x) => x.id === s.id);
|
|
15
|
+
// A known session is replaced in place: a worker saves several times
|
|
16
|
+
// per turn, and a card that jumped under the pointer each time would
|
|
17
|
+
// be worse than an order that lags until the next snapshot or the
|
|
18
|
+
// all view (sortedByUpdated). A new one goes on top.
|
|
19
|
+
if (i < 0) return [s, ...list];
|
|
20
|
+
const next = list.slice();
|
|
21
|
+
next[i] = s;
|
|
22
|
+
return next;
|
|
23
|
+
}
|
|
24
|
+
if (type === "session_gone") {
|
|
25
|
+
if (!list.some((x) => x.id === data.id)) return list;
|
|
26
|
+
return list.filter((x) => x.id !== data.id);
|
|
27
|
+
}
|
|
28
|
+
return list;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const updatedMs = (s) => {
|
|
32
|
+
const ms = Date.parse(s.updated_at || "");
|
|
33
|
+
return Number.isNaN(ms) ? 0 : ms;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// The list in the server's order: updated_at desc (a snapshot's order).
|
|
37
|
+
export function sortedByUpdated(list) {
|
|
38
|
+
return list.slice().sort((a, b) => updatedMs(b) - updatedMs(a));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A delegated session (parent_id) right after its parent when both are in
|
|
42
|
+
// the list: families keep the place of their newest member, the parent
|
|
43
|
+
// leads, its children follow in the list's order. A child whose parent
|
|
44
|
+
// is not listed stays where it is.
|
|
45
|
+
export function withChildrenAfterParents(list) {
|
|
46
|
+
const ids = new Set(list.map((s) => s.id));
|
|
47
|
+
const children = new Map();
|
|
48
|
+
for (const s of list) {
|
|
49
|
+
if (s.parent_id && ids.has(s.parent_id) && s.parent_id !== s.id) {
|
|
50
|
+
if (!children.has(s.parent_id)) children.set(s.parent_id, []);
|
|
51
|
+
children.get(s.parent_id).push(s);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!children.size) return list;
|
|
55
|
+
const grouped = (s) => s.parent_id && ids.has(s.parent_id) && s.parent_id !== s.id;
|
|
56
|
+
const familyMs = (s) => Math.max(updatedMs(s), ...(children.get(s.id) || []).map(updatedMs));
|
|
57
|
+
const heads = list.filter((s) => !grouped(s));
|
|
58
|
+
const ordered = heads.slice().sort((a, b) => familyMs(b) - familyMs(a));
|
|
59
|
+
return ordered.flatMap((s) => [s, ...(children.get(s.id) || [])]);
|
|
60
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// How many session cards the top strip shows for its width in px: a card
|
|
2
|
+
// needs about 360 px plus a 12 px gap next to the 150 px "All sessions" tile;
|
|
3
|
+
// never fewer than 3 (narrow screens scroll them), never more than 6.
|
|
4
|
+
export const STRIP_TILE_PX = 150;
|
|
5
|
+
export const STRIP_GAP_PX = 12;
|
|
6
|
+
export const STRIP_CARD_PX = 360;
|
|
7
|
+
|
|
8
|
+
export function stripColumns(width) {
|
|
9
|
+
const fit = Math.floor(((width || 0) - STRIP_TILE_PX - STRIP_GAP_PX) / (STRIP_CARD_PX + STRIP_GAP_PX));
|
|
10
|
+
return Math.min(6, Math.max(3, fit));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The hidden strip's "▾ sessions" pill carries the list's count, and how
|
|
14
|
+
// many of those sessions a worker runs (the cards' green "live" badge), so a
|
|
15
|
+
// user who hid the strip still sees there is a live session to go back to.
|
|
16
|
+
// Plain text parts; live is "" when none. Zero sessions keep the bare label.
|
|
17
|
+
export function stripShowParts(sessions) {
|
|
18
|
+
const list = sessions || [];
|
|
19
|
+
const n = list.length;
|
|
20
|
+
const liveN = list.filter((s) => s && s.owner === "worker").length;
|
|
21
|
+
return {
|
|
22
|
+
count: n ? `${n} ${n === 1 ? "session" : "sessions"}` : "sessions",
|
|
23
|
+
live: liveN ? `${liveN} live` : "",
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The one-line tool tally of a long, tool-heavy turn, from its activity rows
|
|
2
|
+
// (activity.js): "12 tool calls (2 failed) · execute ×7 · read_file ×3 · …".
|
|
3
|
+
// The TUI builds the same text in Ruby (TurnTally); both follow
|
|
4
|
+
// spec/shared/tally_matrix.json. No DOM here, so node --test covers it.
|
|
5
|
+
|
|
6
|
+
// Below this many calls the running row says enough.
|
|
7
|
+
export const MIN_CALLS = 3;
|
|
8
|
+
import { toolName } from "./activity.js";
|
|
9
|
+
|
|
10
|
+
const TOP_TOOLS = 3;
|
|
11
|
+
const SEPARATOR = " · ";
|
|
12
|
+
|
|
13
|
+
// @param rows activity rows ({ tool, params, status: running|ok|error }) in call order
|
|
14
|
+
// @param opts.last end with the last call and its params (the web drops it: the rows show it)
|
|
15
|
+
// @returns the tally text, or null below MIN_CALLS
|
|
16
|
+
export function tallyText(rows, { last = true } = {}) {
|
|
17
|
+
const calls = rows || [];
|
|
18
|
+
if (calls.length < MIN_CALLS) return null;
|
|
19
|
+
|
|
20
|
+
const failed = calls.filter((r) => r.status === "error").length;
|
|
21
|
+
let head = `${calls.length} tool calls`;
|
|
22
|
+
if (failed > 0) head += ` (${failed} failed)`;
|
|
23
|
+
// A Map keeps first-use order; the index breaks ties.
|
|
24
|
+
const counts = new Map();
|
|
25
|
+
for (const r of calls) counts.set(toolName(r), (counts.get(toolName(r)) || 0) + 1);
|
|
26
|
+
const top = [...counts.entries()]
|
|
27
|
+
.map(([tool, n], i) => ({ tool, n, i }))
|
|
28
|
+
.sort((a, b) => b.n - a.n || a.i - b.i)
|
|
29
|
+
.slice(0, TOP_TOOLS);
|
|
30
|
+
const fields = [head, ...top.map(({ tool, n }) => `${tool} ×${n}`)];
|
|
31
|
+
if (last) {
|
|
32
|
+
const call = calls[calls.length - 1];
|
|
33
|
+
const params = String(call.params || "").replace(/\s+/g, " ").trim();
|
|
34
|
+
fields.push(`last: ${[toolName(call), params].filter((s) => s).join(" ")}`);
|
|
35
|
+
}
|
|
36
|
+
return fields.join(SEPARATOR);
|
|
37
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Live thinking as a one-line ticker (web.thinking_ticker): the newest
|
|
2
|
+
// complete sentence of the accumulated thinking, changing only at sentence
|
|
3
|
+
// boundaries and no sooner than a dwell after the last change. Pure — no DOM,
|
|
4
|
+
// no timers. `createTicker` is clock-free: `now` is a getter the caller
|
|
5
|
+
// supplies (Date.now() live, a fixed value in the tests) and the caller owns
|
|
6
|
+
// the timers (`dueIn` tells when a pending line may show).
|
|
7
|
+
//
|
|
8
|
+
// The sentence boundary rule lives in sentences.js (shared with the
|
|
9
|
+
// narration box). Fallback for code-like runs without punctuation: a
|
|
10
|
+
// trailing fragment longer than maxLen (160) counts as the current sentence
|
|
11
|
+
// (the dwell bounds its churn).
|
|
12
|
+
import { DEFAULT_MAX_LEN, splitSentences } from "./sentences.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_DWELL_MS = 1500;
|
|
15
|
+
|
|
16
|
+
// The newest complete sentence of +text+ (the end of the text counts as a
|
|
17
|
+
// boundary: the ticker corrects itself next frame), else the trailing
|
|
18
|
+
// fragment when longer than maxLen, else "".
|
|
19
|
+
export function currentSentence(text, { maxLen = DEFAULT_MAX_LEN } = {}) {
|
|
20
|
+
const { sentences, rest } = splitSentences(text, { atEnd: true });
|
|
21
|
+
if (sentences.length) return sentences[sentences.length - 1];
|
|
22
|
+
const fragment = rest.replace(/\s+/g, " ").trim();
|
|
23
|
+
return fragment.length > maxLen ? fragment : "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A sentence ticker. `feed(text)` computes the newest sentence and, honouring
|
|
27
|
+
// the dwell, either shows it now or keeps it pending; `tick()` shows a pending
|
|
28
|
+
// line when its dwell has passed; `close()` forgets everything (the caller
|
|
29
|
+
// writes the plain label). Each returns { line, changed, dueIn } where dueIn
|
|
30
|
+
// is the ms until a pending line may show (0 when nothing pends).
|
|
31
|
+
export function createTicker({ now, dwellMs = DEFAULT_DWELL_MS }) {
|
|
32
|
+
let shown = "";
|
|
33
|
+
let pending = null;
|
|
34
|
+
let closed = false;
|
|
35
|
+
let lastChange = typeof now === "function" ? now() : now;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
feed(text) {
|
|
39
|
+
if (closed) return { line: "", changed: false, dueIn: 0 };
|
|
40
|
+
if (pending !== null && currentSentence(text) === pending) {
|
|
41
|
+
// Still waiting on the dwell; nothing to do.
|
|
42
|
+
return { line: shown, changed: false, dueIn: Math.max(0, dwellMs - (now() - lastChange)) };
|
|
43
|
+
}
|
|
44
|
+
const candidate = currentSentence(text);
|
|
45
|
+
if (!candidate || candidate === shown) {
|
|
46
|
+
return { line: shown, changed: false, dueIn: 0 };
|
|
47
|
+
}
|
|
48
|
+
const elapsed = now() - lastChange;
|
|
49
|
+
// Show now if nothing is shown yet, or the dwell has passed since the
|
|
50
|
+
// last shown change.
|
|
51
|
+
if (shown === "" || elapsed >= dwellMs) {
|
|
52
|
+
shown = candidate;
|
|
53
|
+
pending = null;
|
|
54
|
+
lastChange = now();
|
|
55
|
+
return { line: shown, changed: true, dueIn: 0 };
|
|
56
|
+
}
|
|
57
|
+
// Keep the newest candidate as pending; feed overwrites it.
|
|
58
|
+
pending = candidate;
|
|
59
|
+
return { line: shown, changed: false, dueIn: Math.max(0, dwellMs - elapsed) };
|
|
60
|
+
},
|
|
61
|
+
tick() {
|
|
62
|
+
if (closed) return { line: "", changed: false, dueIn: 0 };
|
|
63
|
+
if (pending !== null && now() - lastChange >= dwellMs) {
|
|
64
|
+
shown = pending;
|
|
65
|
+
pending = null;
|
|
66
|
+
lastChange = now();
|
|
67
|
+
return { line: shown, changed: true, dueIn: 0 };
|
|
68
|
+
}
|
|
69
|
+
return { line: shown, changed: false, dueIn: pending ? Math.max(0, dwellMs - (now() - lastChange)) : 0 };
|
|
70
|
+
},
|
|
71
|
+
close() {
|
|
72
|
+
shown = "";
|
|
73
|
+
pending = null;
|
|
74
|
+
closed = true;
|
|
75
|
+
lastChange = typeof now === "function" ? now() : now;
|
|
76
|
+
return "";
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|