samagotchi 0.61.0 → 0.63.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 +91 -1
- data/README.md +4 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +3 -0
- data/docs/cli.md +26 -9
- data/docs/configuration.md +33 -20
- data/docs/context.md +21 -3
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +14 -3
- data/docs/internals/engine.md +24 -11
- data/docs/internals/prompt-caching.md +6 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +131 -5
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +4 -4
- data/docs/sub-agent.md +6 -0
- data/docs/testing.md +10 -4
- data/lib/samagotchi/answer_refs.rb +18 -26
- data/lib/samagotchi/bridge.rb +23 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/context_absorber.rb +25 -6
- data/lib/samagotchi/engine.rb +9 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/idle_recap.rb +2 -3
- data/lib/samagotchi/image_store.rb +8 -0
- data/lib/samagotchi/kernel_loop.rb +7 -16
- data/lib/samagotchi/llm/chat_loop.rb +23 -15
- data/lib/samagotchi/plugin/api.rb +36 -18
- data/lib/samagotchi/plugin/attached_context.rb +7 -4
- data/lib/samagotchi/reasoning_history.rb +22 -4
- data/lib/samagotchi/recap_setup.rb +3 -3
- data/lib/samagotchi/row_refs.rb +125 -0
- data/lib/samagotchi/session.rb +12 -0
- data/lib/samagotchi/session_metrics.rb +24 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +26 -9
- data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +57 -14
- data/lib/samagotchi/tool_activity.rb +8 -4
- data/lib/samagotchi/tool_response.rb +5 -4
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +16 -3
- data/lib/samagotchi/tools/builtins.rb +9 -2
- data/lib/samagotchi/tools/execute.rb +37 -7
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/registry.rb +5 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +20 -5
- data/lib/samagotchi/web/message_parts.rb +17 -9
- data/lib/samagotchi/web/public/activity.js +6 -3
- data/lib/samagotchi/web/public/app.js +106 -106
- data/lib/samagotchi/web/public/card.js +73 -23
- data/lib/samagotchi/web/public/chains.js +24 -24
- data/lib/samagotchi/web/public/command_view.js +39 -35
- data/lib/samagotchi/web/public/context_chips.js +92 -64
- data/lib/samagotchi/web/public/copy.js +7 -15
- data/lib/samagotchi/web/public/diff_view.js +20 -23
- data/lib/samagotchi/web/public/families_view.js +51 -39
- data/lib/samagotchi/web/public/format.js +0 -48
- data/lib/samagotchi/web/public/images.js +12 -16
- data/lib/samagotchi/web/public/index.html +61 -10
- data/lib/samagotchi/web/public/keep_focus.js +24 -0
- data/lib/samagotchi/web/public/lists_view.js +13 -13
- data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
- data/lib/samagotchi/web/public/question_card.js +11 -0
- data/lib/samagotchi/web/public/question_cards.js +63 -7
- data/lib/samagotchi/web/public/read_view.js +10 -9
- data/lib/samagotchi/web/public/ref_menu.js +18 -5
- data/lib/samagotchi/web/public/refs.js +113 -15
- data/lib/samagotchi/web/public/scope.js +23 -0
- data/lib/samagotchi/web/public/scroll.js +42 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +299 -70
- data/lib/samagotchi/web/public/timing.js +1 -8
- data/lib/samagotchi/web/public/turn_html.js +122 -61
- data/lib/samagotchi/web/public/turn_model.js +49 -0
- data/lib/samagotchi/web/public/turn_view.js +167 -116
- data/lib/samagotchi/web/public/vendor/directive.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
- data/lib/samagotchi/worker.rb +49 -32
- data/lib/samagotchi/worker_inbound.rb +17 -14
- metadata +10 -1
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -921,7 +921,7 @@ module Samagotchi
|
|
|
921
921
|
state = nil
|
|
922
922
|
session_json = { id: id, status: displayed_status(session), used_memory_names: Array(session.used_memory_names),
|
|
923
923
|
prompt_notes: session.prompt_notes.map(&:to_h) }
|
|
924
|
-
messages = last_assistant_for_display(session.messages, turn_id: turn_id)
|
|
924
|
+
messages = last_assistant_for_display(session.messages, turn_id: turn_id, cwd: session.working_directory)
|
|
925
925
|
end
|
|
926
926
|
live_metrics = state && state["metrics"]
|
|
927
927
|
# Without recent=1: a tab opened before this release (it replaces its
|
|
@@ -1696,7 +1696,8 @@ module Samagotchi
|
|
|
1696
1696
|
source = if live
|
|
1697
1697
|
persisted.merge(live_metrics).merge(
|
|
1698
1698
|
"started_at" => persisted["started_at"] || live_metrics["started_at"],
|
|
1699
|
-
"turn_records" => merge_timing_records(persisted["turn_records"], live_metrics
|
|
1699
|
+
"turn_records" => merge_timing_records(without_running_turn(persisted["turn_records"], live_metrics),
|
|
1700
|
+
live_metrics["turn_records"]),
|
|
1700
1701
|
"tool_records" => merge_timing_records(persisted["tool_records"], live_metrics["tool_records"])
|
|
1701
1702
|
)
|
|
1702
1703
|
else
|
|
@@ -1777,6 +1778,18 @@ module Samagotchi
|
|
|
1777
1778
|
AnalyticsFile.read(@session_class.session_dir(session_id, state_dir: default_state_dir)) || {}
|
|
1778
1779
|
end
|
|
1779
1780
|
|
|
1781
|
+
# The saved turn records without the one the live worker is running:
|
|
1782
|
+
# its turn checkpoint saves that turn's record so far as a killed
|
|
1783
|
+
# turn's (canceled, worker_stopped, SessionMetrics#persist's
|
|
1784
|
+
# running_turn), which is only true once the worker is gone.
|
|
1785
|
+
def without_running_turn(records, live_metrics)
|
|
1786
|
+
active = live_metrics["active_turn"]
|
|
1787
|
+
id = active.is_a?(Hash) ? active["id"] || active[:id] : nil
|
|
1788
|
+
return records if id.nil?
|
|
1789
|
+
|
|
1790
|
+
Array(records).reject { |record| record.is_a?(Hash) && (record["id"] || record[:id]) == id }
|
|
1791
|
+
end
|
|
1792
|
+
|
|
1780
1793
|
def merge_timing_records(persisted, live)
|
|
1781
1794
|
(Array(persisted) + Array(live)).each_with_object({}) do |record, records|
|
|
1782
1795
|
next unless record.is_a?(Hash)
|
|
@@ -1910,7 +1923,7 @@ module Samagotchi
|
|
|
1910
1923
|
message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
|
|
1911
1924
|
# The refs after_turn hooks attached (AnswerRefs), checked again:
|
|
1912
1925
|
# a stored file is never trusted. Never raises.
|
|
1913
|
-
refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"])
|
|
1926
|
+
refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"], cwd: cwd)
|
|
1914
1927
|
message[:display_refs] = refs unless refs.empty?
|
|
1915
1928
|
end
|
|
1916
1929
|
message[:parts] = did if did
|
|
@@ -1955,8 +1968,10 @@ module Samagotchi
|
|
|
1955
1968
|
|
|
1956
1969
|
# The last message messages_for_display shows as an answer
|
|
1957
1970
|
# (AnswerTail), as a list of at most one: only that one is rendered.
|
|
1958
|
-
|
|
1959
|
-
|
|
1971
|
+
# +cwd+: the session's folder, for its file refs (nil from a live
|
|
1972
|
+
# worker's tail: its refs were held to the folder when they were added).
|
|
1973
|
+
def last_assistant_for_display(msgs, turn_id: nil, cwd: nil)
|
|
1974
|
+
messages_for_display([AnswerTail.find(msgs, turn_id: turn_id)].compact, cwd: cwd)
|
|
1960
1975
|
end
|
|
1961
1976
|
|
|
1962
1977
|
def parse_json(str)
|
|
@@ -9,6 +9,7 @@ require_relative "../tools/builtins"
|
|
|
9
9
|
require_relative "../image_store"
|
|
10
10
|
require_relative "../llm_context_edit"
|
|
11
11
|
require_relative "../token_usage"
|
|
12
|
+
require_relative "../row_refs"
|
|
12
13
|
|
|
13
14
|
module Samagotchi
|
|
14
15
|
module Web
|
|
@@ -37,7 +38,8 @@ module Samagotchi
|
|
|
37
38
|
# A plugin tool's params are what its live row showed (its preview),
|
|
38
39
|
# saved with the result as tool_params (native: one per call, chat: one
|
|
39
40
|
# per result); its label ("chrome: screenshot") likewise as tool_labels,
|
|
40
|
-
# the part's label
|
|
41
|
+
# the part's label:, and its row refs as tool_refs (RowRefs, checked
|
|
42
|
+
# again here), the part's refs:. The web server has no Engine and runs no plugins, so a
|
|
41
43
|
# result without them (older sessions) falls back to the given
|
|
42
44
|
# registry: the built-ins', and a plugin tool's arguments as key="value"
|
|
43
45
|
# from the parsers' args:, as the live row shows them for a tool
|
|
@@ -106,11 +108,12 @@ module Samagotchi
|
|
|
106
108
|
shown = saved_list(responses, :tool_params, calls.length)
|
|
107
109
|
labels = saved_list(responses, :tool_labels, calls.length)
|
|
108
110
|
diffs = saved_list(responses, :tool_diffs, calls.length)
|
|
111
|
+
refs = saved_list(responses, :tool_refs, calls.length)
|
|
109
112
|
ids = saved_list(responses, :tool_ids, calls.length)
|
|
110
113
|
images = split_images(responses, calls.length)
|
|
111
114
|
calls.each_with_index.map do |call, i|
|
|
112
|
-
tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i],
|
|
113
|
-
tool_id: ids[i], marks: marks)
|
|
115
|
+
tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], refs: refs[i],
|
|
116
|
+
cwd: cwd, tool_id: ids[i], marks: marks)
|
|
114
117
|
end
|
|
115
118
|
end
|
|
116
119
|
|
|
@@ -144,7 +147,8 @@ module Samagotchi
|
|
|
144
147
|
response = by_id[field(raw, :id).to_s] || (responses[i] && !unique.call(ids[i]) ? responses[i] : nil)
|
|
145
148
|
tool_part(call, response && field(response, :content).to_s, registry, field(response, :tool_params),
|
|
146
149
|
response && field(response, :images), label: field(response, :tool_labels),
|
|
147
|
-
diff: field(response, :tool_diffs),
|
|
150
|
+
diff: field(response, :tool_diffs),
|
|
151
|
+
refs: field(response, :tool_refs), cwd: cwd,
|
|
148
152
|
tool_id: Array(field(response, :tool_ids)).first, marks: marks)
|
|
149
153
|
end
|
|
150
154
|
end
|
|
@@ -224,10 +228,12 @@ module Samagotchi
|
|
|
224
228
|
|
|
225
229
|
# +shown+ is the saved params line, when it is a String.
|
|
226
230
|
# +diff+ is what an edit/write changed (EditPreview.change), saved as
|
|
227
|
-
# tool_diffs. +
|
|
231
|
+
# tool_diffs. +refs+: a plugin tool's row refs, saved as tool_refs
|
|
232
|
+
# (RowRefs.for_display checks them again against +cwd+). +cwd+: the
|
|
233
|
+
# session's working directory, for the title.
|
|
228
234
|
# +tool_id+: its output's ToolIds id, with its ✂ mark in +marks+.
|
|
229
|
-
def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil,
|
|
230
|
-
marks: {})
|
|
235
|
+
def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, refs: nil, cwd: nil,
|
|
236
|
+
tool_id: nil, marks: {})
|
|
231
237
|
name = call[:name].to_s
|
|
232
238
|
params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
|
|
233
239
|
part = { tool: name, params: params.to_s }
|
|
@@ -240,9 +246,11 @@ module Samagotchi
|
|
|
240
246
|
part[:output] = output.length > OUTPUT_MAX ? output[0, OUTPUT_MAX] : output
|
|
241
247
|
part[:output_truncated] = true if output.length > OUTPUT_MAX
|
|
242
248
|
end
|
|
243
|
-
|
|
244
|
-
part[:images] =
|
|
249
|
+
shots = Array(images).select { |image| image.is_a?(Hash) }
|
|
250
|
+
part[:images] = shots.map { |image| ImageStore.symbolize(image).slice(:file, :name, :width, :height) } unless shots.empty?
|
|
245
251
|
part[:diff] = diff if diff.is_a?(Hash)
|
|
252
|
+
row_refs = RowRefs.for_display(refs, cwd: cwd)
|
|
253
|
+
part[:refs] = row_refs unless row_refs.empty?
|
|
246
254
|
if tool_id
|
|
247
255
|
part[:tool_id] = tool_id.to_s
|
|
248
256
|
mark = mark_of(tool_id.to_s, marks, output)
|
|
@@ -45,8 +45,11 @@ export function newActivity() {
|
|
|
45
45
|
// ref: what the row traces to (the server's FileRef: {kind: "file",
|
|
46
46
|
// path, line?, end_line?}, the file a file tool touched), which
|
|
47
47
|
// refs.js makes the row's title and diff lines open.
|
|
48
|
+
// refs: a plugin tool's refs (the server's RowRefs: [{kind, target,
|
|
49
|
+
// label?, actions}]): the first on the row's title, the others in its
|
|
50
|
+
// menu (refs.js bindRowRefs).
|
|
48
51
|
export const ACTIVITY_FIELDS = Object.freeze(["title", "called_as"]);
|
|
49
|
-
export const EVENT_FIELDS = Object.freeze(["view", "ref"]);
|
|
52
|
+
export const EVENT_FIELDS = Object.freeze(["view", "ref", "refs"]);
|
|
50
53
|
const ROW_FIELDS = Object.freeze([...ACTIVITY_FIELDS, ...EVENT_FIELDS]);
|
|
51
54
|
|
|
52
55
|
// The +fields+ of +source+ that are set, as an object (for a spread).
|
|
@@ -137,10 +140,10 @@ export function addCompleted(model, event = {}, now = null) {
|
|
|
137
140
|
// A grep that found nothing (status ok): the row says "no match".
|
|
138
141
|
if (event.activity?.no_match) row.no_match = true;
|
|
139
142
|
// The images the tool returned (a reload in the turn view draws them from
|
|
140
|
-
// the saved parts, turn_html.js
|
|
143
|
+
// the saved parts, turn_html.js toolRowTemplate).
|
|
141
144
|
if (event.images?.length) row.images = event.images;
|
|
142
145
|
// What an edit/write changed in its file (a reload draws it from the
|
|
143
|
-
// saved parts, turn_html.js
|
|
146
|
+
// saved parts, turn_html.js toolRowTemplate).
|
|
144
147
|
if (event.diff) row.diff = event.diff;
|
|
145
148
|
const duration = callDuration(row, event, now);
|
|
146
149
|
if (duration !== null) row.duration_ms = duration;
|
|
@@ -36,12 +36,12 @@ import { createShownTexts } from "./shown_texts.js";
|
|
|
36
36
|
import { MODEL_KEY } from "./model_pick.js";
|
|
37
37
|
import { emptySessionState, sessionStateFrom } from "./session_state.js";
|
|
38
38
|
import { createModelPicker } from "./model_picker.js";
|
|
39
|
-
import { chipName,
|
|
40
|
-
import {
|
|
41
|
-
import { createTurnView, turnHistoryNodes } from "./turn_view.js";
|
|
42
|
-
import { BADGES,
|
|
43
|
-
import { createStageView } from "./stage_view.js";
|
|
44
|
-
import { CONTINUE_OPENER, isOpener, chainDate, chainNeighbours, continueRefusal, continuedText,
|
|
39
|
+
import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText } from "./images.js";
|
|
40
|
+
import { normalize, firstPreview, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
|
|
41
|
+
import { createTurnView, stepText, turnHistoryNodes } from "./turn_view.js";
|
|
42
|
+
import { BADGES, elementOf, emptyAnswerTemplate, noteBubbleTemplate, stateBadgeTemplate, steerRowTemplate, userBubbleTemplate } from "./turn_html.js";
|
|
43
|
+
import { createStageView, FOLD_MS } from "./stage_view.js";
|
|
44
|
+
import { CONTINUE_OPENER, isOpener, chainDate, chainNeighbours, continueRefusal, continuedText, openChildrenTemplate } from "./chains.js";
|
|
45
45
|
import { createTaskStopper } from "./task_stop.js";
|
|
46
46
|
import { appendQuote } from "./annotations.js";
|
|
47
47
|
import { parsePresets } from "./annotate_presets.js";
|
|
@@ -49,13 +49,14 @@ import { createAnnotateBar } from "./annotate_bar.js";
|
|
|
49
49
|
import { copyText, installCopy } from "./copy.js";
|
|
50
50
|
import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
|
|
51
51
|
import { createQuestionCards } from "./question_cards.js";
|
|
52
|
-
import {
|
|
52
|
+
import { cardPlace, drawCard, isCard, isNoticeCard, refreshCardBody, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
53
53
|
import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
|
|
54
54
|
import { createCommandList } from "./command_list.js";
|
|
55
55
|
import { createPromptHistory } from "./prompt_history.js";
|
|
56
56
|
import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
57
|
-
import {
|
|
58
|
-
import {
|
|
57
|
+
import { scopeDir, scopeTemplate } from "./scope.js";
|
|
58
|
+
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
59
|
+
import { easeScrollTo, FOLLOW_MS, shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
59
60
|
import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
|
|
60
61
|
import { applySessionEvent, flattenFamilies, inTree, sortedByUpdated } from "./sessions_list.js";
|
|
61
62
|
import { createNotifications } from "./notifications.js";
|
|
@@ -94,14 +95,12 @@ const infoTextEl = $("#infoText");
|
|
|
94
95
|
const contextChips = createContextChips({
|
|
95
96
|
el: $("#contextChips"),
|
|
96
97
|
api: { list: listContext, show: getContext, detach: detachContext, add: addContext },
|
|
97
|
-
escapeHtml,
|
|
98
98
|
toast: (text) => showToast(text),
|
|
99
99
|
});
|
|
100
100
|
// The open session's LLM context chip in the info bar, and its popover:
|
|
101
101
|
// /llm-context runs in the session's worker.
|
|
102
102
|
const llmContextControl = createLlmContextControl({
|
|
103
103
|
el: infoTextEl,
|
|
104
|
-
escapeHtml,
|
|
105
104
|
summary: () => (selected ? current.llmContext : null),
|
|
106
105
|
session: () => selected,
|
|
107
106
|
running: () => liveTurn.running,
|
|
@@ -116,7 +115,6 @@ let newChatLlmContext = null;
|
|
|
116
115
|
const newChatLlmSummary = () => (modelPickEl.hidden ? null : modelPicker.chosenRow()?.llm_context || null);
|
|
117
116
|
const llmContextNewControl = createLlmContextControl({
|
|
118
117
|
el: llmContextNewEl,
|
|
119
|
-
escapeHtml,
|
|
120
118
|
summary: newChatLlmSummary,
|
|
121
119
|
kind: NEW_CHAT,
|
|
122
120
|
choice: () => newChatLlmContext,
|
|
@@ -264,6 +262,7 @@ const questionCards = createQuestionCards({
|
|
|
264
262
|
isNearBottom: () => isNearBottom(),
|
|
265
263
|
scrollToEnd: () => { historyEl.scrollTop = historyEl.scrollHeight; },
|
|
266
264
|
removeHintIfEmpty: () => removeHintIfEmpty(),
|
|
265
|
+
askingText: () => turnDom.askingText(),
|
|
267
266
|
});
|
|
268
267
|
// The event id (or, from an older worker, the event_seq) the selected session
|
|
269
268
|
// was rendered at (null: no live worker then), where a stream opened later
|
|
@@ -392,29 +391,11 @@ function followSelectedSession(sess) {
|
|
|
392
391
|
// new chat starts on the start page.
|
|
393
392
|
function renderScope() {
|
|
394
393
|
const el = $("#scope");
|
|
395
|
-
|
|
396
|
-
if (scopeError) {
|
|
397
|
-
el.innerHTML = `<span class="scope-name error" title="${escapeHtml(scopeError)}">folder not found</span>${allLink}`;
|
|
398
|
-
} else if (scope.projectName) {
|
|
399
|
-
el.innerHTML = `<span class="scope-name" title="Sessions of ${escapeHtml(scope.projectDir)}">${escapeHtml(scope.projectName)}</span>${allLink}`;
|
|
400
|
-
} else {
|
|
401
|
-
const where = scope.dir || scope.serverDir;
|
|
402
|
-
const backLink = scope.backName
|
|
403
|
-
? `<a class="scope-all scope-back" href="${escapeHtml(projectScopeHref(scope.backDir, location.hash, location.search))}" title="Sessions of ${escapeHtml(scope.backName)} only">← ${escapeHtml(scope.backName)}</a>`
|
|
404
|
-
: "";
|
|
405
|
-
el.innerHTML = `<span class="scope-name" title="Sessions of every project">all projects</span>${backLink}${where ? `<span class="scope-where" title="New chats start in ${escapeHtml(where)}">new chat in<span class="path"><bdi>${escapeHtml(where)}</bdi></span></span>` : ""}`;
|
|
406
|
-
}
|
|
394
|
+
render(scopeTemplate(scope, { error: scopeError, where: () => ({ hash: location.hash, search: location.search }) }), el);
|
|
407
395
|
el.hidden = false;
|
|
408
396
|
// The tab: "<session title> · Chi · <project>" (updateSessionTitle).
|
|
409
397
|
tabBase = scope.projectName ? `Chi · ${scope.projectName}` : "Chi";
|
|
410
398
|
updateSessionTitle();
|
|
411
|
-
// The links follow the hash (an open session, the all-sessions view).
|
|
412
|
-
el.querySelector(".scope-all:not(.scope-back)")?.addEventListener("click", (e) => {
|
|
413
|
-
e.currentTarget.href = allScopeHref(location.hash, scope.dir, location.search);
|
|
414
|
-
});
|
|
415
|
-
el.querySelector(".scope-back")?.addEventListener("click", (e) => {
|
|
416
|
-
e.currentTarget.href = projectScopeHref(scope.backDir, location.hash, location.search);
|
|
417
|
-
});
|
|
418
399
|
}
|
|
419
400
|
|
|
420
401
|
// The session lists (lists_view.js): the strip, all sessions, their cards,
|
|
@@ -584,7 +565,7 @@ function updateComposerMode() {
|
|
|
584
565
|
actionBtn.textContent = "Start";
|
|
585
566
|
actionBtn.disabled = false;
|
|
586
567
|
emptyEl.classList.remove("hidden");
|
|
587
|
-
historyEl.
|
|
568
|
+
historyEl.replaceChildren();
|
|
588
569
|
updateInfoBar();
|
|
589
570
|
} else if (current.owner === "tui") {
|
|
590
571
|
setPlaceholder("A terminal chi owns this session · run it with chi --shared to send from here", "A terminal chi owns this session");
|
|
@@ -698,7 +679,7 @@ async function select(id) {
|
|
|
698
679
|
selected = id;
|
|
699
680
|
setSessionHash(id);
|
|
700
681
|
lists.paintActive();
|
|
701
|
-
historyEl.
|
|
682
|
+
historyEl.replaceChildren(hintEl("Loading…"));
|
|
702
683
|
emptyEl.classList.add("hidden");
|
|
703
684
|
closeStream();
|
|
704
685
|
resetSessionState();
|
|
@@ -740,7 +721,7 @@ async function resync({ endText = null } = {}) {
|
|
|
740
721
|
data = await getSession(id, { parts: true });
|
|
741
722
|
} catch (e) {
|
|
742
723
|
if (e.status === 404) return leaveGoneSession(id, "not_found");
|
|
743
|
-
if (id === selected) historyEl.
|
|
724
|
+
if (id === selected) historyEl.replaceChildren(hintEl(`Error: ${e.message}`));
|
|
744
725
|
return;
|
|
745
726
|
}
|
|
746
727
|
if (id !== selected) return;
|
|
@@ -832,16 +813,30 @@ function renderSessionView(data, { kept = [] } = {}) {
|
|
|
832
813
|
updateComposerMode();
|
|
833
814
|
}
|
|
834
815
|
|
|
816
|
+
// The history's one line while it has nothing else (loading, an error,
|
|
817
|
+
// no output yet).
|
|
818
|
+
function hintEl(text) {
|
|
819
|
+
const el = document.createElement("div");
|
|
820
|
+
el.className = "hint";
|
|
821
|
+
el.textContent = text;
|
|
822
|
+
return el;
|
|
823
|
+
}
|
|
824
|
+
|
|
835
825
|
function renderHistory(items) {
|
|
836
826
|
if (!items.length) {
|
|
837
|
-
historyEl.
|
|
827
|
+
historyEl.replaceChildren(hintEl("No output yet. Send a message."));
|
|
838
828
|
return;
|
|
839
829
|
}
|
|
840
830
|
if (typeof items[0] === "object" && items[0].role) {
|
|
841
|
-
historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) =>
|
|
831
|
+
historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images) }));
|
|
842
832
|
linkTextRefs();
|
|
843
833
|
} else {
|
|
844
|
-
historyEl.
|
|
834
|
+
historyEl.replaceChildren(...items.map((c) => {
|
|
835
|
+
const el = document.createElement("div");
|
|
836
|
+
el.className = "bubble output";
|
|
837
|
+
el.textContent = String(c);
|
|
838
|
+
return el;
|
|
839
|
+
}));
|
|
845
840
|
}
|
|
846
841
|
historyEl.scrollTop = historyEl.scrollHeight;
|
|
847
842
|
}
|
|
@@ -983,7 +978,7 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
|
|
|
983
978
|
if (kind !== "gone") liveTurn.timing.ended += 1;
|
|
984
979
|
// A completed turn with no answer: its notice where the answer goes
|
|
985
980
|
// (the turn view places it), else after the turn; before the timing.
|
|
986
|
-
const endNote = emptyAnswer ?
|
|
981
|
+
const endNote = emptyAnswer ? elementOf(emptyAnswerTemplate(emptyAnswer)) : null;
|
|
987
982
|
const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
|
|
988
983
|
if (endNote && !endNote.isConnected) appendToHistory(endNote);
|
|
989
984
|
liveTurn.timing.finish({ canceled: kind === "canceled" });
|
|
@@ -1005,8 +1000,8 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
|
|
|
1005
1000
|
function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null } = {}) {
|
|
1006
1001
|
const hint = historyEl.querySelector(".hint");
|
|
1007
1002
|
if (hint) hint.remove();
|
|
1008
|
-
const div =
|
|
1009
|
-
label, state, own, enqueuedId, key: normalize(prompt), thumbs:
|
|
1003
|
+
const div = elementOf(userBubbleTemplate({ content: prompt }, {
|
|
1004
|
+
label, state, own, enqueuedId, key: normalize(prompt), thumbs: thumbsTemplate(selected, images),
|
|
1010
1005
|
}));
|
|
1011
1006
|
const follow = isNearBottom();
|
|
1012
1007
|
appendToHistory(div);
|
|
@@ -1023,7 +1018,7 @@ function setBubbleState(bubble, state) {
|
|
|
1023
1018
|
}
|
|
1024
1019
|
bubble.classList.add(state);
|
|
1025
1020
|
bubble.dataset.userState = state;
|
|
1026
|
-
bubble.
|
|
1021
|
+
bubble.append(elementOf(stateBadgeTemplate(state)));
|
|
1027
1022
|
}
|
|
1028
1023
|
|
|
1029
1024
|
function bubbleById(enqueuedId) {
|
|
@@ -1101,6 +1096,7 @@ function showRecap(text, turnsSince = 0) {
|
|
|
1101
1096
|
details.appendChild(body);
|
|
1102
1097
|
// A stale one sits where it was written, above the turns it doesn't cover.
|
|
1103
1098
|
// Never in the stage: a recap is between turns.
|
|
1099
|
+
turnDom.settleFold?.();
|
|
1104
1100
|
const before = recapPlace(turnPromptBubbles(), turnsSince);
|
|
1105
1101
|
keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
|
|
1106
1102
|
}
|
|
@@ -1137,10 +1133,9 @@ function showSteers(steers) {
|
|
|
1137
1133
|
for (const steer of steers || []) {
|
|
1138
1134
|
if (turnDom.steer(steer)) continue;
|
|
1139
1135
|
removeHintIfEmpty();
|
|
1140
|
-
const
|
|
1141
|
-
holder.innerHTML = steerRowHtml(steer, { bubble: true });
|
|
1136
|
+
const row = elementOf(steerRowTemplate(steer, { bubble: true }));
|
|
1142
1137
|
const follow = isNearBottom();
|
|
1143
|
-
appendToHistory(
|
|
1138
|
+
appendToHistory(row);
|
|
1144
1139
|
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1145
1140
|
}
|
|
1146
1141
|
}
|
|
@@ -1168,36 +1163,6 @@ function cardElement(id) {
|
|
|
1168
1163
|
return findInTurnRoots(".plugin-card").find((el) => el.dataset.cardId === String(id)) || null;
|
|
1169
1164
|
}
|
|
1170
1165
|
|
|
1171
|
-
// A notice is a <details> (a one-line row that opens), a card a <div>: a
|
|
1172
|
-
// card that turns into the other kind (btw's "thinking…" → its answer) is
|
|
1173
|
-
// drawn on a new element in its place. Returns the element drawn.
|
|
1174
|
-
function drawCard(el, card) {
|
|
1175
|
-
const notice = isNoticeCard(card);
|
|
1176
|
-
if (el.tagName !== (notice ? "DETAILS" : "DIV")) {
|
|
1177
|
-
const fresh = document.createElement(notice ? "details" : "div");
|
|
1178
|
-
el.replaceWith(fresh);
|
|
1179
|
-
el = fresh;
|
|
1180
|
-
}
|
|
1181
|
-
el.className = cardClass(card);
|
|
1182
|
-
el.dataset.cardId = card.id;
|
|
1183
|
-
// A running turn's own card (the stage's status waits on one that asks).
|
|
1184
|
-
if (card.in_turn) el.dataset.inTurn = "1";
|
|
1185
|
-
else delete el.dataset.inTurn;
|
|
1186
|
-
if (notice) {
|
|
1187
|
-
const open = el.open;
|
|
1188
|
-
el.innerHTML = noticeInnerHtml(card);
|
|
1189
|
-
el.open = open;
|
|
1190
|
-
} else if (card.body_html === undefined && el.dataset.body === String(card.body ?? "") && el.querySelector(".card-body")) {
|
|
1191
|
-
// The same body again from the stream: keep the rendered one.
|
|
1192
|
-
const body = el.querySelector(".card-body").innerHTML;
|
|
1193
|
-
el.innerHTML = cardInnerHtml({ ...card, body_html: body });
|
|
1194
|
-
} else {
|
|
1195
|
-
el.innerHTML = cardInnerHtml(card);
|
|
1196
|
-
}
|
|
1197
|
-
el.dataset.body = String(card.body ?? "");
|
|
1198
|
-
return el;
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
1166
|
// Show +card+: update the one with its id where it is, else add it (a
|
|
1202
1167
|
// running turn's card as a row of its step; else before +before+ or at the
|
|
1203
1168
|
// end of the history). A running turn's card with actions asks the user
|
|
@@ -1299,12 +1264,15 @@ function turnAnswerEl(turnEls) {
|
|
|
1299
1264
|
function placeResolvedQuestion(entry, before) {
|
|
1300
1265
|
const pq = entry.pending_question;
|
|
1301
1266
|
if (!pq?.id || !(entry.answer || entry.cancelled)) return;
|
|
1302
|
-
|
|
1267
|
+
const turnEls = reloadedTurnEls(before);
|
|
1268
|
+
const asked = Number(entry.iteration) || 1;
|
|
1269
|
+
// The reply that asked: its step's text in the reloaded block (the card
|
|
1270
|
+
// keeps it, as live), never the running turn's.
|
|
1271
|
+
const said = stepText(turnEls.find((el) => el.matches(".turn-work")), asked);
|
|
1272
|
+
questionCards.renderQuestion(pq, { said });
|
|
1303
1273
|
const card = questionCards.questionCard();
|
|
1304
1274
|
if (!card || card.dataset.qid !== String(pq.id)) return;
|
|
1305
1275
|
questionCards.resolveQuestion(pq.id, { answer: entry.answer || null, cancelled: !!entry.cancelled, reason: entry.reason || "" });
|
|
1306
|
-
const turnEls = reloadedTurnEls(before);
|
|
1307
|
-
const asked = Number(entry.iteration) || 1;
|
|
1308
1276
|
const later = turnEls.find((el) => el.matches(".bubble.user.steered") && Number(el.dataset.step) > asked);
|
|
1309
1277
|
historyEl.insertBefore(card, later || turnAnswerEl(turnEls) || before);
|
|
1310
1278
|
}
|
|
@@ -1321,8 +1289,7 @@ function refreshCardBodies() {
|
|
|
1321
1289
|
if (id !== selected) return;
|
|
1322
1290
|
for (const card of data.cards || []) {
|
|
1323
1291
|
const el = card.type === "card" && cardElement(card.id);
|
|
1324
|
-
|
|
1325
|
-
if (body && typeof card.body_html === "string" && el.dataset.body === String(card.body ?? "")) body.innerHTML = card.body_html;
|
|
1292
|
+
if (el) refreshCardBody(el, card);
|
|
1326
1293
|
}
|
|
1327
1294
|
} catch (_) {}
|
|
1328
1295
|
}, 150);
|
|
@@ -1509,6 +1476,7 @@ function currentSessionDuration() {
|
|
|
1509
1476
|
|
|
1510
1477
|
// The stage takes a running turn's rows (its extras); else the history.
|
|
1511
1478
|
function appendToHistory(el) {
|
|
1479
|
+
// (The stage's place first moves a turn still sliding away into the history.)
|
|
1512
1480
|
if (turnDom.place?.(el)) return;
|
|
1513
1481
|
appendAboveLiveTiming(historyEl, el, liveTurn.timing.el);
|
|
1514
1482
|
}
|
|
@@ -1876,9 +1844,7 @@ function commandBubble(commandId) {
|
|
|
1876
1844
|
|
|
1877
1845
|
function addNoteBubble(note) {
|
|
1878
1846
|
removeHintIfEmpty();
|
|
1879
|
-
const div =
|
|
1880
|
-
div.className = "bubble note";
|
|
1881
|
-
div.innerHTML = noteHtml(note);
|
|
1847
|
+
const div = elementOf(noteBubbleTemplate(note));
|
|
1882
1848
|
const follow = isNearBottom();
|
|
1883
1849
|
appendToHistory(div);
|
|
1884
1850
|
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
@@ -1958,7 +1924,7 @@ const lightboxEl = $("#lightbox");
|
|
|
1958
1924
|
let chips = [];
|
|
1959
1925
|
|
|
1960
1926
|
function renderChips() {
|
|
1961
|
-
|
|
1927
|
+
render(chipsTemplate(chips), chipsEl);
|
|
1962
1928
|
chipsEl.hidden = !chips.length;
|
|
1963
1929
|
}
|
|
1964
1930
|
|
|
@@ -2039,15 +2005,51 @@ fitPrompt();
|
|
|
2039
2005
|
// The card floats over the history: keep the history's bottom padding at the
|
|
2040
2006
|
// card's height, and stay pinned to the end if the reader was there, since the
|
|
2041
2007
|
// card grows (text, chips, Cancel, a wrapping footer) under a reader at the end.
|
|
2008
|
+
// In the stage view the stage floats above the card, out of the dock's flow
|
|
2009
|
+
// (stage_view.js): the reserve counts it too, with its 10 px gap, while it
|
|
2010
|
+
// shows.
|
|
2042
2011
|
const dockEl = $("#dock");
|
|
2043
2012
|
const centerEl = $("#center");
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2013
|
+
// A leaving stage (the hand-off's slide) no longer counts: the reserve eases
|
|
2014
|
+
// down over the slide (index.html, #center.reserve-easing).
|
|
2015
|
+
const stageEl = view === "stage" ? turnDom.root : null;
|
|
2016
|
+
const STAGE_GAP = 10;
|
|
2017
|
+
function dockReserve() {
|
|
2018
|
+
const shown = stageEl && !stageEl.hidden && !stageEl.classList.contains("leaving");
|
|
2019
|
+
return dockEl.offsetHeight + (shown ? stageEl.offsetHeight + STAGE_GAP : 0);
|
|
2020
|
+
}
|
|
2021
|
+
const historyEnd = () => historyEl.scrollHeight - historyEl.clientHeight;
|
|
2022
|
+
// A reader at the end stays there when the reserve changes: at once for the
|
|
2023
|
+
// composer card, eased for the stage (+fromStage+: it appeared, grew or
|
|
2024
|
+
// leaves), so the history glides rather than jumps. An eased follow running
|
|
2025
|
+
// counts as at the end.
|
|
2026
|
+
function updateReserve({ fromStage = false } = {}) {
|
|
2027
|
+
const follow = atHistoryEnd() || historyEl.dataset.following !== undefined;
|
|
2028
|
+
const leaving = !!stageEl?.classList.contains("leaving");
|
|
2029
|
+
centerEl.classList.toggle("reserve-easing", leaving);
|
|
2030
|
+
const reserve = `${dockReserve()}px`;
|
|
2031
|
+
const changed = centerEl.style.getPropertyValue("--dock-h") !== reserve;
|
|
2032
|
+
if (changed) centerEl.style.setProperty("--dock-h", reserve);
|
|
2033
|
+
if (changed && follow) {
|
|
2034
|
+
if (fromStage) easeScrollTo(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce: matchMedia("(prefers-reduced-motion: reduce)").matches });
|
|
2035
|
+
else if (historyEl.dataset.following === undefined) historyEl.scrollTop = historyEl.scrollHeight;
|
|
2036
|
+
}
|
|
2049
2037
|
placeToast();
|
|
2050
|
-
}
|
|
2038
|
+
}
|
|
2039
|
+
const dockObserver = new ResizeObserver((entries) => updateReserve({ fromStage: entries.some((e) => e.target === stageEl) }));
|
|
2040
|
+
dockObserver.observe(dockEl);
|
|
2041
|
+
if (stageEl) {
|
|
2042
|
+
dockObserver.observe(stageEl);
|
|
2043
|
+
// Only showing, hiding and leaving change the reserve (not the stage's
|
|
2044
|
+
// other classes: waiting, in-use…).
|
|
2045
|
+
let stageShown = "";
|
|
2046
|
+
new MutationObserver(() => {
|
|
2047
|
+
const shown = `${stageEl.hidden}|${stageEl.classList.contains("leaving")}`;
|
|
2048
|
+
if (shown === stageShown) return;
|
|
2049
|
+
stageShown = shown;
|
|
2050
|
+
updateReserve({ fromStage: true });
|
|
2051
|
+
}).observe(stageEl, { attributes: true, attributeFilter: ["class", "hidden"] });
|
|
2052
|
+
}
|
|
2051
2053
|
window.addEventListener("resize", () => placeToast());
|
|
2052
2054
|
|
|
2053
2055
|
promptEl.addEventListener("paste", (e) => {
|
|
@@ -2180,7 +2182,7 @@ async function continueSelected() {
|
|
|
2180
2182
|
if (selected === id) await select(refusal.id);
|
|
2181
2183
|
showToast(`${short} is continued already: ${selected === refusal.id ? "opened" : "see"} ${refusal.id.slice(0, 8)}`);
|
|
2182
2184
|
} else if (refusal.kind === "children") {
|
|
2183
|
-
showToast(
|
|
2185
|
+
showToast(openChildrenTemplate(id, refusal.children, sessionHash), null, { sticky: true });
|
|
2184
2186
|
} else {
|
|
2185
2187
|
showToast(`Can't continue ${short}: ${refusal.text}`, null, { sticky: true });
|
|
2186
2188
|
}
|
|
@@ -2260,14 +2262,16 @@ async function setArchived(id, archive, { undo = true } = {}) {
|
|
|
2260
2262
|
|
|
2261
2263
|
// The toast sits above the session's composer card (measured: it grows with
|
|
2262
2264
|
// a wrapping footer or a taller box), so it never covers the card's
|
|
2263
|
-
// controls; with no card in sight (the
|
|
2264
|
-
// 24 px from the bottom.
|
|
2265
|
+
// controls, and above the stage while it shows; with no card in sight (the
|
|
2266
|
+
// start page, all sessions) the CSS's 24 px from the bottom.
|
|
2265
2267
|
function placeToast() {
|
|
2266
2268
|
const gap = 12;
|
|
2267
2269
|
const dock = document.getElementById("dock");
|
|
2268
2270
|
const r = dock.getBoundingClientRect();
|
|
2269
2271
|
const over = document.body.classList.contains("has-session") && r.height > 0;
|
|
2270
|
-
const
|
|
2272
|
+
const stage = document.getElementById("turnStage");
|
|
2273
|
+
const top = stage && !stage.hidden ? Math.min(r.top, stage.getBoundingClientRect().top) : r.top;
|
|
2274
|
+
const bottom = over ? `${Math.round(window.innerHeight - top + gap)}px` : "";
|
|
2271
2275
|
const root = document.documentElement.style;
|
|
2272
2276
|
if (root.getPropertyValue("--toast-bottom") !== bottom) {
|
|
2273
2277
|
if (bottom) root.setProperty("--toast-bottom", bottom);
|
|
@@ -2275,24 +2279,20 @@ function placeToast() {
|
|
|
2275
2279
|
}
|
|
2276
2280
|
}
|
|
2277
2281
|
|
|
2278
|
-
// sticky: stays until its action or its ✕ (else gone after 6 s).
|
|
2282
|
+
// sticky: stays until its action or its ✕ (else gone after 6 s). +text+:
|
|
2283
|
+
// words, or a template (chains.js openChildrenTemplate: a link in it, a
|
|
2284
|
+
// delegate a continue waits for, opens that session and hides the toast).
|
|
2279
2285
|
let toastTimer = null;
|
|
2280
|
-
|
|
2281
|
-
function showToast(text, action = null, { sticky = false, html = false } = {}) {
|
|
2286
|
+
function showToast(text, action = null, { sticky = false } = {}) {
|
|
2282
2287
|
const el = $("#toast");
|
|
2283
|
-
|
|
2284
|
-
|
|
2288
|
+
const hide = () => { el.hidden = true; };
|
|
2289
|
+
render(html`<span @click=${(e) => { if (e.target.closest?.("a[href]")) hide(); }}>${text}</span>${action
|
|
2290
|
+
? html`<button type="button" class="toast-action" @click=${() => { hide(); action.run(); }}>${action.label}</button>`
|
|
2291
|
+
: nothing}${sticky ? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${hide}>✕</button>` : nothing}`, el);
|
|
2285
2292
|
placeToast();
|
|
2286
2293
|
el.hidden = false;
|
|
2287
|
-
el.querySelector(".toast-action")?.addEventListener("click", () => {
|
|
2288
|
-
el.hidden = true;
|
|
2289
|
-
action.run();
|
|
2290
|
-
});
|
|
2291
|
-
el.querySelector(".toast-close")?.addEventListener("click", () => { el.hidden = true; });
|
|
2292
|
-
// A link in it (a delegate a continue waits for) opens that session.
|
|
2293
|
-
el.querySelectorAll("a[href]").forEach((a) => a.addEventListener("click", () => { el.hidden = true; }));
|
|
2294
2294
|
clearTimeout(toastTimer);
|
|
2295
|
-
if (!sticky) toastTimer = setTimeout(
|
|
2295
|
+
if (!sticky) toastTimer = setTimeout(hide, 6000);
|
|
2296
2296
|
}
|
|
2297
2297
|
|
|
2298
2298
|
// Annotate (annotate_bar.js): a selection in the conversation offers
|