samagotchi 0.96.0 → 0.98.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -1
  3. data/bin/chi +3 -0
  4. data/docs/architecture.md +12 -2
  5. data/docs/bundle-authoring.md +7 -4
  6. data/docs/cli.md +13 -4
  7. data/docs/configuration.md +13 -0
  8. data/docs/hooks.md +59 -0
  9. data/docs/internals/background-tasks.md +1 -1
  10. data/docs/internals/bundles.md +2 -2
  11. data/docs/internals/engine.md +21 -1
  12. data/docs/memory.md +43 -3
  13. data/docs/plugins.md +8 -1
  14. data/docs/sessions.md +2 -2
  15. data/lib/samagotchi/answer_refs.rb +16 -9
  16. data/lib/samagotchi/bundle_command/check.rb +5 -3
  17. data/lib/samagotchi/bundle_command/try/run.rb +19 -4
  18. data/lib/samagotchi/bundle_command.rb +2 -2
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +39 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/engine.rb +75 -40
  24. data/lib/samagotchi/hooks/bundle_loader.rb +18 -15
  25. data/lib/samagotchi/hooks.rb +4 -1
  26. data/lib/samagotchi/llm/errors.rb +135 -3
  27. data/lib/samagotchi/memory_bundle/bundle_hook.rb +28 -9
  28. data/lib/samagotchi/memory_bundle/manifest.rb +17 -0
  29. data/lib/samagotchi/memory_bundle/profile.rb +14 -4
  30. data/lib/samagotchi/memory_command.rb +237 -0
  31. data/lib/samagotchi/output_formatter.rb +8 -2
  32. data/lib/samagotchi/pending_question.rb +7 -3
  33. data/lib/samagotchi/plugin/api.rb +12 -3
  34. data/lib/samagotchi/question_asked.rb +83 -0
  35. data/lib/samagotchi/question_desk.rb +3 -2
  36. data/lib/samagotchi/send_command.rb +11 -11
  37. data/lib/samagotchi/session_inbox.rb +29 -0
  38. data/lib/samagotchi/session_manager.rb +11 -0
  39. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -0
  40. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  41. data/lib/samagotchi/terminal_ui.rb +1 -0
  42. data/lib/samagotchi/testing/bundle.rb +18 -1
  43. data/lib/samagotchi/tools/memory.rb +1 -1
  44. data/lib/samagotchi/turn_checkpoint.rb +6 -0
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +30 -9
  47. data/lib/samagotchi/web/public/app.js +44 -11
  48. data/lib/samagotchi/web/public/index.html +36 -13
  49. data/lib/samagotchi/web/public/info_bar_model.js +0 -2
  50. data/lib/samagotchi/web/public/lists_view.js +32 -8
  51. data/lib/samagotchi/web/public/markdown_place.js +25 -8
  52. data/lib/samagotchi/web/public/question_cards.js +23 -6
  53. data/lib/samagotchi/web/public/session_rename.js +88 -15
  54. data/lib/samagotchi/web/public/session_title.js +6 -0
  55. data/lib/samagotchi/web/public/stage_frame.js +24 -14
  56. data/lib/samagotchi/web/public/stage_model.js +4 -2
  57. data/lib/samagotchi/web/public/stage_slots.js +41 -9
  58. data/lib/samagotchi/web/public/stage_view.js +149 -8
  59. data/lib/samagotchi/web/public/stream_handlers.js +5 -1
  60. data/lib/samagotchi/web/public/turn_model.js +20 -0
  61. data/lib/samagotchi/web/public/turn_view.js +22 -9
  62. data/lib/samagotchi/worker.rb +19 -5
  63. data/lib/samagotchi/worker_idle_exit.rb +16 -8
  64. metadata +3 -1
@@ -727,6 +727,8 @@ module Samagotchi
727
727
  @screen.commit("#{paint("model>", 36)} #{output}")
728
728
  end
729
729
  prefill_confirm(event)
730
+ # /title from any client: the row's title follows.
731
+ @status.update(title: event[:title]) if Array(event[:changed]).include?("title")
730
732
  return unless event[:model_name]
731
733
 
732
734
  # After the output, so the status row changes with the line saying why.
@@ -5,7 +5,7 @@ require_relative "formatting"
5
5
  module Samagotchi
6
6
  class TerminalUI
7
7
  # The status row under the prompt, the same for both TUIs:
8
- # `status> model=… | ↳ parent | ⑂ 2 running · 1 waiting | ctx=12.3% (under20) | mem: … | muted: …`.
8
+ # `status> Title | model=… | ↳ parent | ⑂ 2 running · 1 waiting | ctx=12.3% (under20) | mem: … | muted: …`.
9
9
  # A UI feeds it what it learns (attached mode from the Bridge's events,
10
10
  # the REPL from its Engine's) through #update, and the row is redrawn in
11
11
  # the surface's status slot only when its text changes.
@@ -15,6 +15,8 @@ module Samagotchi
15
15
  # Memory names shown before ", +N".
16
16
  MEMORY_LIMIT = 8
17
17
  # What the row shows:
18
+ # title the session's own title (Session#title; not
19
+ # the first message's preview), or nil
18
20
  # model, default_model the model in use, and the config's default
19
21
  # (named beside another model)
20
22
  # served [served, asked, expected]: what the server said it
@@ -29,12 +31,17 @@ module Samagotchi
29
31
  # records them as used)
30
32
  # muted its --mute list
31
33
  # running a turn runs: the row trails STEER_HINT
32
- FIELDS = %i[model default_model served parent_id children context used_memories preloaded muted running].freeze
34
+ FIELDS = %i[title model default_model served parent_id children context used_memories preloaded muted running].freeze
33
35
 
34
36
  # The tail while a turn runs: where a typed line goes (the next step)
