kward 0.69.1 → 0.71.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/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +68 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +90 -2
- data/README.md +30 -6
- data/Rakefile +96 -0
- data/doc/agent-tools.md +43 -0
- data/doc/api.md +92 -0
- data/doc/authentication.md +39 -25
- data/doc/configuration.md +2 -16
- data/doc/context-tools.md +70 -0
- data/doc/getting-started.md +3 -1
- data/doc/plugins.md +2 -2
- data/doc/releasing.md +14 -5
- data/doc/rpc.md +3 -11
- data/doc/session-management.md +220 -0
- data/doc/usage.md +13 -7
- data/doc/workspace-tools.md +105 -0
- data/lib/kward/cli/commands.rb +8 -0
- data/lib/kward/cli/openrouter_commands.rb +55 -0
- data/lib/kward/cli/prompt_interface.rb +85 -7
- data/lib/kward/cli/rendering.rb +11 -6
- data/lib/kward/cli/sessions.rb +454 -15
- data/lib/kward/cli/settings.rb +0 -30
- data/lib/kward/cli/slash_commands.rb +38 -11
- data/lib/kward/cli.rb +14 -0
- data/lib/kward/compactor.rb +4 -1
- data/lib/kward/config_files.rb +4 -6
- data/lib/kward/conversation.rb +49 -5
- data/lib/kward/model/client.rb +37 -50
- data/lib/kward/model/context_usage.rb +13 -6
- data/lib/kward/model/model_info.rb +92 -9
- data/lib/kward/model/payloads.rb +2 -0
- data/lib/kward/openrouter_model_cache.rb +120 -0
- data/lib/kward/plugin_registry.rb +47 -1
- data/lib/kward/prompt_interface/banner.rb +16 -51
- data/lib/kward/prompt_interface/composer_controller.rb +60 -87
- data/lib/kward/prompt_interface/composer_renderer.rb +7 -1
- data/lib/kward/prompt_interface/key_handler.rb +31 -10
- data/lib/kward/prompt_interface/layout.rb +2 -2
- data/lib/kward/prompt_interface/overlay_renderer.rb +24 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +23 -2
- data/lib/kward/prompt_interface/question_prompt.rb +34 -42
- data/lib/kward/prompt_interface/runtime_state.rb +6 -1
- data/lib/kward/prompt_interface/screen.rb +10 -4
- data/lib/kward/prompt_interface/selection_prompt.rb +518 -61
- data/lib/kward/prompt_interface/slash_overlay.rb +4 -4
- data/lib/kward/prompt_interface/transcript_buffer.rb +7 -16
- data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
- data/lib/kward/prompt_interface.rb +31 -32
- data/lib/kward/prompts/commands.rb +6 -3
- data/lib/kward/prompts.rb +2 -2
- data/lib/kward/rpc/server.rb +3 -8
- data/lib/kward/rpc/session_manager.rb +19 -8
- data/lib/kward/session_diff.rb +106 -9
- data/lib/kward/session_store.rb +23 -4
- data/lib/kward/session_tree_renderer.rb +2 -1
- data/lib/kward/telemetry/logger.rb +5 -3
- data/lib/kward/tool_output_compactor.rb +127 -0
- data/lib/kward/tools/base.rb +8 -2
- data/lib/kward/tools/registry.rb +37 -6
- data/lib/kward/tools/retrieve_tool_output.rb +71 -0
- data/lib/kward/tools/search/web.rb +2 -2
- data/lib/kward/tools/summarize_file_structure.rb +29 -0
- data/lib/kward/tools/tool_call.rb +2 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +58 -2
- data/templates/default/fulldoc/html/css/kward.css +570 -78
- data/templates/default/fulldoc/html/full_list.erb +107 -0
- data/templates/default/fulldoc/html/js/kward.js +259 -97
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/kward_navigation.rb +91 -0
- data/templates/default/layout/html/layout.erb +59 -13
- data/templates/default/layout/html/setup.rb +34 -39
- metadata +13 -3
- data/lib/kward/resources/avatar_kward_logo.rb +0 -50
- data/lib/kward/resources/pixel_logo.rb +0 -232
data/lib/kward/cli/sessions.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
1
3
|
# Namespace for the Kward CLI agent runtime.
|
|
2
4
|
module Kward
|
|
3
5
|
# Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
|
|
@@ -106,6 +108,13 @@ module Kward
|
|
|
106
108
|
path = select_session_path(session_store) if path.empty?
|
|
107
109
|
return nil if path.to_s.empty?
|
|
108
110
|
|
|
111
|
+
load_session(session_store, path, message: "Resumed session")
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
runtime_output("Error: #{e.message}")
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def load_session(session_store, path, message: nil)
|
|
109
118
|
previous_session = @active_session
|
|
110
119
|
@active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
111
120
|
reset_session_diff(@active_session.path)
|
|
@@ -113,15 +122,12 @@ module Kward
|
|
|
113
122
|
cleanup_replaced_session(previous_session)
|
|
114
123
|
update_assistant_prompt(conversation)
|
|
115
124
|
restore_prompt_transcript do
|
|
116
|
-
runtime_output("
|
|
125
|
+
runtime_output("#{message}: #{@active_session.path}") if message
|
|
117
126
|
render_conversation_transcript(conversation)
|
|
118
127
|
end
|
|
119
128
|
agent = build_interactive_agent(conversation)
|
|
120
129
|
@prompt.redraw if @prompt.respond_to?(:redraw) && !@prompt.respond_to?(:restore_transcript)
|
|
121
130
|
agent
|
|
122
|
-
rescue StandardError => e
|
|
123
|
-
runtime_output("Error: #{e.message}")
|
|
124
|
-
nil
|
|
125
131
|
end
|
|
126
132
|
|
|
127
133
|
def navigate_session_tree(session_store)
|
|
@@ -131,7 +137,7 @@ module Kward
|
|
|
131
137
|
return nil
|
|
132
138
|
end
|
|
133
139
|
|
|
134
|
-
tree_items = session_tree_items(session_store)
|
|
140
|
+
tree_items = run_busy_local_command_and_requeue { session_tree_items(session_store) }
|
|
135
141
|
if tree_items.empty?
|
|
136
142
|
runtime_output("No session tree entries found.")
|
|
137
143
|
return nil
|
|
@@ -147,8 +153,12 @@ module Kward
|
|
|
147
153
|
entry = tree_items.find { |item| item[:entry]["id"].to_s == entry_id }&.fetch(:entry)
|
|
148
154
|
return nil unless entry
|
|
149
155
|
|
|
150
|
-
selected_text =
|
|
151
|
-
|
|
156
|
+
selected_text = nil
|
|
157
|
+
agent = run_busy_local_command_and_requeue do
|
|
158
|
+
selected_text = apply_session_tree_entry(entry)
|
|
159
|
+
runtime_output("Moved session tree position to #{entry["id"]}.")
|
|
160
|
+
reload_active_session(session_store)
|
|
161
|
+
end
|
|
152
162
|
if selected_text && !selected_text.empty?
|
|
153
163
|
if @prompt.respond_to?(:prefill_input)
|
|
154
164
|
@prompt.prefill_input(selected_text)
|
|
@@ -156,8 +166,7 @@ module Kward
|
|
|
156
166
|
runtime_output("Selected text for editing:\n#{selected_text}")
|
|
157
167
|
end
|
|
158
168
|
end
|
|
159
|
-
|
|
160
|
-
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
169
|
+
@prompt.redraw if @prompt.respond_to?(:redraw) && !@prompt.respond_to?(:restore_transcript)
|
|
161
170
|
agent
|
|
162
171
|
rescue StandardError => e
|
|
163
172
|
runtime_output("Session tree error: #{e.message}")
|
|
@@ -175,11 +184,193 @@ module Kward
|
|
|
175
184
|
answer.match?(/\A\d+\z/) ? labels[answer.to_i - 1] : nil
|
|
176
185
|
end
|
|
177
186
|
|
|
187
|
+
def rewind_session(session_store)
|
|
188
|
+
return say_sessions_unavailable unless session_store
|
|
189
|
+
unless @active_session
|
|
190
|
+
runtime_output("No active persisted session.")
|
|
191
|
+
return nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
points = rewind_points(session_store)
|
|
195
|
+
if points.empty?
|
|
196
|
+
runtime_output("No prompts to rewind to.")
|
|
197
|
+
return nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
labels = points.map { |point| point[:label] }
|
|
201
|
+
choice = select_rewind_point(labels)
|
|
202
|
+
return nil unless choice
|
|
203
|
+
|
|
204
|
+
point = points[labels.index(choice)]
|
|
205
|
+
return nil unless point
|
|
206
|
+
|
|
207
|
+
if point[:return_leaf_id]
|
|
208
|
+
@active_session.branch(point[:return_leaf_id])
|
|
209
|
+
@rewind_return_leaf_id = nil
|
|
210
|
+
else
|
|
211
|
+
@rewind_return_leaf_id = @active_session.leaf_id || session_store.current_leaf(@active_session.path)
|
|
212
|
+
selected_text = apply_session_tree_entry(point[:entry])
|
|
213
|
+
if selected_text && !selected_text.empty?
|
|
214
|
+
if @prompt.respond_to?(:prefill_input)
|
|
215
|
+
@prompt.prefill_input(selected_text)
|
|
216
|
+
else
|
|
217
|
+
runtime_output("Selected prompt for editing:\n#{selected_text}")
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
agent = reload_active_session(session_store)
|
|
222
|
+
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
223
|
+
agent
|
|
224
|
+
rescue StandardError => e
|
|
225
|
+
runtime_output("Rewind error: #{e.message}")
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def select_rewind_point(labels)
|
|
230
|
+
if @prompt.respond_to?(:select)
|
|
231
|
+
return @prompt.select("Rewind>", labels, title: "Rewind")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
numbered_labels = labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }
|
|
235
|
+
runtime_output((["Rewind to:"] + numbered_labels).join("\n"))
|
|
236
|
+
answer = @prompt.ask("Rewind point number>").to_s.strip
|
|
237
|
+
answer.match?(/\A\d+\z/) ? labels[answer.to_i - 1] : nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def rewind_points(session_store)
|
|
241
|
+
entries = session_store.session_entries(@active_session.path)
|
|
242
|
+
current_leaf_id = @active_session.leaf_id || session_store.current_leaf(@active_session.path)
|
|
243
|
+
active_path = active_session_tree_entry_ids(entries, current_leaf_id)
|
|
244
|
+
user_entries = entries.select { |entry| rewind_entry?(entry) }
|
|
245
|
+
points = user_entries.reverse_each.with_index.map do |entry, index|
|
|
246
|
+
{
|
|
247
|
+
entry: entry,
|
|
248
|
+
label: rewind_point_label(entry, index, active_path.include?(entry["id"].to_s)),
|
|
249
|
+
timestamp: entry["timestamp"]
|
|
250
|
+
}
|
|
251
|
+
end
|
|
252
|
+
return_point = rewind_return_point(entries, current_leaf_id)
|
|
253
|
+
points = [return_point] + points if return_point
|
|
254
|
+
align_rewind_point_timestamps(points, picker_choice_width)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def rewind_return_point(entries, current_leaf_id)
|
|
258
|
+
return nil if @rewind_return_leaf_id.to_s.empty?
|
|
259
|
+
return nil if @rewind_return_leaf_id.to_s == current_leaf_id.to_s
|
|
260
|
+
|
|
261
|
+
entry = entries.find { |candidate| candidate["id"].to_s == @rewind_return_leaf_id.to_s }
|
|
262
|
+
return nil unless entry
|
|
263
|
+
|
|
264
|
+
{
|
|
265
|
+
return_leaf_id: @rewind_return_leaf_id,
|
|
266
|
+
label: "Return to where I was: #{truncate_rewind_text(rewind_return_text(entry))}",
|
|
267
|
+
timestamp: entry["timestamp"]
|
|
268
|
+
}
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def align_rewind_point_timestamps(points, width)
|
|
272
|
+
labels = points.map { |point| point[:label].to_s }
|
|
273
|
+
label_width = labels.map(&:length).max.to_i
|
|
274
|
+
points.each do |point|
|
|
275
|
+
timestamp = relative_rewind_time(point[:timestamp])
|
|
276
|
+
next if timestamp.empty?
|
|
277
|
+
|
|
278
|
+
point[:label] = right_aligned_picker_metadata(point[:label], timestamp, width: width, minimum_label_width: label_width)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def right_aligned_picker_metadata(label, metadata, width:, minimum_label_width: 0)
|
|
283
|
+
label = label.to_s
|
|
284
|
+
metadata = metadata.to_s
|
|
285
|
+
fallback_width = minimum_label_width + metadata.length + 2
|
|
286
|
+
target_width = width.to_i.positive? ? width.to_i : fallback_width
|
|
287
|
+
label_width = [target_width - metadata.length - 2, 1].max
|
|
288
|
+
"#{truncate_picker_label(label, label_width).ljust(label_width)} #{metadata}"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def truncate_picker_label(label, width)
|
|
292
|
+
return "" if width <= 0
|
|
293
|
+
|
|
294
|
+
text = label.to_s
|
|
295
|
+
return text if text.length <= width
|
|
296
|
+
return text.slice(0, width) if width <= 3
|
|
297
|
+
|
|
298
|
+
"#{text.slice(0, width - 3)}..."
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def relative_rewind_time(timestamp)
|
|
302
|
+
time = timestamp.is_a?(Time) ? timestamp.utc : Time.iso8601(timestamp.to_s).utc
|
|
303
|
+
seconds = [(Time.now.utc - time).to_i, 0].max
|
|
304
|
+
case seconds
|
|
305
|
+
when 0...60
|
|
306
|
+
"just now"
|
|
307
|
+
when 60...3600
|
|
308
|
+
minutes = seconds / 60
|
|
309
|
+
"#{minutes} min ago"
|
|
310
|
+
when 3600...86_400
|
|
311
|
+
hours = seconds / 3600
|
|
312
|
+
"#{hours} h ago"
|
|
313
|
+
else
|
|
314
|
+
days = seconds / 86_400
|
|
315
|
+
"#{days} d ago"
|
|
316
|
+
end
|
|
317
|
+
rescue ArgumentError
|
|
318
|
+
""
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def rewind_return_text(entry)
|
|
322
|
+
message = entry["message"]
|
|
323
|
+
text = full_message_text(message) if message.is_a?(Hash)
|
|
324
|
+
text.to_s.empty? ? entry["id"].to_s : text
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def rewind_entry?(entry)
|
|
328
|
+
return false unless entry["type"] == "message"
|
|
329
|
+
|
|
330
|
+
message = entry["message"]
|
|
331
|
+
message.is_a?(Hash) && message_role(message) == "user" && !full_message_text(message).empty?
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def rewind_point_label(entry, index, active)
|
|
335
|
+
marker = active ? "• " : ""
|
|
336
|
+
prefix = case index
|
|
337
|
+
when 0 then "Last prompt"
|
|
338
|
+
when 1 then "2 turns ago"
|
|
339
|
+
else "#{index + 1} turns ago"
|
|
340
|
+
end
|
|
341
|
+
"#{marker}#{prefix}: #{truncate_rewind_text(full_message_text(entry["message"] || {}))}"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def active_session_tree_entry_ids(entries, leaf_id)
|
|
345
|
+
by_id = entries.to_h { |entry| [entry["id"].to_s, entry] }
|
|
346
|
+
ids = []
|
|
347
|
+
seen = {}
|
|
348
|
+
current = by_id[leaf_id.to_s]
|
|
349
|
+
while current && !seen[current["id"].to_s]
|
|
350
|
+
seen[current["id"].to_s] = true
|
|
351
|
+
ids << current["id"].to_s
|
|
352
|
+
current = by_id[current["parentId"].to_s]
|
|
353
|
+
end
|
|
354
|
+
ids
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def truncate_rewind_text(text)
|
|
358
|
+
text.to_s.gsub(/\s+/, " ").strip
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def picker_choice_width
|
|
362
|
+
if @prompt.respond_to?(:picker_choice_width)
|
|
363
|
+
@prompt.picker_choice_width
|
|
364
|
+
else
|
|
365
|
+
96
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
178
369
|
def apply_session_tree_entry(entry)
|
|
179
370
|
message = entry["message"]
|
|
180
371
|
if message.is_a?(Hash) && message_role(message) == "user"
|
|
181
372
|
target_leaf = entry["parentId"]
|
|
182
|
-
|
|
373
|
+
@active_session.branch(target_leaf) unless target_leaf.to_s.empty?
|
|
183
374
|
return full_message_text(message)
|
|
184
375
|
end
|
|
185
376
|
|
|
@@ -210,13 +401,19 @@ module Kward
|
|
|
210
401
|
SessionTreeRenderer.new(roots: roots, current_leaf_id: current_leaf_id).items
|
|
211
402
|
end
|
|
212
403
|
|
|
213
|
-
def rename_session(argument)
|
|
404
|
+
def rename_session(argument, require_name: false)
|
|
214
405
|
unless @active_session
|
|
215
406
|
runtime_output("No active persisted session.")
|
|
216
407
|
return
|
|
217
408
|
end
|
|
218
409
|
|
|
219
|
-
|
|
410
|
+
name = argument.to_s.strip
|
|
411
|
+
if require_name && name.empty?
|
|
412
|
+
runtime_output("Usage: /rename <name>")
|
|
413
|
+
return
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
@active_session.rename(name)
|
|
220
417
|
label = @active_session.name ? "Named session: #{@active_session.name}" : "Cleared session name."
|
|
221
418
|
runtime_output(label)
|
|
222
419
|
end
|
|
@@ -233,6 +430,198 @@ module Kward
|
|
|
233
430
|
agent
|
|
234
431
|
end
|
|
235
432
|
|
|
433
|
+
def fork_session(session_store)
|
|
434
|
+
return say_sessions_unavailable unless session_store
|
|
435
|
+
unless @active_session
|
|
436
|
+
runtime_output("No active persisted session.")
|
|
437
|
+
return nil
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
points = fork_points(session_store)
|
|
441
|
+
if points.empty?
|
|
442
|
+
runtime_output("No prompts to fork from.")
|
|
443
|
+
return nil
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
point = select_fork_point_from_points(points)
|
|
447
|
+
return nil unless point
|
|
448
|
+
|
|
449
|
+
run_busy_local_command_and_requeue(activity: "forking") do
|
|
450
|
+
fork_session_from_point(session_store, point)
|
|
451
|
+
end
|
|
452
|
+
rescue StandardError => e
|
|
453
|
+
runtime_output("Fork error: #{e.message}")
|
|
454
|
+
nil
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def fork_points(session_store)
|
|
458
|
+
fork_points_for_session(session_store, @active_session)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def fork_points_for_session(session_store, session)
|
|
462
|
+
entries = session_store.session_entries(session.path)
|
|
463
|
+
current_leaf_id = session.leaf_id || session_store.current_leaf(session.path)
|
|
464
|
+
active_path = active_session_tree_entry_ids(entries, current_leaf_id)
|
|
465
|
+
entries.each_with_index.filter_map do |entry, index|
|
|
466
|
+
next unless rewind_entry?(entry)
|
|
467
|
+
next unless active_path.include?(entry["id"].to_s)
|
|
468
|
+
|
|
469
|
+
{
|
|
470
|
+
entry: entry,
|
|
471
|
+
entry_index: index,
|
|
472
|
+
label: fork_point_label(entry),
|
|
473
|
+
timestamp: entry["timestamp"]
|
|
474
|
+
}
|
|
475
|
+
end.reverse.then { |points| align_rewind_point_timestamps(points, picker_choice_width) }
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def fork_point_label(entry)
|
|
479
|
+
"Fork from: #{truncate_rewind_text(full_message_text(entry["message"] || {}))}"
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def select_fork_point(labels)
|
|
483
|
+
if @prompt.respond_to?(:select)
|
|
484
|
+
return @prompt.select("Fork>", labels, title: "Fork")
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
numbered_labels = labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }
|
|
488
|
+
runtime_output((["Fork from:"] + numbered_labels).join("\n"))
|
|
489
|
+
answer = @prompt.ask("Fork point number>").to_s.strip
|
|
490
|
+
answer.match?(/\A\d+\z/) ? labels[answer.to_i - 1] : nil
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def fork_session_from_point(session_store, point)
|
|
494
|
+
previous_session = @active_session
|
|
495
|
+
forked_session, conversation, selected_text = create_fork_from_point(session_store, previous_session, point)
|
|
496
|
+
@active_session = track_session(forked_session)
|
|
497
|
+
reset_session_diff(@active_session.path)
|
|
498
|
+
cleanup_replaced_session(previous_session)
|
|
499
|
+
update_assistant_prompt(conversation)
|
|
500
|
+
restore_prompt_transcript do
|
|
501
|
+
runtime_output("Forked session: #{@active_session.path}")
|
|
502
|
+
render_conversation_transcript(conversation)
|
|
503
|
+
end
|
|
504
|
+
prefill_selected_fork_text(selected_text)
|
|
505
|
+
agent = build_interactive_agent(conversation)
|
|
506
|
+
@prompt.redraw if @prompt.respond_to?(:redraw) && !@prompt.respond_to?(:restore_transcript)
|
|
507
|
+
agent
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def create_fork_from_point(session_store, source_session, point)
|
|
511
|
+
entries = session_store.session_entries(source_session.path)
|
|
512
|
+
messages = entries[0...point[:entry_index]].filter_map { |entry| entry["message"] }
|
|
513
|
+
forked_session, conversation = session_store.create_independent_from_messages(
|
|
514
|
+
messages,
|
|
515
|
+
provider: current_model_provider,
|
|
516
|
+
model: current_model_id,
|
|
517
|
+
reasoning_effort: current_reasoning_effort,
|
|
518
|
+
parent_session: source_session
|
|
519
|
+
)
|
|
520
|
+
[forked_session, conversation, full_message_text(point[:entry]["message"] || {})]
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def prefill_selected_fork_text(selected_text)
|
|
524
|
+
return if selected_text.to_s.empty?
|
|
525
|
+
|
|
526
|
+
if @prompt.respond_to?(:prefill_input)
|
|
527
|
+
@prompt.prefill_input(selected_text)
|
|
528
|
+
else
|
|
529
|
+
runtime_output("Selected prompt for editing:\n#{selected_text}")
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def clone_session_from_path(session_store, path)
|
|
534
|
+
clone_path = clone_session_file_from_path(session_store, path)
|
|
535
|
+
load_session(session_store, clone_path, message: "Cloned session")
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def fork_session_from_picker(session_store, source_path)
|
|
539
|
+
source_session, = session_store.load(source_path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
540
|
+
point = select_fork_point_for_session(session_store, source_session)
|
|
541
|
+
return nil unless point
|
|
542
|
+
|
|
543
|
+
forked_session, = create_fork_from_point(session_store, source_session, point)
|
|
544
|
+
forked_session.path
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def select_fork_point_for_session(session_store, session)
|
|
548
|
+
points = fork_points_for_session(session_store, session)
|
|
549
|
+
if points.empty?
|
|
550
|
+
runtime_output("No prompts to fork from.")
|
|
551
|
+
return nil
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
select_fork_point_from_points(points)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def select_fork_point_from_points(points)
|
|
558
|
+
labels = points.map { |point| point[:label] }
|
|
559
|
+
choice = select_fork_point(labels)
|
|
560
|
+
return nil unless choice
|
|
561
|
+
|
|
562
|
+
points[labels.index(choice)]
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def clone_session_file_from_path(session_store, path)
|
|
566
|
+
source_session, source_conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
567
|
+
clone, = session_store.create_independent_from_conversation(source_conversation, parent_session: source_session)
|
|
568
|
+
clone.path
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def clone_session_selection(session_store, sessions, labels, label)
|
|
572
|
+
copy_session_selection(session_store, sessions, labels, label) do |source|
|
|
573
|
+
clone_session_file_from_path(session_store, source.path)
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def copy_session_selection(session_store, sessions, labels, label)
|
|
578
|
+
source = sessions[labels.index(label)]
|
|
579
|
+
return nil unless source
|
|
580
|
+
|
|
581
|
+
copy_path = yield source
|
|
582
|
+
insert_session_copy(session_store, sessions, labels, source, copy_path)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def insert_session_copy(session_store, sessions, labels, source, copy_path)
|
|
586
|
+
copy_info = session_store.recent_tree(limit: nil).find { |session| File.expand_path(session.path) == File.expand_path(copy_path) }
|
|
587
|
+
copy_info ||= session_store.recent(limit: nil).find { |session| File.expand_path(session.path) == File.expand_path(copy_path) }
|
|
588
|
+
return nil unless copy_info
|
|
589
|
+
|
|
590
|
+
source_index = sessions.index(source) || 0
|
|
591
|
+
copy_index = source_index + 1
|
|
592
|
+
sessions.insert(copy_index, copy_info)
|
|
593
|
+
labels.replace(session_picker_labels(sessions))
|
|
594
|
+
continue_session_selection(labels, copy_index)
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def delete_session_selection(_session_store, sessions, labels, label)
|
|
598
|
+
source = sessions[labels.index(label)]
|
|
599
|
+
return nil unless source
|
|
600
|
+
|
|
601
|
+
SessionTrash.new.delete(source.path)
|
|
602
|
+
index = sessions.index(source) || labels.index(label) || 0
|
|
603
|
+
sessions.delete_at(index)
|
|
604
|
+
labels.replace(session_picker_labels(sessions))
|
|
605
|
+
next_index = [index, labels.length - 1].min
|
|
606
|
+
continue_session_selection(labels, next_index)
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def rename_session_selection(session_store, sessions, labels, label, name)
|
|
610
|
+
source = sessions[labels.index(label)]
|
|
611
|
+
return nil unless source
|
|
612
|
+
|
|
613
|
+
session_store.load(source.path).first.rename(name)
|
|
614
|
+
updated = session_store.recent_tree(limit: nil)
|
|
615
|
+
sessions.replace(updated)
|
|
616
|
+
labels.replace(session_picker_labels(sessions))
|
|
617
|
+
index = sessions.index { |session| File.expand_path(session.path) == File.expand_path(source.path) } || 0
|
|
618
|
+
continue_session_selection(labels, index)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def continue_session_selection(labels, selection_index)
|
|
622
|
+
{ select_continue: true, choices: labels, selection_index: selection_index }
|
|
623
|
+
end
|
|
624
|
+
|
|
236
625
|
def copy_session_text(conversation, argument)
|
|
237
626
|
target = copy_target(argument)
|
|
238
627
|
unless target
|
|
@@ -314,16 +703,46 @@ module Kward
|
|
|
314
703
|
end
|
|
315
704
|
|
|
316
705
|
def select_session_path(session_store)
|
|
317
|
-
|
|
706
|
+
select_session_path_from_sessions(session_store.recent_tree(limit: nil), session_store: session_store)
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
def reopen_sessions_after_fork(session_store, source_path, source_label)
|
|
710
|
+
fork_path = run_busy_local_command_and_requeue(activity: "forking") do
|
|
711
|
+
fork_session_from_picker(session_store, source_path)
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
sessions = session_store.recent_tree(limit: nil)
|
|
715
|
+
labels = session_picker_labels(sessions)
|
|
716
|
+
initial_index = if fork_path
|
|
717
|
+
sessions.index { |session| File.expand_path(session.path) == File.expand_path(fork_path) }
|
|
718
|
+
else
|
|
719
|
+
labels.index(source_label)
|
|
720
|
+
end
|
|
721
|
+
select_session_path_from_sessions(sessions, session_store: session_store, initial_index: initial_index || 0)
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
def select_session_path_from_sessions(sessions, session_store: @session_store, initial_index: 0)
|
|
318
725
|
if sessions.empty?
|
|
319
726
|
runtime_output("No saved sessions found.")
|
|
320
727
|
return nil
|
|
321
728
|
end
|
|
322
729
|
|
|
323
|
-
labels = sessions
|
|
730
|
+
labels = session_picker_labels(sessions)
|
|
324
731
|
if @prompt.respond_to?(:select)
|
|
325
|
-
choice = @prompt.select(
|
|
732
|
+
choice = @prompt.select(
|
|
733
|
+
"Session>",
|
|
734
|
+
labels,
|
|
735
|
+
initial_index: initial_index,
|
|
736
|
+
action_keys: { "c" => { action: :clone, activity: "cloning" }, "f" => { action: :fork, defer_finish_render: true }, "r" => { action: :rename, input_prompt: "Name>" }, "d" => { action: :delete, confirm: "Press d again to delete, Esc to cancel.", confirm_title: "Delete session?" } },
|
|
737
|
+
action_handlers: {
|
|
738
|
+
clone: ->(label) { clone_session_selection(session_store, sessions, labels, label) },
|
|
739
|
+
delete: ->(label) { delete_session_selection(session_store, sessions, labels, label) },
|
|
740
|
+
rename: ->(label, name) { rename_session_selection(session_store, sessions, labels, label, name) }
|
|
741
|
+
}
|
|
742
|
+
)
|
|
326
743
|
return nil unless choice
|
|
744
|
+
return choice if choice.respond_to?(:conversation)
|
|
745
|
+
return choice[:path] ? choice : session_selection_action(choice, sessions, labels, defer_finish_render: choice[:defer_finish_render]) if choice.is_a?(Hash)
|
|
327
746
|
|
|
328
747
|
selected = sessions[labels.index(choice)]
|
|
329
748
|
return selected&.path
|
|
@@ -339,6 +758,26 @@ module Kward
|
|
|
339
758
|
end
|
|
340
759
|
end
|
|
341
760
|
|
|
761
|
+
def session_selection_action(choice, sessions, labels, defer_finish_render: false)
|
|
762
|
+
selected = sessions[labels.index(choice[:choice])]
|
|
763
|
+
return nil unless selected
|
|
764
|
+
|
|
765
|
+
{ action: choice[:action], path: selected.path, choice_label: choice[:choice] }.tap do |action|
|
|
766
|
+
action[:defer_finish_render] = true if defer_finish_render
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def session_picker_labels(sessions)
|
|
771
|
+
labels = sessions.map { |session| session_label(session) }
|
|
772
|
+
label_width = labels.map(&:length).max.to_i
|
|
773
|
+
sessions.zip(labels).map do |session, label|
|
|
774
|
+
timestamp = relative_rewind_time(session.modified_at)
|
|
775
|
+
next label if timestamp.empty?
|
|
776
|
+
|
|
777
|
+
right_aligned_picker_metadata(label, timestamp, width: picker_choice_width, minimum_label_width: label_width)
|
|
778
|
+
end
|
|
779
|
+
end
|
|
780
|
+
|
|
342
781
|
def session_label(session)
|
|
343
782
|
title = session.name.to_s.strip
|
|
344
783
|
title = session.first_message.to_s.strip if title.empty?
|
data/lib/kward/cli/settings.rb
CHANGED
|
@@ -222,9 +222,6 @@ module Kward
|
|
|
222
222
|
when /\Ashow busy help/, /\Ahide busy help/
|
|
223
223
|
set_composer_busy_help(!composer_busy_help?)
|
|
224
224
|
runtime_output("Busy help #{composer_busy_help? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.")
|
|
225
|
-
when /\Ashow startup banner/, /\Ahide startup banner/
|
|
226
|
-
set_banner_enabled(!banner_enabled?)
|
|
227
|
-
runtime_output("Startup banner #{banner_enabled? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.")
|
|
228
225
|
when /\Aenable session auto-resume/, /\Adisable session auto-resume/
|
|
229
226
|
set_session_auto_resume_enabled(!session_auto_resume_enabled?)
|
|
230
227
|
runtime_output("Session auto-resume #{session_auto_resume_enabled? ? "enabled" : "disabled"}.")
|
|
@@ -237,7 +234,6 @@ module Kward
|
|
|
237
234
|
"Overlay alignment (#{settings["alignment"]})",
|
|
238
235
|
"Overlay width (#{settings["width"]})",
|
|
239
236
|
"#{composer_busy_help? ? "Hide" : "Show"} busy help (currently #{on_off(composer_busy_help?)})",
|
|
240
|
-
"#{banner_enabled? ? "Hide" : "Show"} startup banner (currently #{on_off(banner_enabled?)})",
|
|
241
237
|
"#{session_auto_resume_enabled? ? "Disable" : "Enable"} session auto-resume (currently #{on_off(session_auto_resume_enabled?)})",
|
|
242
238
|
"Back"
|
|
243
239
|
]
|
|
@@ -247,10 +243,6 @@ module Kward
|
|
|
247
243
|
ConfigFiles.composer_busy_help?(safely_read_config.to_h)
|
|
248
244
|
end
|
|
249
245
|
|
|
250
|
-
def banner_enabled?
|
|
251
|
-
ConfigFiles.banner_enabled?(safely_read_config.to_h)
|
|
252
|
-
end
|
|
253
|
-
|
|
254
246
|
def session_auto_resume_enabled?
|
|
255
247
|
ConfigFiles.session_auto_resume_enabled?(safely_read_config.to_h)
|
|
256
248
|
end
|
|
@@ -259,10 +251,6 @@ module Kward
|
|
|
259
251
|
update_nested_config("composer", "busy_help" => enabled)
|
|
260
252
|
end
|
|
261
253
|
|
|
262
|
-
def set_banner_enabled(enabled)
|
|
263
|
-
update_nested_config("banner", "enabled" => enabled)
|
|
264
|
-
end
|
|
265
|
-
|
|
266
254
|
def set_session_auto_resume_enabled(enabled)
|
|
267
255
|
update_nested_config("sessions", "auto_resume" => enabled)
|
|
268
256
|
end
|
|
@@ -518,24 +506,6 @@ module Kward
|
|
|
518
506
|
runtime_output("Model error: #{e.message}")
|
|
519
507
|
end
|
|
520
508
|
|
|
521
|
-
# Writes the openrouter catalog output for the terminal CLI flow.
|
|
522
|
-
def print_openrouter_catalog
|
|
523
|
-
unless @client.respond_to?(:openrouter_catalog)
|
|
524
|
-
runtime_output("OpenRouter catalog is unavailable for this client.")
|
|
525
|
-
return
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
models = Array(@client.openrouter_catalog)
|
|
529
|
-
if models.empty?
|
|
530
|
-
runtime_output("No OpenRouter catalog models available.")
|
|
531
|
-
else
|
|
532
|
-
ids = models.map { |model| model[:id] || model["id"] || model }.map(&:to_s).reject(&:empty?)
|
|
533
|
-
runtime_output((["OpenRouter catalog:"] + ids).join("\n"))
|
|
534
|
-
end
|
|
535
|
-
rescue StandardError => e
|
|
536
|
-
runtime_output("OpenRouter catalog error: #{e.message}")
|
|
537
|
-
end
|
|
538
|
-
|
|
539
509
|
def configure_reasoning(conversation = nil)
|
|
540
510
|
unless model_overlay_available?
|
|
541
511
|
runtime_output("Reasoning overlay is unavailable in this prompt.")
|
|
@@ -32,9 +32,6 @@ module Kward
|
|
|
32
32
|
models = run_busy_local_command_and_requeue { normalized_available_models }
|
|
33
33
|
configure_model(agent.conversation, models: models)
|
|
34
34
|
[true, nil]
|
|
35
|
-
when "openrouter/catalog"
|
|
36
|
-
run_busy_local_command_and_requeue { print_openrouter_catalog }
|
|
37
|
-
[true, nil]
|
|
38
35
|
when "reasoning"
|
|
39
36
|
configure_reasoning(agent.conversation)
|
|
40
37
|
[true, nil]
|
|
@@ -43,19 +40,49 @@ module Kward
|
|
|
43
40
|
[true, nil]
|
|
44
41
|
when "new"
|
|
45
42
|
[true, run_busy_local_command_and_requeue { start_new_session(session_store) }]
|
|
46
|
-
when "resume"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
43
|
+
when "sessions", "resume"
|
|
44
|
+
unless session_store
|
|
45
|
+
say_sessions_unavailable
|
|
46
|
+
return [true, nil]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
path = argument.to_s.strip
|
|
50
|
+
if path.empty?
|
|
51
|
+
sessions = run_busy_local_command_and_requeue { session_store.recent_tree(limit: nil) }
|
|
52
|
+
path = select_session_path_from_sessions(sessions, session_store: session_store)
|
|
53
|
+
end
|
|
54
|
+
replacement_agent = nil
|
|
55
|
+
selection = path
|
|
56
|
+
loop do
|
|
57
|
+
replacement_agent = if selection.respond_to?(:conversation)
|
|
58
|
+
selection
|
|
59
|
+
elsif selection.is_a?(Hash) && selection[:action] == :clone
|
|
60
|
+
run_busy_local_command_and_requeue(activity: "cloning") { clone_session_from_path(session_store, selection[:path]) }
|
|
61
|
+
elsif selection.is_a?(Hash) && selection[:action] == :fork
|
|
62
|
+
selection = reopen_sessions_after_fork(session_store, selection[:path], selection[:choice_label])
|
|
63
|
+
next
|
|
64
|
+
elsif selection.to_s.empty?
|
|
65
|
+
nil
|
|
66
|
+
else
|
|
67
|
+
run_busy_local_command_and_requeue { resume_session(session_store, selection) }
|
|
68
|
+
end
|
|
69
|
+
break
|
|
70
|
+
end
|
|
71
|
+
[true, replacement_agent]
|
|
52
72
|
when "name"
|
|
53
|
-
|
|
73
|
+
rename_session(argument)
|
|
74
|
+
[true, nil]
|
|
75
|
+
when "rename"
|
|
76
|
+
rename_session(argument, require_name: true)
|
|
54
77
|
[true, nil]
|
|
55
78
|
when "clone"
|
|
56
79
|
[true, run_busy_local_command_and_requeue { clone_session(session_store, agent) }]
|
|
80
|
+
when "fork"
|
|
81
|
+
[true, fork_session(session_store)]
|
|
82
|
+
when "rewind"
|
|
83
|
+
[true, run_busy_local_command_and_requeue { rewind_session(session_store) }]
|
|
57
84
|
when "tree"
|
|
58
|
-
[true,
|
|
85
|
+
[true, navigate_session_tree(session_store)]
|
|
59
86
|
when "copy"
|
|
60
87
|
run_busy_local_command_and_requeue { copy_session_text(agent.conversation, argument) }
|
|
61
88
|
[true, nil]
|