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
|
@@ -17,16 +17,19 @@ import {
|
|
|
17
17
|
uploadImage,
|
|
18
18
|
listModels,
|
|
19
19
|
} from "./data.js";
|
|
20
|
-
import { MODEL_KEY
|
|
20
|
+
import { MODEL_KEY } from "./model_pick.js";
|
|
21
|
+
import { createModelPicker } from "./model_picker.js";
|
|
21
22
|
import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
|
|
22
23
|
import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, firstMessageOf, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
|
|
23
24
|
import { chatHistoryHtml, createChatView } from "./chat_view.js";
|
|
24
25
|
import { createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
26
|
+
import { createStageView } from "./stage_view.js";
|
|
25
27
|
import { annotateBarLeft, annotationSource, appendQuote, quoteBlock } from "./annotations.js";
|
|
26
28
|
import { parsePresets, presetNote } from "./annotate_presets.js";
|
|
27
29
|
import { COPY_SOURCE_ATTR, copySource, copySourceAttr, copyText, installCopy, showsAnswer } from "./copy.js";
|
|
28
30
|
import { routeChunk } from "./chunk_router.js";
|
|
29
31
|
import { extractCtxPct, savedCtxPct, cardCtxText } from "./ctx.js";
|
|
32
|
+
import { diffHtml } from "./diff_view.js";
|
|
30
33
|
import { approvalAllowed, approvalView, isApproval, nextCardAction, resultText, summaryText } from "./question_card.js";
|
|
31
34
|
import { cardClass, cardInnerHtml, cardPlace, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
32
35
|
import { gripFloor, promptHeight } from "./composer_size.js";
|
|
@@ -36,12 +39,12 @@ import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js
|
|
|
36
39
|
import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
37
40
|
import { stripColumns, stripShowParts } from "./strip.js";
|
|
38
41
|
import { applySessionEvent, listedSessions, sortedByUpdated, withChildrenAfterParents } from "./sessions_list.js";
|
|
39
|
-
import { NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, trackAttention } from "./notify.js";
|
|
42
|
+
import { NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, notifyState as bellState, trackAttention } from "./notify.js";
|
|
40
43
|
import {
|
|
41
44
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
|
|
42
45
|
} from "./timing.js";
|
|
43
46
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
44
|
-
import { clientLabel, commandView, continueLine, emptyRetryLine, hookNoticeLabel, isCommandLine, isOwn, keepEarlyRestore, newClientId, promptOps, reminderText, restoreAction, restoreOnAck, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
47
|
+
import { clientLabel, commandView, continueLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
45
48
|
|
|
46
49
|
const $ = (s) => document.querySelector(s);
|
|
47
50
|
const topStripEl = $("#topStrip");
|
|
@@ -57,7 +60,6 @@ const promptEl = $("#prompt");
|
|
|
57
60
|
const actionBtn = $("#actionBtn");
|
|
58
61
|
const cancelBtn = $("#cancelBtn");
|
|
59
62
|
const modelPickEl = $("#modelPick");
|
|
60
|
-
const modelSelectEl = $("#modelSelect");
|
|
61
63
|
const composeHintEl = $("#composeHint");
|
|
62
64
|
|
|
63
65
|
let selected = null;
|
|
@@ -71,18 +73,32 @@ let resyncsInFlight = 0;
|
|
|
71
73
|
let resyncAgain = false;
|
|
72
74
|
let allSessions = [];
|
|
73
75
|
let seenContent = new Set();
|
|
74
|
-
// The
|
|
75
|
-
// with its steps (turn_view.js),
|
|
76
|
-
|
|
76
|
+
// The page's view of a turn (web.view, or ?view=turn|stage|chat): one block
|
|
77
|
+
// per turn with its steps (turn_view.js), the running turn pinned above the
|
|
78
|
+
// composer (the stage), or a row of bubbles (chat_view.js).
|
|
79
|
+
const view = ["turn", "stage", "chat"].includes(document.body.dataset.view) ? document.body.dataset.view : "turn";
|
|
80
|
+
// The turn and stage views draw a turn as steps (a reload asks for parts).
|
|
81
|
+
const turnView = view !== "chat";
|
|
77
82
|
// The running turn's DOM: the streaming bubbles, the thinking block and the
|
|
78
|
-
// activity panel, or the turn block.
|
|
79
|
-
const
|
|
83
|
+
// activity panel, the turn block, or the stage (the turn block in its cloud).
|
|
84
|
+
const viewArgs = {
|
|
80
85
|
historyEl,
|
|
81
86
|
appendToHistory: (el) => appendToHistory(el),
|
|
82
87
|
isNearBottom: () => isNearBottom(),
|
|
83
88
|
sawText: (k) => seenContent.add(k),
|
|
84
89
|
sessionId: () => selected,
|
|
85
|
-
}
|
|
90
|
+
};
|
|
91
|
+
const turnDom = view === "stage"
|
|
92
|
+
? createStageView({ ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl })
|
|
93
|
+
: (turnView ? createTurnView : createChatView)(viewArgs);
|
|
94
|
+
// Where a running turn's rows can be: the history, and the stage while it
|
|
95
|
+
// holds a turn (lookups by id, copy, annotate, card actions).
|
|
96
|
+
function turnRoots() {
|
|
97
|
+
return turnDom.root ? [historyEl, turnDom.root] : [historyEl];
|
|
98
|
+
}
|
|
99
|
+
function findInTurnRoots(selector) {
|
|
100
|
+
return turnRoots().flatMap((root) => [...root.querySelectorAll(selector)]);
|
|
101
|
+
}
|
|
86
102
|
let currentFirstPreview = "";
|
|
87
103
|
let currentUsedMemories = [];
|
|
88
104
|
// The session's --memory and --mute lists (from the session card; shown in
|
|
@@ -104,6 +120,10 @@ let currentOwner = null;
|
|
|
104
120
|
let currentTiming = normalizeTiming();
|
|
105
121
|
let turnRunning = false;
|
|
106
122
|
let liveTurnTimingEl = null;
|
|
123
|
+
// What the running turn waits on, after its live timing line: a provider
|
|
124
|
+
// retry (generation_retrying) or plugins' setup (plugin_init_wait); "" when
|
|
125
|
+
// it streams again.
|
|
126
|
+
let liveTurnNote = "";
|
|
107
127
|
let timingInterval = null;
|
|
108
128
|
// Live ask_user_question card, keyed by pending question id. Re-rendered from
|
|
109
129
|
// pending_question on session select so a reload mid-question restores it.
|
|
@@ -477,6 +497,7 @@ const SEND_HINT = "Enter to send · Shift+Enter for a newline";
|
|
|
477
497
|
// its next step (steering). Cancel is its own button then.
|
|
478
498
|
function updateComposerMode() {
|
|
479
499
|
document.body.classList.toggle("has-session", !!selected);
|
|
500
|
+
if (selected) modelPicker.close();
|
|
480
501
|
const running = turnRunning && !!selected;
|
|
481
502
|
cancelBtn.style.display = running ? "" : "none";
|
|
482
503
|
if (running) {
|
|
@@ -824,7 +845,7 @@ function applyFinalMarkdown(messages) {
|
|
|
824
845
|
);
|
|
825
846
|
if (!message) return;
|
|
826
847
|
|
|
827
|
-
const bubble =
|
|
848
|
+
const bubble = findInTurnRoots(".bubble.output")
|
|
828
849
|
.reverse()
|
|
829
850
|
.find((item) => showsAnswer(item, message, normalize));
|
|
830
851
|
if (!bubble) return;
|
|
@@ -836,7 +857,7 @@ function applyFinalMarkdown(messages) {
|
|
|
836
857
|
|
|
837
858
|
function seedSeenFromDom() {
|
|
838
859
|
seenContent = new Set();
|
|
839
|
-
|
|
860
|
+
findInTurnRoots(".bubble").forEach((b) => {
|
|
840
861
|
const k = normalize(b.textContent);
|
|
841
862
|
if (k) seenContent.add(k);
|
|
842
863
|
});
|
|
@@ -845,8 +866,13 @@ function seedSeenFromDom() {
|
|
|
845
866
|
// Sticky-follow scroll: only auto-scroll to the bottom while the user is
|
|
846
867
|
// already near it; never yank them back down mid-read. Capture the decision
|
|
847
868
|
// BEFORE any DOM mutation that grows the content (new chunk / row / thinking
|
|
848
|
-
// delta), so the gap measured is the one the user was sitting at.
|
|
869
|
+
// delta), so the gap measured is the one the user was sitting at. While the
|
|
870
|
+
// stage holds a turn its rows go there: nothing it does moves the history.
|
|
849
871
|
function isNearBottom() {
|
|
872
|
+
return !turnDom.holds?.() && atHistoryEnd();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function atHistoryEnd() {
|
|
850
876
|
const { scrollHeight, scrollTop, clientHeight } = historyEl;
|
|
851
877
|
return shouldFollowScroll({ scrollHeight, scrollTop, clientHeight });
|
|
852
878
|
}
|
|
@@ -870,7 +896,7 @@ function addEndBubble(text) {
|
|
|
870
896
|
div.className = "bubble cancel";
|
|
871
897
|
div.textContent = text;
|
|
872
898
|
appendToHistory(div);
|
|
873
|
-
historyEl.scrollTop = historyEl.scrollHeight;
|
|
899
|
+
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
874
900
|
}
|
|
875
901
|
|
|
876
902
|
// Everything a turn's end (completed, canceled, failed) closes: then its
|
|
@@ -883,7 +909,7 @@ function endTurnView(kind, endText = null) {
|
|
|
883
909
|
if (endText) addEndBubble(endText);
|
|
884
910
|
if (kept.length) {
|
|
885
911
|
kept.forEach((el) => appendToHistory(el));
|
|
886
|
-
historyEl.scrollTop = historyEl.scrollHeight;
|
|
912
|
+
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
887
913
|
}
|
|
888
914
|
// The question dies with its turn (the worker stopped waiting).
|
|
889
915
|
if (pendingQuestion) resolveQuestionCard(pendingQuestion.id, { cancelled: true, reason: "turn ended" });
|
|
@@ -939,25 +965,29 @@ function setBubbleState(bubble, state) {
|
|
|
939
965
|
|
|
940
966
|
function bubbleById(enqueuedId) {
|
|
941
967
|
if (!enqueuedId) return null;
|
|
942
|
-
return
|
|
968
|
+
return findInTurnRoots(".bubble.user[data-enqueued-id]").find(
|
|
943
969
|
(el) => el.dataset.enqueuedId === enqueuedId,
|
|
944
970
|
) || null;
|
|
945
971
|
}
|
|
946
972
|
|
|
947
973
|
// This tab's oldest untagged echo, preferring one with the same text.
|
|
948
974
|
function untaggedOwnBubble(prompt) {
|
|
949
|
-
const echoes =
|
|
975
|
+
const echoes = findInTurnRoots(".bubble.user[data-own]:not([data-enqueued-id])");
|
|
950
976
|
const text = normalize(prompt || "");
|
|
951
977
|
return echoes.find((el) => el.dataset.userContent === text) || echoes[0] || null;
|
|
952
978
|
}
|
|
953
979
|
|
|
980
|
+
// Returns the bubble of the turn a turn_started starts (the stage takes it
|
|
981
|
+
// into its prompt line), else null.
|
|
954
982
|
function applyPromptOps(event) {
|
|
955
983
|
const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge });
|
|
956
984
|
if (event.type === "input_merged") unmatchedMerge = false;
|
|
957
985
|
if (event.type === "pending_input_merged") unmatchedMerge = false;
|
|
986
|
+
let started = null;
|
|
958
987
|
for (const op of ops) {
|
|
959
988
|
if (op.op === "add") {
|
|
960
|
-
makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
|
|
989
|
+
const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
|
|
990
|
+
if (op.state === "started") started = bubble;
|
|
961
991
|
} else if (op.op === "tag") {
|
|
962
992
|
if (bubbleById(op.enqueuedId)) continue;
|
|
963
993
|
const echo = untaggedOwnBubble(op.prompt);
|
|
@@ -968,10 +998,11 @@ function applyPromptOps(event) {
|
|
|
968
998
|
const bubble = bubbleById(op.enqueuedId) || untaggedOwnBubble(op.prompt);
|
|
969
999
|
// A resync (a new worker woken by this page's send) re-rendered the
|
|
970
1000
|
// history and took the echo with it: draw the prompt again.
|
|
971
|
-
if (!bubble) makeUserBubble(op.prompt, { own: true, enqueuedId: op.enqueuedId, images: op.images });
|
|
1001
|
+
if (!bubble) started = makeUserBubble(op.prompt, { own: true, enqueuedId: op.enqueuedId, images: op.images });
|
|
972
1002
|
else {
|
|
973
1003
|
if (op.enqueuedId) bubble.dataset.enqueuedId = op.enqueuedId;
|
|
974
1004
|
setBubbleState(bubble, null);
|
|
1005
|
+
started = bubble;
|
|
975
1006
|
}
|
|
976
1007
|
} else if (op.op === "steer") {
|
|
977
1008
|
const bubble = bubbleById(op.enqueuedId);
|
|
@@ -979,6 +1010,7 @@ function applyPromptOps(event) {
|
|
|
979
1010
|
else unmatchedMerge = true;
|
|
980
1011
|
}
|
|
981
1012
|
}
|
|
1013
|
+
return started;
|
|
982
1014
|
}
|
|
983
1015
|
|
|
984
1016
|
// ── Idle recap ─────────────────────────────────────────────────────────────
|
|
@@ -1001,8 +1033,9 @@ function showRecap(text, turnsSince = 0) {
|
|
|
1001
1033
|
details.appendChild(summary);
|
|
1002
1034
|
details.appendChild(body);
|
|
1003
1035
|
// A stale one sits where it was written, above the turns it doesn't cover.
|
|
1036
|
+
// Never in the stage: a recap is between turns.
|
|
1004
1037
|
const before = recapPlace([...historyEl.querySelectorAll(":scope > .bubble.user")], turnsSince);
|
|
1005
|
-
keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) :
|
|
1038
|
+
keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurnTimingEl)));
|
|
1006
1039
|
}
|
|
1007
1040
|
|
|
1008
1041
|
// A turn started: the recap describes the chat before it.
|
|
@@ -1062,7 +1095,7 @@ function showHookNotice(data, { before = null } = {}) {
|
|
|
1062
1095
|
|
|
1063
1096
|
function cardElement(id) {
|
|
1064
1097
|
if (!id) return null;
|
|
1065
|
-
return
|
|
1098
|
+
return findInTurnRoots(".plugin-card").find((el) => el.dataset.cardId === String(id)) || null;
|
|
1066
1099
|
}
|
|
1067
1100
|
|
|
1068
1101
|
function drawCard(el, card) {
|
|
@@ -1170,7 +1203,7 @@ function refreshCardBodies() {
|
|
|
1170
1203
|
}, 150);
|
|
1171
1204
|
}
|
|
1172
1205
|
|
|
1173
|
-
|
|
1206
|
+
function onCardAction(e) {
|
|
1174
1207
|
const button = e.target.closest(".plugin-card .card-action");
|
|
1175
1208
|
if (!button || !selected) return;
|
|
1176
1209
|
const card = button.closest(".plugin-card");
|
|
@@ -1187,7 +1220,8 @@ historyEl.addEventListener("click", (e) => {
|
|
|
1187
1220
|
err.classList.remove("hidden");
|
|
1188
1221
|
})
|
|
1189
1222
|
.finally(() => { button.disabled = false; });
|
|
1190
|
-
}
|
|
1223
|
+
}
|
|
1224
|
+
turnRoots().forEach((root) => root.addEventListener("click", onCardAction));
|
|
1191
1225
|
|
|
1192
1226
|
function removeRecap() {
|
|
1193
1227
|
historyEl.querySelectorAll(".bubble.recap").forEach((el) => el.remove());
|
|
@@ -1272,30 +1306,43 @@ function startLiveTurnTiming(startedAt = new Date().toISOString()) {
|
|
|
1272
1306
|
currentTiming.activeTurn = { started_at: startedAt };
|
|
1273
1307
|
// The turn records hold the finished turns, so this one comes next.
|
|
1274
1308
|
liveTurnNumber = currentTiming.turnRecords.length + 1;
|
|
1309
|
+
liveTurnNote = "";
|
|
1275
1310
|
liveTurnTimingEl = document.createElement("div");
|
|
1276
1311
|
liveTurnTimingEl.className = "turn-timing live";
|
|
1277
|
-
|
|
1312
|
+
// The stage's status row while it holds the turn: the line moves into the
|
|
1313
|
+
// history with it.
|
|
1314
|
+
(turnDom.timingHost?.() || historyEl).appendChild(liveTurnTimingEl);
|
|
1278
1315
|
updateLiveTiming();
|
|
1279
1316
|
timingInterval = setInterval(updateLiveTiming, 1000);
|
|
1280
1317
|
}
|
|
1281
1318
|
|
|
1319
|
+
// The stage takes a running turn's rows (its extras); else the history.
|
|
1282
1320
|
function appendToHistory(el) {
|
|
1321
|
+
if (turnDom.place?.(el)) return;
|
|
1283
1322
|
appendAboveLiveTiming(historyEl, el, liveTurnTimingEl);
|
|
1284
1323
|
}
|
|
1285
1324
|
|
|
1286
1325
|
function updateLiveTiming() {
|
|
1287
1326
|
const duration = elapsedSince(currentTiming.activeTurn?.started_at);
|
|
1288
1327
|
if (liveTurnTimingEl && duration !== null) {
|
|
1289
|
-
liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: true });
|
|
1328
|
+
liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: true }) + (liveTurnNote ? ` · ${liveTurnNote}` : "");
|
|
1290
1329
|
}
|
|
1291
1330
|
updateInfoBar();
|
|
1292
1331
|
}
|
|
1293
1332
|
|
|
1333
|
+
// Set (or clear, "") what the running turn waits on.
|
|
1334
|
+
function setLiveTurnNote(text) {
|
|
1335
|
+
if (liveTurnNote === text) return;
|
|
1336
|
+
liveTurnNote = text;
|
|
1337
|
+
if (currentTiming.activeTurn) updateLiveTiming();
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1294
1340
|
function finishLiveTurnTiming(record = null) {
|
|
1295
1341
|
const duration = Number.isFinite(Number(record?.duration_ms))
|
|
1296
1342
|
? Number(record.duration_ms)
|
|
1297
1343
|
: elapsedSince(currentTiming.activeTurn?.started_at);
|
|
1298
1344
|
if (liveTurnTimingEl && duration !== null) {
|
|
1345
|
+
liveTurnNote = "";
|
|
1299
1346
|
liveTurnTimingEl.classList.remove("live");
|
|
1300
1347
|
const at = record ? currentTiming.turnRecords.indexOf(record) : -1;
|
|
1301
1348
|
liveTurnTimingEl.textContent = turnTimingText(at >= 0 ? at + 1 : liveTurnNumber, duration);
|
|
@@ -1322,15 +1369,12 @@ function updateFirstPeek() {
|
|
|
1322
1369
|
}
|
|
1323
1370
|
}
|
|
1324
1371
|
|
|
1325
|
-
function
|
|
1326
|
-
|
|
1327
|
-
if (!v || currentUsedMemories.includes(v)) return;
|
|
1328
|
-
currentUsedMemories.push(v);
|
|
1372
|
+
function setUsedMemories(names) {
|
|
1373
|
+
currentUsedMemories = names.slice();
|
|
1329
1374
|
updateInfoBar();
|
|
1330
1375
|
const sess = allSessions.find(s=>s.id===selected);
|
|
1331
1376
|
if (sess) {
|
|
1332
|
-
sess.used_memory_names =
|
|
1333
|
-
if (!sess.used_memory_names.includes(v)) sess.used_memory_names.push(v);
|
|
1377
|
+
sess.used_memory_names = currentUsedMemories.slice();
|
|
1334
1378
|
render();
|
|
1335
1379
|
document.querySelectorAll(".card").forEach(el=>el.classList.toggle("active", el.dataset.id===selected));
|
|
1336
1380
|
}
|
|
@@ -1394,6 +1438,7 @@ const streamHandlers = {
|
|
|
1394
1438
|
// block (see chunk_router.js). Raw `content` is only used as a fallback
|
|
1395
1439
|
// for pre-Phase-2 / non-enriched servers.
|
|
1396
1440
|
const { text, thinking } = routeChunk(data);
|
|
1441
|
+
setLiveTurnNote("");
|
|
1397
1442
|
turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
|
|
1398
1443
|
const pct = extractCtxPct(data, currentCtxWindow);
|
|
1399
1444
|
if (pct !== null && !Number.isNaN(pct)) {
|
|
@@ -1404,9 +1449,11 @@ const streamHandlers = {
|
|
|
1404
1449
|
},
|
|
1405
1450
|
generation_started: (data) => {
|
|
1406
1451
|
if (data?.context_window_tokens) currentCtxWindow = data.context_window_tokens;
|
|
1452
|
+
setLiveTurnNote("");
|
|
1407
1453
|
turnDom.generationStarted(data || {});
|
|
1408
1454
|
},
|
|
1409
1455
|
generation_completed: (data) => {
|
|
1456
|
+
setLiveTurnNote("");
|
|
1410
1457
|
if (data?.served_model) {
|
|
1411
1458
|
currentServed = [data.served_model, data.requested_model];
|
|
1412
1459
|
updateInfoBar();
|
|
@@ -1424,13 +1471,16 @@ const streamHandlers = {
|
|
|
1424
1471
|
},
|
|
1425
1472
|
turn_enqueued: (data) => applyPromptOps(data),
|
|
1426
1473
|
turn_started: (data) => keepFollowing(historyEl, () => {
|
|
1474
|
+
// A finished turn still in the stage goes first: the start op looks
|
|
1475
|
+
// for a queued prompt among its rows.
|
|
1476
|
+
turnDom.handOff?.({ immediate: true });
|
|
1427
1477
|
markRecapStale();
|
|
1428
|
-
applyPromptOps(data);
|
|
1478
|
+
const promptBubble = applyPromptOps(data);
|
|
1429
1479
|
setTurnRunning(true);
|
|
1430
1480
|
setLiveStatus("running");
|
|
1431
1481
|
clearTimingInterval();
|
|
1432
1482
|
liveTurnTimingEl = null;
|
|
1433
|
-
turnDom.turnStarted(data);
|
|
1483
|
+
turnDom.turnStarted({ ...data, promptBubble });
|
|
1434
1484
|
startLiveTurnTiming(data.started_at);
|
|
1435
1485
|
}),
|
|
1436
1486
|
turn_completed: async (data) => {
|
|
@@ -1530,24 +1580,11 @@ const streamHandlers = {
|
|
|
1530
1580
|
context_added: (data) => addNoteBubble({ label: data.label, content: data.text || "" }),
|
|
1531
1581
|
continue_offered: (data) => renderContinueCard(data),
|
|
1532
1582
|
continue_resolved: (data) => resolveContinueCard(data),
|
|
1533
|
-
tool_call_started: (data) =>
|
|
1534
|
-
turnDom.toolStarted(data);
|
|
1535
|
-
const call = data.call || {};
|
|
1536
|
-
let name = null;
|
|
1537
|
-
if (call.name === "memory_read" && call.content) {
|
|
1538
|
-
name = String(call.content).split(",")[0].trim();
|
|
1539
|
-
if (name) name = name.replace(/\.md$/,"").split("/").pop();
|
|
1540
|
-
} else if (call.name === "read" && call.content && /memories\/.+\.md/.test(call.content)) {
|
|
1541
|
-
name = String(call.content).split("/").pop().replace(/\.md$/,"");
|
|
1542
|
-
}
|
|
1543
|
-
if (name) addUsedMemory(name);
|
|
1544
|
-
},
|
|
1583
|
+
tool_call_started: (data) => turnDom.toolStarted(data),
|
|
1545
1584
|
tool_call_completed: (data) => turnDom.toolCompleted(data),
|
|
1585
|
+
// The engine's list, after every memory read and at a turn's end.
|
|
1546
1586
|
used_memories_updated: (data) => {
|
|
1547
|
-
if (Array.isArray(data.used_memory_names))
|
|
1548
|
-
currentUsedMemories = data.used_memory_names.slice();
|
|
1549
|
-
updateInfoBar();
|
|
1550
|
-
}
|
|
1587
|
+
if (Array.isArray(data.used_memory_names)) setUsedMemories(data.used_memory_names);
|
|
1551
1588
|
},
|
|
1552
1589
|
context_status: (data) => {
|
|
1553
1590
|
const pct = extractCtxPct(data, currentCtxWindow);
|
|
@@ -1568,13 +1605,21 @@ const streamHandlers = {
|
|
|
1568
1605
|
resolveQuestionCard(data.id, { cancelled: true, reason: data.reason });
|
|
1569
1606
|
},
|
|
1570
1607
|
// One collected just as a turn started describes the chat before it.
|
|
1571
|
-
recap_ready: (data) => {
|
|
1608
|
+
recap_ready: (data) => {
|
|
1609
|
+
// After the turn it covers, never above it.
|
|
1610
|
+
turnDom.handOff?.({ immediate: true });
|
|
1611
|
+
if (!turnRunning) showRecap(data.recap);
|
|
1612
|
+
},
|
|
1572
1613
|
guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
|
|
1573
1614
|
// In the turn view, a notice during a turn is a row of its current step.
|
|
1574
1615
|
hook_notice: (data) => { if (!turnDom.notice(data)) showHookNotice(data); },
|
|
1575
1616
|
// The loop asks again after an empty answer: a row of the empty step (the
|
|
1576
1617
|
// chat view shows nothing; after a reload neither does the turn view).
|
|
1577
1618
|
empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
|
|
1619
|
+
// The provider is asked again after an error, or the turn waits for
|
|
1620
|
+
// plugins' setup: a note on the live timing line until it streams.
|
|
1621
|
+
generation_retrying: (data) => setLiveTurnNote(retryStatusLine(data)),
|
|
1622
|
+
plugin_init_wait: (data) => setLiveTurnNote(initWaitLine(data)),
|
|
1578
1623
|
plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
|
|
1579
1624
|
plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
|
|
1580
1625
|
// A plugin's card, or a new version of one (the same id).
|
|
@@ -1752,6 +1797,13 @@ function renderApprovalDetails(view) {
|
|
|
1752
1797
|
what.appendChild(code);
|
|
1753
1798
|
box.appendChild(what);
|
|
1754
1799
|
}
|
|
1800
|
+
// An edit/write: the diff it would make, under the path.
|
|
1801
|
+
if (view.preview) {
|
|
1802
|
+
const diff = document.createElement("div");
|
|
1803
|
+
diff.className = "approval-diff";
|
|
1804
|
+
diff.innerHTML = diffHtml(view.preview);
|
|
1805
|
+
box.appendChild(diff);
|
|
1806
|
+
}
|
|
1755
1807
|
for (const [cls, label, text] of [["approval-where", "in", view.where], ["approval-why", "why", view.why]]) {
|
|
1756
1808
|
if (!text) continue;
|
|
1757
1809
|
const row = document.createElement("div");
|
|
@@ -1886,9 +1938,12 @@ async function handleSendTurn() {
|
|
|
1886
1938
|
}
|
|
1887
1939
|
const hint = historyEl.querySelector(".hint");
|
|
1888
1940
|
if (hint) hint.remove();
|
|
1941
|
+
// Sending moves a finished turn out of the stage at once (else the echo
|
|
1942
|
+
// would land among its rows); into a running one it is a queued row there.
|
|
1943
|
+
turnDom.handOff?.({ immediate: true });
|
|
1889
1944
|
const sending = chips.slice();
|
|
1890
1945
|
const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending });
|
|
1891
|
-
historyEl.scrollTop = historyEl.scrollHeight;
|
|
1946
|
+
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1892
1947
|
if (!currentFirstPreview || currentFirstPreview==="—") {
|
|
1893
1948
|
currentFirstPreview = normalize(prompt).slice(0,80) + (normalize(prompt).length>80?"…":"");
|
|
1894
1949
|
updateInfoBar();
|
|
@@ -1996,7 +2051,7 @@ function drawCommandBubble(div, { label, line, text, busy = false, failed = fals
|
|
|
1996
2051
|
|
|
1997
2052
|
function commandBubble(commandId) {
|
|
1998
2053
|
if (!commandId) return null;
|
|
1999
|
-
return
|
|
2054
|
+
return findInTurnRoots(".bubble.command").find((el) => el.dataset.commandId === String(commandId)) || null;
|
|
2000
2055
|
}
|
|
2001
2056
|
|
|
2002
2057
|
function addNoteBubble(note) {
|
|
@@ -2119,7 +2174,17 @@ $("#newBtn").addEventListener("click", () => {
|
|
|
2119
2174
|
// preselected while it is still offered (else the server's default). Hidden
|
|
2120
2175
|
// until the list is in, and in a session (its model is in the info bar). A
|
|
2121
2176
|
// host that failed is the picker's tooltip; a route that fails leaves the
|
|
2122
|
-
// picker hidden and the server's default in charge.
|
|
2177
|
+
// picker hidden and the server's default in charge. The search list itself
|
|
2178
|
+
// is model_picker.js'; a pick sends the focus to the message box.
|
|
2179
|
+
const modelPicker = createModelPicker({
|
|
2180
|
+
button: modelPickEl,
|
|
2181
|
+
panel: $("#modelPanel"),
|
|
2182
|
+
input: $("#modelSearch"),
|
|
2183
|
+
list: $("#modelList"),
|
|
2184
|
+
composer: $("#composer"),
|
|
2185
|
+
center: $("#center"),
|
|
2186
|
+
onPick: () => promptEl.focus(),
|
|
2187
|
+
});
|
|
2123
2188
|
async function loadModels() {
|
|
2124
2189
|
let payload;
|
|
2125
2190
|
try {
|
|
@@ -2127,26 +2192,16 @@ async function loadModels() {
|
|
|
2127
2192
|
} catch (_) {
|
|
2128
2193
|
return;
|
|
2129
2194
|
}
|
|
2130
|
-
const names = modelNames(payload);
|
|
2131
|
-
if (!names.length) return;
|
|
2132
2195
|
let stored = null;
|
|
2133
2196
|
try { stored = localStorage.getItem(MODEL_KEY); } catch (_) {}
|
|
2134
|
-
|
|
2135
|
-
modelSelectEl.innerHTML = names
|
|
2136
|
-
.map((n) => `<option value="${escapeHtml(n)}"${n === chosen ? " selected" : ""}>${escapeHtml(n)}</option>`)
|
|
2137
|
-
.join("");
|
|
2197
|
+
if (!modelPicker.setModels(payload, stored)) return;
|
|
2138
2198
|
modelPickEl.title = payload.warning ? `The model the new chat starts on · ${payload.warning}` : "The model the new chat starts on";
|
|
2139
|
-
modelPickEl.dataset.value = chosen; // sizes the select to the chosen name (CSS)
|
|
2140
2199
|
modelPickEl.hidden = false;
|
|
2141
2200
|
}
|
|
2142
|
-
modelSelectEl.addEventListener("change", () => {
|
|
2143
|
-
modelPickEl.dataset.value = modelSelectEl.value;
|
|
2144
|
-
try { localStorage.setItem(MODEL_KEY, modelSelectEl.value); } catch (_) {}
|
|
2145
|
-
});
|
|
2146
2201
|
// The picker's choice for a create request; nothing (the server's default)
|
|
2147
2202
|
// while the list is not in.
|
|
2148
2203
|
function chosenModel() {
|
|
2149
|
-
return modelPickEl.hidden ? undefined :
|
|
2204
|
+
return modelPickEl.hidden ? undefined : modelPicker.chosen() || undefined;
|
|
2150
2205
|
}
|
|
2151
2206
|
actionBtn.addEventListener("click", () => {
|
|
2152
2207
|
if (!selected) handleCreate();
|
|
@@ -2238,7 +2293,8 @@ fitPrompt();
|
|
|
2238
2293
|
const dockEl = $("#dock");
|
|
2239
2294
|
const centerEl = $("#center");
|
|
2240
2295
|
new ResizeObserver(() => {
|
|
2241
|
-
|
|
2296
|
+
// The stage's height too: a reader at the end stays there.
|
|
2297
|
+
const follow = atHistoryEnd();
|
|
2242
2298
|
centerEl.style.setProperty("--dock-h", `${dockEl.offsetHeight}px`);
|
|
2243
2299
|
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
2244
2300
|
}).observe(dockEl);
|
|
@@ -2261,12 +2317,12 @@ composerEl.addEventListener("drop", (e) => {
|
|
|
2261
2317
|
e.preventDefault();
|
|
2262
2318
|
addImageFiles(files);
|
|
2263
2319
|
});
|
|
2264
|
-
|
|
2320
|
+
turnRoots().forEach((root) => root.addEventListener("click", (e) => {
|
|
2265
2321
|
const img = e.target.closest("img.thumb");
|
|
2266
2322
|
if (!img) return;
|
|
2267
2323
|
lightboxEl.querySelector("img").src = img.src;
|
|
2268
2324
|
lightboxEl.hidden = false;
|
|
2269
|
-
});
|
|
2325
|
+
}));
|
|
2270
2326
|
lightboxEl.addEventListener("click", () => { lightboxEl.hidden = true; });
|
|
2271
2327
|
document.addEventListener("keydown", (e) => {
|
|
2272
2328
|
if (e.key === "Escape" && !lightboxEl.hidden) lightboxEl.hidden = true;
|
|
@@ -2499,7 +2555,7 @@ function updateAnnotate() {
|
|
|
2499
2555
|
const sel = window.getSelection();
|
|
2500
2556
|
if (!selected || currentOwner === "tui" || !sel || sel.isCollapsed || !sel.rangeCount) return hideAnnotate();
|
|
2501
2557
|
const range = sel.getRangeAt(0);
|
|
2502
|
-
if (!
|
|
2558
|
+
if (!turnRoots().some((root) => root.contains(range.startContainer))) return hideAnnotate();
|
|
2503
2559
|
const source = annotationSource(range.startContainer);
|
|
2504
2560
|
// A running turn's narration box shows complete sentences and its thinking
|
|
2505
2561
|
// is appended by delta; a selection in either is refused while the step is
|
|
@@ -2545,8 +2601,9 @@ function scheduleAnnotate() {
|
|
|
2545
2601
|
}
|
|
2546
2602
|
document.addEventListener("selectionchange", scheduleAnnotate);
|
|
2547
2603
|
historyEl.addEventListener("scroll", scheduleAnnotate);
|
|
2604
|
+
turnDom.scrollEl?.addEventListener("scroll", scheduleAnnotate, true);
|
|
2548
2605
|
window.addEventListener("resize", scheduleAnnotate);
|
|
2549
|
-
installCopy(
|
|
2606
|
+
turnRoots().forEach((root) => installCopy(root));
|
|
2550
2607
|
// Keep the selection when a button is pressed.
|
|
2551
2608
|
annotateBar.addEventListener("mousedown", (e) => e.preventDefault());
|
|
2552
2609
|
annotateBar.addEventListener("click", (e) => {
|
|
@@ -2583,19 +2640,19 @@ function tabInFront() {
|
|
|
2583
2640
|
return document.visibilityState === "visible" && document.hasFocus();
|
|
2584
2641
|
}
|
|
2585
2642
|
|
|
2586
|
-
function notifySupported() {
|
|
2587
|
-
return typeof globalThis.Notification === "function";
|
|
2588
|
-
}
|
|
2589
|
-
|
|
2590
2643
|
function notifyWanted() {
|
|
2591
2644
|
try { return localStorage.getItem(NOTIFY_KEY) === "1"; } catch (_) { return false; }
|
|
2592
2645
|
}
|
|
2593
2646
|
|
|
2594
|
-
// "on" | "off" | "denied" | "unsupported"
|
|
2647
|
+
// "on" | "off" | "denied" | "unsupported" (notify.js)
|
|
2595
2648
|
function notifyState() {
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2649
|
+
const hasNotification = typeof globalThis.Notification === "function";
|
|
2650
|
+
return bellState({
|
|
2651
|
+
hasNotification,
|
|
2652
|
+
secureContext: globalThis.isSecureContext !== false,
|
|
2653
|
+
permission: hasNotification ? Notification.permission : "default",
|
|
2654
|
+
wanted: notifyWanted(),
|
|
2655
|
+
});
|
|
2599
2656
|
}
|
|
2600
2657
|
|
|
2601
2658
|
const NOTIFY_TITLES = {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { newActivity, addStarted, addCompleted, finalizeActivity, activityCount, toolName } from "./activity.js";
|
|
12
12
|
import { copySourceAttr } from "./copy.js";
|
|
13
13
|
import { escapeHtml, leadTrimmed, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
|
|
14
|
+
import { activityDiffHtml } from "./diff_view.js";
|
|
14
15
|
import { thumbsHtml } from "./images.js";
|
|
15
16
|
import { shouldFollowScroll } from "./scroll.js";
|
|
16
17
|
import { tallyText } from "./tally.js";
|
|
@@ -348,7 +349,11 @@ export function fillActivityRow(el, row, sessionId) {
|
|
|
348
349
|
const toolEl = el.querySelector(".activity-tool");
|
|
349
350
|
if (toolEl) toolEl.textContent = toolName(row);
|
|
350
351
|
const paramsEl = el.querySelector(".activity-params");
|
|
351
|
-
if (paramsEl)
|
|
352
|
+
if (paramsEl) {
|
|
353
|
+
// The title in place of the params; the full params line on hover.
|
|
354
|
+
paramsEl.textContent = row.title || row.params || "";
|
|
355
|
+
paramsEl.title = row.title && row.params ? row.params : "";
|
|
356
|
+
}
|
|
352
357
|
const outEl = el.querySelector(".activity-output");
|
|
353
358
|
if (outEl) {
|
|
354
359
|
if (row.status === "running") {
|
|
@@ -366,4 +371,6 @@ export function fillActivityRow(el, row, sessionId) {
|
|
|
366
371
|
// The images a tool returned (the chat view's reloaded history has no tool
|
|
367
372
|
// rows; the turn view's has, turn_view.js reloadRowHtml).
|
|
368
373
|
if (row.images?.length && !el.querySelector(".thumbs")) el.insertAdjacentHTML("beforeend", thumbsHtml(sessionId, row.images));
|
|
374
|
+
// An edit/write's change, collapsed: "diff +3 −1".
|
|
375
|
+
if (row.diff && !el.querySelector(".activity-diff")) el.insertAdjacentHTML("beforeend", activityDiffHtml(row.diff));
|
|
369
376
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// What chi's edit/write tools change, as HTML, apart from the DOM. One
|
|
2
|
+
// renderer for both places: the approval card (the dry run, which can also
|
|
3
|
+
// say the edit would fail, or that the file isn't shown) and a tool row's
|
|
4
|
+
// collapsed "diff +3 −1" (what the call really changed). A diff is the
|
|
5
|
+
// server's {text, added, removed, truncated, new_file} (EditPreview).
|
|
6
|
+
|
|
7
|
+
import { escapeHtml } from "./format.js";
|
|
8
|
+
|
|
9
|
+
// More lines than this fold behind a "show all N lines" toggle.
|
|
10
|
+
export const DIFF_FOLD_LINES = 20;
|
|
11
|
+
|
|
12
|
+
const MINUS = "−";
|
|
13
|
+
|
|
14
|
+
// "+3 −1"
|
|
15
|
+
export function diffCounts(diff) {
|
|
16
|
+
return `+${Number(diff?.added) || 0} ${MINUS}${Number(diff?.removed) || 0}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A row's summary line: "diff +3 −1", "new file +12".
|
|
20
|
+
export function diffSummary(diff) {
|
|
21
|
+
return diff?.new_file ? `new file +${Number(diff.added) || 0}` : `diff ${diffCounts(diff)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function diffLineClass(line) {
|
|
25
|
+
if (line.startsWith("@@")) return "hunk";
|
|
26
|
+
if (line.startsWith("+")) return "add";
|
|
27
|
+
if (line.startsWith("-")) return "del";
|
|
28
|
+
if (line.startsWith("\\") || line.startsWith("… ")) return "meta";
|
|
29
|
+
return "ctx";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function linesHtml(lines) {
|
|
33
|
+
return lines.map((line) => `<span class="diff-line diff-${diffLineClass(line)}">${escapeHtml(line) || " "}</span>`).join("");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// @param diff an EditPreview: a diff, or {error} / {skipped}
|
|
37
|
+
// @return {string} HTML; "" for none
|
|
38
|
+
export function diffHtml(diff, { fold = DIFF_FOLD_LINES } = {}) {
|
|
39
|
+
if (!diff || typeof diff !== "object") return "";
|
|
40
|
+
if (diff.error) return `<div class="diff-note warn">this edit would fail: ${escapeHtml(diff.error)}</div>`;
|
|
41
|
+
if (diff.skipped) return `<div class="diff-note">diff not shown: ${escapeHtml(diff.skipped)}</div>`;
|
|
42
|
+
const text = String(diff.text ?? "");
|
|
43
|
+
if (!text) return `<div class="diff-note">no change</div>`;
|
|
44
|
+
const lines = text.split("\n");
|
|
45
|
+
const head = diff.new_file ? `<div class="diff-note">new file</div>` : "";
|
|
46
|
+
const shown = `<pre class="diff"><code>${linesHtml(lines.slice(0, fold))}</code></pre>`;
|
|
47
|
+
if (lines.length <= fold) return head + shown;
|
|
48
|
+
// The rest opens under the first lines; the toggle hides once open (CSS).
|
|
49
|
+
const rest = `<details class="diff-more"><summary>show all ${lines.length} lines</summary>` +
|
|
50
|
+
`<pre class="diff"><code>${linesHtml(lines.slice(fold))}</code></pre></details>`;
|
|
51
|
+
return head + shown + rest;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A tool row's collapsed diff (closed by default).
|
|
55
|
+
export function activityDiffHtml(diff) {
|
|
56
|
+
if (!diff || typeof diff !== "object" || diff.error || diff.skipped) return "";
|
|
57
|
+
return `<details class="activity-diff"><summary>${escapeHtml(diffSummary(diff))}</summary>${diffHtml(diff)}</details>`;
|
|
58
|
+
}
|