35
37
  # and the two other choices. Dropped whole first when the row is cut.
36
38
  STEER_HINT = " · typing goes in at the next step (/cut, /queue)"
37
39
 
40
+ # The longest title shown, and the share of the row it may take on a
41
+ # narrow terminal: the rest of the row still fits.
42
+ TITLE_MAX = 32
43
+ TITLE_SHARE = 3
44
+
38
45
  # The turn events that end a turn (the hint goes).
39
46
  TURN_END_EVENTS = %i[turn_completed turn_canceled turn_failed].freeze
40
47
 
@@ -74,6 +81,7 @@ module Samagotchi
74
81
  fields.merge!(model: state[:model_name], default_model: default_model) if state[:model_name]
75
82
  STATE_LISTS.each { |field, key| fields[field] = Array(state[key]) if state.key?(key) }
76
83
  fields[:parent_id] = state[:parent_id] if state.key?(:parent_id)
84
+ fields[:title] = state[:title] if state.key?(:title)
77
85
  # The last turn's ctx, so it shows before the next turn's.
78
86
  fields[:context] = state[:context_status] if state[:context_status].is_a?(Hash)
79
87
  update(**fields)
@@ -126,7 +134,8 @@ module Samagotchi
126
134
  model_text = status_model_text(model, @values[:default_model], served: served, served_for: served_for,
127
135
  expected: expected.is_a?(String))
128
136
  end
129
- segments = [model_text,
137
+ segments = [title_text(@values[:title], width),
138
+ model_text,
130
139
  parent ? "↳ #{parent.to_s[0, 8]}" : "",
131
140
  children_text(@values[:children]),
132
141
  status_context_text(estimate: @values[:context]),
@@ -147,6 +156,18 @@ module Samagotchi
147
156
  segments[0..-2] + ["#{segments.last}#{STEER_HINT}"]
148
157
  end
149
158
 
159
+ # The title cut to TITLE_MAX, and to a TITLE_SHARE-th of +width+
160
+ # (the row's other segments come after it); "" without one.
161
+ def title_text(title, width)
162
+ title = title.to_s.strip
163
+ return "" if title.empty?
164
+
165
+ max = [TITLE_MAX, width / TITLE_SHARE].min
166
+ return title if title.length <= max
167
+
168
+ max > 1 ? "#{title[0, max - 1]}…" : ""
169
+ end
170
+
150
171
  # `⑂ 2 running · 1 waiting`, the counts there are; "" with neither (no
151
172
  # children, or all ended). Not "not reported": a REPL parent is never
152
173
  # given its children's replies (no wakes there), and a worker moves
@@ -889,6 +889,7 @@ module Samagotchi
889
889
  if command
890
890
  show_command_result(command)
891
891
  refresh_status_row(clear_unknown_context: true) if command.changed.include?(:model)
892
+ refresh_status_row if command.changed.include?(:title)
892
893
  persist_recent_history(input) if command.shell
893
894
  return
894
895
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "../plugin/loader"
4
4
  require_relative "../plugin/tool_spec"
5
5
  require_relative "../hooks"
6
+ require_relative "../question_asked"
6
7
  require_relative "../commands/registry"
7
8
  require_relative "../session_commands"
8
9
  require_relative "../tools/builtins"
@@ -179,7 +180,7 @@ module Samagotchi
179
180
  end
180
181
  @hooks_namespace = Module.new
181
182
  @manifest.hooks.each do |basename, hook|
182
- next if hook.event.empty?
183
+ next if hook.events.empty?
183
184
 
184
185
  Hooks::BundleLoader.load_hook_file(@name, basename, File.join(@dir, "hooks", basename), hook, hooks,
185
186
  settings: @settings, namespace: @hooks_namespace)
@@ -203,6 +204,22 @@ module Samagotchi
203
204
  payload
204
205
  end
205
206
 
207
+ # Fire :question_asked as chi's two seams do (QuestionAsked.fire): a
208
+ # question (+kind+ "question") or an approval ("approval") asked by a
209
+ # step whose text is +text+, with the question's +question+ fields.
210
+ # The hooks' refs are checked as chi checks them (the question caps,
211
+ # file refs under +cwd+, default the session's folder).
212
+ # @return [Array<Hash>] the refs the step and its pending question
213
+ # get, as stored; [] for none
214
+ def question_asked(text, kind: "question", question: {}, cwd: nil)
215
+ step = { role: "model", content: text.to_s }
216
+ fields = failing do
217
+ QuestionAsked.fire(hooks: hooks, conversation: [step], kind: kind.to_s, fields: question.to_h,
218
+ cwd: cwd || @recorder[:cwd])
219
+ end
220
+ Array(fields[:refs])
221
+ end
222
+
206
223
  # Build the before_tool_call payload from the real Gate, without firing
207
224
  # this bundle's hooks.
208
225
  # @param call [Hash] the parsed tool call
@@ -356,7 +356,7 @@ module Samagotchi
356
356
  ([file] + overlay_files(dir, entry_name, index_text)).each { |path| trash.move(path) }
357
357
  Samagotchi::MemoryBundle::IndexUpdater.remove_index(resolved_scope, entry_name)
358
358
  "Memory '#{entry_name}' removed from #{resolved_scope} scope: moved to #{trash.dir} " \
359
- "(`chi bundle trash` lists and empties it); index line dropped."
359
+ "(`chi bundle trash` lists and empties it)#{"; index line dropped" if line}."
360
360
  end
361
361
 
362
362
  # The entry's model overlays: <name>.<key>.md with a key ModelOverlay
@@ -86,6 +86,12 @@ module Samagotchi
86
86
  settled_record(id)
87
87
  end
88
88
 
89
+ # The settled outcomes past +max_age+ (SessionInbox.prune_old).
90
+ # @return [Integer] how many went
91
+ def self.prune_settled(session_dir, max_age: SessionInbox::TOMBSTONE_MAX_AGE)
92
+ SessionInbox.prune_old(session_dir, SETTLED_DIR, max_age: max_age)
93
+ end
94
+
89
95
  def self.settled_record(id, ended_at: nil, seconds: nil)
90
96
  LastTurn.new(outcome: "canceled", cancel_reason: WORKER_STOPPED, ended_at: ended_at, seconds: seconds, enqueued_id: id)
91
97
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.96.0"
4
+ VERSION = "0.98.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -14,6 +14,7 @@ require_relative "../client_id"
14
14
  require_relative "../delivery"
15
15
  require_relative "../answer_tail"
16
16
  require_relative "../answer_refs"
17
+ require_relative "../question_asked"
17
18
  require_relative "../bridge_client"
18
19
  require_relative "../bridge/bounded_queue"
19
20
  require_relative "../bridge/card_store"
@@ -839,7 +840,7 @@ module Samagotchi
839
840
  plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
840
841
  # Plugins' slow setup still running (chi.init): the page's init row.
841
842
  init_tasks: snapshot_list(turn_snapshot, "init_tasks"),
842
- cards: cards_for_display(live_or_saved(turn_snapshot, "cards") { saved_cards(id) }),
843
+ cards: cards_for_display(live_or_saved(turn_snapshot, "cards") { saved_cards(id) }, cwd: session.working_directory),
843
844
  # The composer's / autocomplete; the built-ins until a worker
844
845
  # names its plugins' too.
845
846
  commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
@@ -871,13 +872,28 @@ module Samagotchi
871
872
  # dead worker saved can't be answered (its turn is gone), nor a chi
872
873
  # REPL's (it shares nothing with the web).
873
874
  # A question between turns (the step-limit one) is the snapshot's own.
875
+ # Its refs (the asking reply's, QuestionAsked) are checked again.
874
876
  def shown_pending_question(session, turn_snapshot, owner)
875
- if turn_snapshot
876
- current_turn = turn_snapshot["current_turn"]
877
- current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
878
- elsif owner&.worker?
879
- session.pending_question&.to_h
880
- end
877
+ pending = if turn_snapshot
878
+ current_turn = turn_snapshot["current_turn"]
879
+ current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
880
+ elsif owner&.worker?
881
+ session.pending_question&.to_h
882
+ end
883
+ with_question_refs(pending, cwd: session.working_directory)
884
+ end
885
+
886
+ # A pending question (a snapshot's, the session file's or a saved
887
+ # card's) with its refs checked again, under a question's caps
888
+ # (QuestionAsked): a stored file is never trusted, and what isn't
889
+ # valid any more is dropped. The question as it was without refs.
890
+ def with_question_refs(pending, cwd:)
891
+ return pending unless pending.is_a?(Hash)
892
+
893
+ key = [:refs, "refs"].find { |name| pending.key?(name) } or return pending
894
+ refs = Samagotchi::AnswerRefs.for_display(pending[key], cwd: cwd, max_refs: Samagotchi::QuestionAsked::MAX_REFS,
895
+ max_bytes: Samagotchi::QuestionAsked::MAX_BYTES)
896
+ refs.empty? ? pending.except(key) : pending.merge(key => refs)
881
897
  end
882
898
 
883
899
  # The full show's session part: the file's, with what a live worker's
@@ -1031,11 +1047,16 @@ module Samagotchi
1031
1047
  # cards: CardStore#list), else (no live worker) the ones the last
1032
1048
  # worker saved in the session's folder (#saved_cards), a card's body as
1033
1049
  # body_html: rendered markdown when the renderer is on, else the
1034
- # escaped text in a <pre>.
1050
+ # escaped text in a <pre>, and a question card's refs checked again
1051
+ # (#with_question_refs; +cwd+ the session's folder, nil where the
1052
+ # session file isn't read: a file ref then stays only absolute).
1035
1053
  # @return [Array<Hash>]
1036
- def cards_for_display(cards)
1054
+ def cards_for_display(cards, cwd: nil)
1037
1055
  Array(cards).filter_map do |card|
1038
1056
  next unless card.is_a?(Hash)
1057
+ if card["type"].to_s == "question" && card["pending_question"].is_a?(Hash)
1058
+ next card.merge("pending_question" => with_question_refs(card["pending_question"], cwd: cwd))
1059
+ end
1039
1060
  next card unless card["type"].to_s == "card"
1040
1061
 
1041
1062
  card.merge("body_html" => card_body_html(card["body"].to_s))
@@ -61,13 +61,13 @@ import { createStripGrip } from "./strip_grip.js";
61
61
  import { waitingOn } from "./notify.js";
62
62
  import { applySessionEvent, sortedByUpdated } from "./sessions_list.js";
63
63
  import { createNotifications } from "./notifications.js";
64
- import { tabTitle, topBarTitle } from "./session_title.js";
64
+ import { renameShown, tabTitle, topBarTitle } from "./session_title.js";
65
65
  import { restartConfirmText, versionNotice } from "./update_notice.js";
66
66
  import {
67
67
  appendAboveLiveTiming, mergeTiming, normalizeTiming,
68
68
  } from "./timing.js";
69
69
  import { createLiveTurn } from "./live_turn.js";
70
- import { applyFinalAnswer, pairAnswerRefs } from "./answer_refs.js";
70
+ import { applyFinalAnswer, pairAnswerRefs, setAnswerRefs } from "./answer_refs.js";
71
71
  import { installRefClicks } from "./refs.js";
72
72
  import { linkRefs, touchedFiles } from "./ref_scan.js";
73
73
  import { DONE_MS, applyInitEvent, dropDone, fadeDone, initRowTemplate, newInitTasks, seedInitTasks } from "./init_row.js";
@@ -97,6 +97,8 @@ const composerEl = $("#composer");
97
97
  // The open session's title in the top bar, when no strip card in sight
98
98
  // shows it; the tab's base ("Chi · <project>"), set by renderScope.
99
99
  const sessionTitleEl = $("#sessionTitle");
100
+ const sessionTitleRowEl = $("#sessionTitleRow");
101
+ const titleRenameBtn = $("#titleRenameBtn");
100
102
  let tabBase = null;
101
103
  let tabShown = null;
102
104
  const infoTextEl = $("#infoText");
@@ -210,8 +212,9 @@ const markdownPlace = createMarkdownPlace({
210
212
  findInTurnRoots: (selector) => findInTurnRoots(selector),
211
213
  normalize,
212
214
  pairAnswerRefs,
215
+ setAnswerRefs,
213
216
  });
214
- const { renderMarkdown, linkRefsIn, applyFinalMarkdown, linkTextRefs } = markdownPlace;
217
+ const { renderMarkdown, pairRefsIn, linkRefsIn, applyFinalMarkdown, linkTextRefs } = markdownPlace;
215
218
  const turnDom = view === "stage"
216
219
  ? createStageView({
217
220
  ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl,
@@ -291,6 +294,7 @@ const questionCards = createQuestionCards({
291
294
  askingText: () => turnDom.askingText(),
292
295
  renderMarkdown,
293
296
  linkRefs: linkRefsIn,
297
+ pairRefs: pairRefsIn,
294
298
  });
295
299
  // The history's bubbles (bubbles.js): output, end lines, every client's
296
300
  // prompts, the recap, warnings and notices, command and note bubbles.
@@ -473,6 +477,9 @@ const lists = createListsView({
473
477
  allViewOpen: () => allViewOpen(),
474
478
  openAllView: () => openAllView(),
475
479
  leaveAllView: (hash) => leaveAllView(hash),
480
+ renameShown,
481
+ onRename: () => rename.open(),
482
+ renaming: () => rename.isOpen(),
476
483
  });
477
484
 
478
485
  // Continue, Archive, Delete and Stop, and the start page when the open
@@ -496,13 +503,31 @@ const sessionActions = createSessionActions({
496
503
  closeLiveStream: () => streamControl?.close(),
497
504
  workerGone,
498
505
  });
499
- // ✎: /title through the chips' path (session_rename.js).
506
+ // The title's ✎: /title through the chips' path (session_rename.js), the
507
+ // input over the title row on screen: the top bar's when it shows the
508
+ // title, else the active strip card's (updateSessionTitle decides which).
500
509
  const rename = createRename({
501
- button: $("#infoRenameBtn"),
510
+ doc: document,
511
+ anchor: () => {
512
+ if (!sessionTitleRowEl.hidden) return sessionTitleRowEl;
513
+ const row = topStripEl.querySelector(".card.active .preview-row");
514
+ return row && row.getClientRects().length ? row : null; // not in the All sessions view's hidden strip
515
+ },
502
516
  session: () => selected,
503
517
  titleOf: (id) => lists.sessionsById().get(id)?.title || null,
518
+ shownTitle: () => shownTitle(),
519
+ closePopovers: () => {
520
+ lists.familyPop.close();
521
+ lists.chainPop.close();
522
+ },
523
+ // Not from inside a strip draw (its paintActive repositions, which may
524
+ // close): the held strip catches up after it.
525
+ onClose: () => queueMicrotask(() => lists.catchUp()),
504
526
  run: (line) => runSessionCommand(line),
505
527
  });
528
+ titleRenameBtn.addEventListener("click", () => rename.open());
529
+ document.addEventListener("scroll", () => rename.reposition(), true);
530
+ window.addEventListener("resize", () => rename.reposition());
506
531
 
507
532
  const { confirmDelete, leaveGoneSession, continueSelected, archiveOne, setArchived } = sessionActions;
508
533
 
@@ -871,6 +896,8 @@ function renderSessionView(data, { kept = [] } = {}) {
871
896
  snapshotCards.during.forEach((card) => upsertCard(card));
872
897
  // A question the worker is blocked on (also after a reload).
873
898
  if (data.pending_question && data.pending_question.id && data.pending_question.status === "pending") {
899
+ // Its refs on the asking step, as question_requested puts them.
900
+ if (data.pending_question.refs) turnDom.questionRefs?.(data.pending_question.refs);
874
901
  questionCards.renderQuestion(data.pending_question);
875
902
  // The turn's replay drew it as asked; the snapshot knows whether it is
876
903
  // relayed to the parent now.
@@ -1055,9 +1082,6 @@ function updateInfoBar() {
1055
1082
  const deleteBtn = document.getElementById("infoDeleteBtn");
1056
1083
  const archiveBtn = document.getElementById("infoArchiveBtn");
1057
1084
  const continueBtn = document.getElementById("infoContinueBtn");
1058
- const renameBtn = document.getElementById("infoRenameBtn");
1059
- if (renameBtn) renameBtn.style.display = buttons.rename.shown ? "" : "none";
1060
- rename.follow(buttons.rename.shown ? selected : null);
1061
1085
  if (continueBtn) {
1062
1086
  continueBtn.style.display = buttons.continue.shown ? "" : "none";
1063
1087
  continueBtn.disabled = buttons.continue.disabled;
@@ -1087,11 +1111,16 @@ function updateInfoBar() {
1087
1111
  // card says it when the strip is shown and has one; else the top bar. The
1088
1112
  // browser tab names it too. It follows the card's (openTitle): a chain link
1089
1113
  // reads its card's title (cardTitle), whatever opener it was sent.
1090
- function updateSessionTitle() {
1114
+ function shownTitle() {
1091
1115
  const shown = selected ? lists.sessionsById().get(selected) : null;
1092
1116
  const first = current.firstPreview || "";
1093
1117
  const raw = !selected ? "" : openTitle({ shown, first, cardTitle: (s) => lists.cardTitle(s) });
1094
- const title = raw === "\u2014" ? "" : raw; // "—": no first message yet
1118
+ return raw === "\u2014" ? "" : raw; // "—": no first message yet
1119
+ }
1120
+ // The top bar's ✎ follows it (not a plain terminal's session), and an open
1121
+ // rename input follows the title row on screen (or closes, none showing).
1122
+ function updateSessionTitle() {
1123
+ const title = shownTitle();
1095
1124
  const text = topBarTitle({
1096
1125
  title,
1097
1126
  cardShown: !!topStripEl.querySelector(".card.active"),
@@ -1099,7 +1128,9 @@ function updateSessionTitle() {
1099
1128
  allView: allViewOpen(),
1100
1129
  });
1101
1130
  if (sessionTitleEl.textContent !== text) sessionTitleEl.textContent = text;
1102
- if (sessionTitleEl.hidden !== !text) sessionTitleEl.hidden = !text;
1131
+ if (sessionTitleRowEl.hidden !== !text) sessionTitleRowEl.hidden = !text;
1132
+ const renamable = !!text && renameShown(current.owner);
1133
+ if (titleRenameBtn.hidden !== !renamable) titleRenameBtn.hidden = !renamable;
1103
1134
  const tip = text ? title : "";
1104
1135
  if (sessionTitleEl.title !== tip) sessionTitleEl.title = tip;
1105
1136
  const tab = tabBase === null ? null : tabTitle({ title, base: tabBase });
@@ -1107,6 +1138,8 @@ function updateSessionTitle() {
1107
1138
  tabShown = tab;
1108
1139
  notifications.setBaseTitle(tab);
1109
1140
  }
1141
+ rename.follow(selected);
1142
+ rename.reposition();
1110
1143
  }
1111
1144
 
1112
1145
  // The stage takes a running turn's rows (its extras); else the history.
@@ -779,6 +779,10 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
779
779
  text, then its markdown when web.markdown is on (.markdown, stage_slots.js drawAsking); capped so the card keeps
780
780
  its room, read from the top. */
781
781
  .ts-asking{max-height:calc(var(--stage-max,60vh) * .35);overflow:auto;scrollbar-width:thin;white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.55;color:var(--narr);border-left:2px solid var(--border-strong);padding-left:10px}
782
+ /* The question room (stage_view.js drawRoom): a model's own question whose asking text overflows that cap. The slot loses its
783
+ cap and its own scroll, and the stage's one scroll covers the whole reply (the stage's cap rises for the turn, stage_frame.js).
784
+ --stage-max also scales the extras, the cloud and the queued slot: a ceiling only, content that fit stays as it is. */
785
+ .turn-stage.question-room .ts-asking{max-height:none;overflow:visible}
782
786
  .turn-stage.asking .ts-headline{display:none}
783
787
  @keyframes ts-fade{from{opacity:.35}}
784
788
  .ts-now{height:var(--ts-now-h);display:flex;align-items:center;gap:8px;min-width:0;padding:0 9px;border-radius:8px;background:var(--accent-tint);font-size:13px}
@@ -820,7 +824,12 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
820
824
  .ts-answer>.bubble.output>.copy-btn{top:0;right:0}
821
825
  /* The cloud: one chip (steps, calls, a tick per call), the turn view's block behind it. */
822
826
  /* The footer: the chip at the stage's bottom edge (the cloud's steps open above it, the scroller's last slot). */
823
- .ts-foot{flex:none;padding:0 14px 10px}
827
+ .ts-foot{position:relative;flex:none;padding:0 14px 10px}
828
+ /* The question room's jump pill (stage_view.js drawPill): at the scroller's bottom edge, above the chip, while the waiting
829
+ card is out of the stage's view; absolute, so the frame never counts it. */
830
+ .ts-room-pill{position:absolute;bottom:100%;right:14px;margin-bottom:6px;padding:4px 11px;border:1px solid var(--warn-border);border-radius:999px;background:var(--warn-bg);color:var(--warn-text);font-size:12.5px;font-weight:600;line-height:1.4;white-space:nowrap;box-shadow:0 2px 8px var(--shadow-strong);animation:ts-fade .2s ease}
831
+ .ts-room-pill:hover:not(:disabled){filter:none;color:var(--warn-bright);border-color:var(--warn);background:var(--warn-bg)}
832
+ .ts-room-pill[hidden]{display:none}
824
833
  /* Below the turn's card: the lines queued for after it (each a turn of its own), apart from the turn by a dashed rule. */
825
834
  .ts-queued{flex:none;display:flex;flex-direction:column;gap:6px;padding:8px 14px 10px;border-top:1px dashed var(--border-strong);max-height:calc(var(--stage-max,60vh) * .3);overflow:auto;scrollbar-width:thin}
826
835
  .ts-queued:empty{display:none}
@@ -887,10 +896,6 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
887
896
  #infoBar #infoStopBtn{background:var(--bg-raised);color:var(--warn-text);border:1px solid var(--border-strong)}
888
897
  #infoBar #infoStopBtn:hover{background:var(--warn-bg);border-color:var(--warn-border)}
889
898
  #infoBar #infoArchiveBtn{background:transparent;color:var(--text-muted);border:1px solid var(--border-strong)}
890
- #infoBar #infoRenameBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
891
- #infoBar #infoRenameBtn:hover{color:var(--text);background:var(--bg-hover)}
892
- /* ✎'s input (session_rename.js), in the button's place. */
893
- #infoBar .rename-input{flex:0 1 18em;min-width:6em;font:inherit;font-size:12px;padding:3px 8px;border-radius:8px;border:1px solid var(--accent-border);background:var(--bg-raised);color:var(--text);outline:none}
894
899
  #infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
895
900
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
896
901
  #infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
@@ -980,8 +985,6 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
980
985
  #infoBar>button{font-size:0;line-height:0;padding:5px 7px}
981
986
  #infoBar>button::before{content:"";display:block;width:14px;height:14px;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
982
987
  #infoBar #infoArchiveBtn{--icon:var(--icon-archive)}
983
- #infoBar #infoRenameBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.5 2.5l3 3L5.5 13.5H2.5v-3z'/%3E%3C/svg%3E")}
984
- #infoBar .rename-input{flex:1 1 8em;font-size:16px}
985
988
  #infoBar #infoArchiveBtn.archived{--icon:var(--icon-unarchive)}
986
989
  #infoBar #infoContinueBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 8h9M8.5 4.5 12 8l-3.5 3.5'/%3E%3C/svg%3E")}
987
990
  #infoBar #infoCancelBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='4' width='8' height='8' rx='1.5'/%3E%3C/svg%3E")}
@@ -1031,16 +1034,32 @@ body.has-session #scope .scope-where{display:none}
1031
1034
  #newBtn .brand-new{font-size:16px}
1032
1035
  }
1033
1036
  /* The bell (notifications): quiet at the top bar's right end; filled in the accent when on, struck through when the browser blocks it. */
1034
- /* The open session's title, when no strip card in sight shows it (session_title.js): the bar's middle, cut with an ellipsis; the whole first message in its tooltip. */
1035
- #sessionTitle{flex:1;min-width:0;font-size:13px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1036
- #sessionTitle[hidden]{display:none}
1037
+ /* The open session's title, when no strip card in sight shows it (session_title.js): the bar's middle, cut with an ellipsis; the whole first message in its tooltip.
1038
+ Its row holds it and its ✎ (session_rename.js), the ✎ right after the text (the text doesn't grow, the row does). */
1039
+ #sessionTitleRow{flex:1;min-width:0;display:flex;align-items:center;gap:2px}
1040
+ #sessionTitleRow[hidden]{display:none}
1041
+ #sessionTitle{flex:0 1 auto;min-width:0;font-size:13px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1042
+ /* The title's ✎ (the top bar's and the active strip card's): quiet, the text's colour on hover. */
1043
+ .title-rename,.card-rename{flex-shrink:0;min-height:0;padding:0 5px;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--text-muted);font-size:13px;line-height:20px;cursor:pointer}
1044
+ .title-rename:hover,.card-rename:hover{color:var(--text);background:var(--bg-hover)}
1045
+ .title-rename[hidden]{display:none}
1046
+ /* The rename input (session_rename.js): fixed over the title row it renames, at the end of body (the strip draws its cards
1047
+ anew on every session event); above the selection bar (20), under the popovers (40). */
1048
+ .rename-input{position:fixed;z-index:30;box-sizing:border-box;margin:0;font:inherit;font-size:var(--rename-font,14px);padding:0 8px;border-radius:8px;border:1px solid var(--accent-border);background:var(--bg-raised);color:var(--text);outline:none;box-shadow:var(--glass-shadow)}
1049
+ .rename-input::placeholder{color:var(--text-faint)}
1050
+ /* Over the top bar's title row (as wide as the bar's middle): a title's width, not a field to the bell. */
1051
+ .rename-input.over-bar{max-width:32em}
1037
1052
  /* A phone: the title is its own row under the bar, so "chi + · pill · bell" stay one line. Nothing on that line wraps to a
1038
1053
  row of its own when the pill's text grows mid-turn ("· 1 waiting"): the scope chip and the pill have a base size of 0 (what
1039
1054
  a wrapping line counts) and grow to their text, the pill first; the scope chip cuts with an ellipsis before the pill does.
1040
1055
  The pill drops its session count while it says live or waiting, so the scope chip keeps its words. */
1041
1056
  @media(max-width:600px){
1042
1057
  #topBar{flex-wrap:wrap;row-gap:2px}
1043
- #sessionTitle{flex-basis:100%;order:10;font-size:12px}
1058
+ #sessionTitleRow{flex-basis:100%;order:10}
1059
+ #sessionTitle{font-size:12px}
1060
+ .title-rename{font-size:12px;line-height:18px}
1061
+ /* Under 16px iOS Safari zooms the page on focus. */
1062
+ .rename-input{font-size:16px}
1044
1063
  #scope,#stripStatus{flex:1 1 0;min-width:0;max-width:max-content}
1045
1064
  #stripStatus{flex-grow:1000;overflow:hidden;text-overflow:ellipsis}
1046
1065
  #scope .scope-name{flex-shrink:1;min-width:0}
@@ -1223,6 +1242,11 @@ body.all-view #center, body.all-view #topStrip{display:none}
1223
1242
  @media (hover:none){.card-tools button{opacity:1}}
1224
1243
  .card.active{background:var(--accent-bg);border-color:var(--accent-border)}
1225
1244
  .card .preview{font-size:14px;line-height:1.4;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1245
+ /* A strip card's title line with its ✎, shown on the active card alone (lists_view.js draws it on each, a select only paints). */
1246
+ .card .preview-row{display:flex;align-items:center;gap:2px;min-width:0}
1247
+ .card .preview-row .preview{flex:1;min-width:0}
1248
+ .card .card-rename{display:none}
1249
+ #topStrip .card.active .card-rename{display:block}
1226
1250
  .card .time{font-variant-numeric:tabular-nums;white-space:nowrap}
1227
1251
  .card .ctx{font-variant-numeric:tabular-nums;white-space:nowrap;color:var(--text-3)}
1228
1252
  #topStrip .card .preview{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -1262,7 +1286,7 @@ a{color:var(--accent)}
1262
1286
  <button type="button" id="newBtn" class="brand" title="New session"><span class="brand-name">chi</span><span class="brand-new">+<span class="brand-new-word"> new session</span></span></button>
1263
1287
  <div id="scope" hidden></div>
1264
1288
  <button type="button" class="strip-toggle" id="stripStatus" title="All sessions, with search"><span class="pill-count">sessions</span></button>
1265
- <span id="sessionTitle" hidden></span>
1289
+ <span id="sessionTitleRow" hidden><span id="sessionTitle"></span><button type="button" id="titleRenameBtn" class="title-rename" title="Rename this session" aria-label="Rename this session" hidden>✎</button></span>
1266
1290
  <button type="button" id="notifyBtn" aria-label="Notifications" aria-pressed="false" hidden><svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><path class="bell" d="M8 1.75a4 4 0 0 0-4 4v2.6L2.75 10.6v.9h10.5v-.9L12 8.35v-2.6a4 4 0 0 0-4-4Z"/><path class="clapper" d="M6.5 13a1.5 1.5 0 0 0 3 0"/><path class="slash" d="M2.5 2.5l11 11"/></svg></button>
1267
1291
  </div>
1268
1292
  <section id="topStrip" aria-label="Latest sessions" class="loading"><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div></section>
@@ -1306,7 +1330,6 @@ a{color:var(--accent)}
1306
1330
  <div class="info" id="infoText"></div>
1307
1331
  <div id="contextChips" aria-label="Attached context" hidden></div>
1308
1332
  <button class="ghost" id="infoCancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
1309
- <button class="ghost" id="infoRenameBtn" title="Rename this session" aria-label="Rename this session" style="display:none">✎</button>
1310
1333
  <button class="ghost" id="infoContinueBtn" title="Continue: start the next link of this session's chain from its recap (this one is archived)" style="display:none">continue →</button>
1311
1334
  <button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
1312
1335
  <button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop session</button>
@@ -42,8 +42,6 @@ export function infoButtons({ id, state, chain, archived, continuing, streaming
42
42
  const busy = !!id && continuing;
43
43
  return {
44
44
  continue: { shown: !!id && !chain.next && state.owner !== "tui", disabled: busy, text: busy ? "continuing…" : "continue →" },
45
- // ✎: /title from the web (a plain terminal's session takes no commands).
46
- rename: { shown: !!id && state.owner !== "tui" },
47
45
  delete: { shown: !!id },
48
46
  archive: {
49
47
  shown: !!id,
@@ -21,6 +21,9 @@
21
21
  // confirmDelete(id), setArchived(id, archive), archiveOne(id, archive)
22
22
  // updateInfoBar(), updateSessionTitle(), showToast(text, action)
23
23
  // allViewOpen(), openAllView(), leaveAllView(hash)
24
+ // renameShown(owner) whether a card's title has its ✎ (session_title.js)
25
+ // onRename() the active card's ✎ clicked (session_rename.js open)
26
+ // renaming() whether that rename input is open (the strip holds)
24
27
 
25
28
  import { ownerBadge, relativeTime, delegatedBy } from "./format.js";
26
29
  import { html, nothing, render } from "./vendor/lit-html.js";
@@ -134,7 +137,9 @@ export function cardAnimStyle({ enterMs = null, now = null } = {}, period = DOT_
134
137
  // +ctx+: title (cardTitle), projectName (the page's scope), activeId (the
135
138
  // open session), chainOpen (its chain's popover is open), parent (the
136
139
  // delegating session, or null).
137
- export function cardTemplate(s, { deletable = false, archivable = false, selectable = false, picked = false, skipReason = null, family = null, open = false, rows = false, matchIds = null, entering = false, anim = "" } = {}, { title = "", projectName = null, activeId = null, chainOpen = false, parent = null } = {}) {
140
+ // renamable: the title line ends in a ✎ (the strip's cards; CSS shows it on
141
+ // the active one alone, since a select only paints the cards again).
142
+ export function cardTemplate(s, { deletable = false, archivable = false, selectable = false, picked = false, skipReason = null, family = null, open = false, rows = false, matchIds = null, entering = false, anim = "", renamable = false } = {}, { title = "", projectName = null, activeId = null, chainOpen = false, parent = null } = {}) {
138
143
  const fam = family && family.members.length ? family : null;
139
144
  const ts = s.updated_at || s.created_at || "";
140
145
  const tip = `${s.id} · updated ${ts} · ${s.status}`;
@@ -164,8 +169,11 @@ export function cardTemplate(s, { deletable = false, archivable = false, selecta
164
169
  const memberIds = [...(fam ? fam.members.map((m) => m.session.id) : []),
165
170
  ...(family?.chain ? family.chain.links.map((x) => x.id).filter((id) => id !== s.id) : [])];
166
171
  const cls = `card${s.archived ? " archived" : ""}${waiting || familyWaiting ? " waiting" : ""}${selectable && picked ? " picked" : ""}${fam && rows && open ? " family-open" : ""}${entering ? " entering" : ""}`;
172
+ const preview = renamable
173
+ ? html`<div class="preview-row"><div class="preview">${title}</div><button type="button" class="card-rename" data-rename=${s.id} title="Rename this session" aria-label="Rename this session">\u270e</button></div>`
174
+ : html`<div class="preview">${title}</div>`;
167
175
  const recap = s.recap ? html`<div class="recap" title=${s.recap}>${s.recap}</div>` : nothing;
168
- return html`<div class=${cls} data-id=${s.id} data-members=${memberIds.length ? memberIds.join(" ") : nothing} style=${anim || nothing} title=${`${tip}${memTitle ? ` · ${memTitle}` : ""}`}><div class="preview">${title}</div>${recap}<div class="meta"><span class=${`status dot ${s.status}${waiting ? " waiting" : ""}`} title=${dotWord} aria-label=${dotWord}></span>${waitingT}${stoppedT}${time}${ctxT}${folderT}${parentChipTemplate(s, parent)}${chain}${kids}${ownerBadgeTemplate(s)}${archived}</div>${famRows}${skip}${pick}${selectable ? nothing : cardToolsTemplate(s, { deletable, archivable })}</div>`;
176
+ return html`<div class=${cls} data-id=${s.id} data-members=${memberIds.length ? memberIds.join(" ") : nothing} style=${anim || nothing} title=${`${tip}${memTitle ? ` · ${memTitle}` : ""}`}>${preview}${recap}<div class="meta"><span class=${`status dot ${s.status}${waiting ? " waiting" : ""}`} title=${dotWord} aria-label=${dotWord}></span>${waitingT}${stoppedT}${time}${ctxT}${folderT}${parentChipTemplate(s, parent)}${chain}${kids}${ownerBadgeTemplate(s)}${archived}</div>${famRows}${skip}${pick}${selectable ? nothing : cardToolsTemplate(s, { deletable, archivable })}</div>`;
169
177
  }
170
178
 
171
179
  function cardToolsTemplate(s, { deletable, archivable }) {
@@ -223,7 +231,7 @@ export function createListsView(deps) {
223
231
  const {
224
232
  doc, win, topStripEl, allListEl, sessions, selected, scope, scopeError, eventsLive, refresh, select,
225
233
  confirmDelete, setArchived, archiveOne, updateInfoBar, updateSessionTitle, showToast,
226
- allViewOpen, openAllView, leaveAllView,
234
+ allViewOpen, openAllView, leaveAllView, renameShown = () => false, onRename = () => {}, renaming = () => false,
227
235
  } = deps;
228
236
  const $ = (s) => doc.querySelector(s);
229
237
 
@@ -261,9 +269,11 @@ export function createListsView(deps) {
261
269
  return chainTitle(s, sessionsById());
262
270
  }
263
271
 
272
+ // A card with no title yet ("—": no first message) has no ✎ either.
264
273
  function cardFor(s, opts) {
265
- return cardTemplate(s, opts, {
266
- title: cardTitle(s),
274
+ const title = cardTitle(s);
275
+ return cardTemplate(s, { ...opts, renamable: !!opts.renamable && title !== "\u2014" }, {
276
+ title,
267
277
  projectName: scope.projectName,
268
278
  activeId: selected(),
269
279
  chainOpen: !!opts.family?.chain && chainOpenHead() === s.id,
@@ -294,9 +304,10 @@ export function createListsView(deps) {
294
304
  const canHover = win.matchMedia?.("(hover: hover)").matches ?? false;
295
305
  function listOrder(el, next, idOf = (s) => s.id) {
296
306
  // The strip's popover holds it too (the pointer leaves the strip for it,
297
- // and a touch screen has no hover at all).
307
+ // and a touch screen has no hover at all), and so does a rename typed
308
+ // over its active card (the card doesn't jump away under the input).
298
309
  const holding = el.getClientRects().length > 0
299
- && ((canHover && el.matches(":hover")) || (el === topStripEl && familyPop.isOpen()) || chainPopIn(el));
310
+ && ((canHover && el.matches(":hover")) || (el === topStripEl && (familyPop.isOpen() || renaming())) || chainPopIn(el));
300
311
  const held = holding ? drawnOrder.get(el) : null;
301
312
  const ordered = held ? heldOrder(held, next, idOf) : next;
302
313
  if (held && ordered.some((x, i) => idOf(x) !== idOf(next[i]))) heldLists.add(el);
@@ -429,6 +440,12 @@ export function createListsView(deps) {
429
440
  onFamily(el.dataset.id, chip, e);
430
441
  return;
431
442
  }
443
+ const ren = e.target.closest(".card-rename");
444
+ if (ren) {
445
+ e.stopPropagation();
446
+ onRename();
447
+ return;
448
+ }
432
449
  const del = e.target.closest(".card-del");
433
450
  if (del) {
434
451
  e.stopPropagation();
@@ -497,7 +514,7 @@ export function createListsView(deps) {
497
514
  }
498
515
  const anim = enteringFor(topStripEl, [...cards.map(familyId), STRIP_TILE]);
499
516
  const tile = stripTileTemplate(stripCountText(shownSessions().length, all.length), anim(STRIP_TILE));
500
- draw(topStripEl, html`${cards.map((f) => cardFor(f.head, { family: f, open: familyPop.openHead() === f.head.id, ...anim(familyId(f)) }))}${tile}`);
517
+ draw(topStripEl, html`${cards.map((f) => cardFor(f.head, { family: f, open: familyPop.openHead() === f.head.id, renamable: renameShown(f.head.owner), ...anim(familyId(f)) }))}${tile}`);
501
518
  bindCards(topStripEl, select, {
502
519
  onFamily: (id, _chip, e) => {
503
520
  familyPop.toggle(id);
@@ -769,6 +786,13 @@ export function createListsView(deps) {
769
786
  return {
770
787
  render,
771
788
  renderAll,
789
+ // The rename input closed: a strip held for it catches up (unless the
790
+ // pointer or a popover holds it still).
791
+ catchUp() {
792
+ if (heldLists.has(topStripEl) && !(canHover && topStripEl.matches(":hover")) && !familyPop.isOpen() && !chainPopIn(topStripEl)) {
793
+ renderTop(stripCards());
794
+ }
795
+ },
772
796
  paintActive,
773
797
  cardTitle,
774
798
  sessionsById,