kward 0.67.0 → 0.68.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 +26 -0
- data/Gemfile.lock +2 -2
- data/README.md +5 -5
- data/doc/authentication.md +24 -1
- data/doc/configuration.md +9 -2
- data/doc/extensibility.md +1 -1
- data/doc/getting-started.md +4 -6
- data/doc/plugins.md +0 -2
- data/doc/releasing.md +7 -8
- data/doc/rpc.md +6 -6
- data/doc/usage.md +5 -2
- data/doc/web-search.md +2 -2
- data/kward.gemspec +4 -0
- data/lib/kward/agent.rb +29 -2
- data/lib/kward/ansi.rb +3 -0
- data/lib/kward/auth/anthropic_oauth.rb +291 -0
- data/lib/kward/auth/file.rb +2 -0
- data/lib/kward/auth/github_oauth.rb +3 -0
- data/lib/kward/auth/openai_oauth.rb +4 -0
- data/lib/kward/auth/openrouter_api_key.rb +2 -0
- data/lib/kward/cancellation.rb +3 -0
- data/lib/kward/cli/auth_commands.rb +82 -0
- data/lib/kward/cli/commands.rb +222 -0
- data/lib/kward/cli/compaction.rb +25 -0
- data/lib/kward/cli/doctor.rb +121 -0
- data/lib/kward/cli/interactive_turn.rb +225 -0
- data/lib/kward/cli/memory_commands.rb +133 -0
- data/lib/kward/cli/plugins.rb +112 -0
- data/lib/kward/cli/prompt_interface.rb +132 -0
- data/lib/kward/cli/rendering.rb +389 -0
- data/lib/kward/cli/runtime_helpers.rb +159 -0
- data/lib/kward/cli/sessions.rb +376 -0
- data/lib/kward/cli/settings.rb +663 -0
- data/lib/kward/cli/slash_commands.rb +112 -0
- data/lib/kward/cli/stats.rb +64 -0
- data/lib/kward/cli/tool_summaries.rb +153 -0
- data/lib/kward/cli.rb +38 -2790
- data/lib/kward/cli_transcript_formatter.rb +4 -7
- data/lib/kward/clipboard.rb +1 -0
- data/lib/kward/compaction/file_operation_tracker.rb +3 -0
- data/lib/kward/compactor.rb +29 -7
- data/lib/kward/config_files.rb +33 -24
- data/lib/kward/conversation.rb +70 -5
- data/lib/kward/events.rb +2 -0
- data/lib/kward/export_path.rb +2 -0
- data/lib/kward/image_attachments.rb +2 -0
- data/lib/kward/markdown_transcript.rb +2 -0
- data/lib/kward/memory/manager.rb +13 -0
- data/lib/kward/message_access.rb +23 -2
- data/lib/kward/message_text.rb +45 -0
- data/lib/kward/model/chat_invocation.rb +2 -0
- data/lib/kward/model/client.rb +295 -77
- data/lib/kward/model/context_overflow.rb +2 -0
- data/lib/kward/model/context_usage.rb +3 -0
- data/lib/kward/model/model_info.rb +143 -4
- data/lib/kward/model/payloads.rb +166 -13
- data/lib/kward/model/retry_message.rb +2 -0
- data/lib/kward/model/stream_parser.rb +129 -0
- data/lib/kward/pan/server.rb +3 -1
- data/lib/kward/plugin_registry.rb +12 -0
- data/lib/kward/private_file.rb +2 -0
- data/lib/kward/prompt_interface/banner.rb +3 -0
- data/lib/kward/prompt_interface/composer_controller.rb +262 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
- data/lib/kward/prompt_interface/composer_state.rb +221 -0
- data/lib/kward/prompt_interface/key_handler.rb +365 -0
- data/lib/kward/prompt_interface/layout.rb +31 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
- data/lib/kward/prompt_interface/question_prompt.rb +328 -0
- data/lib/kward/prompt_interface/runtime_state.rb +59 -0
- data/lib/kward/prompt_interface/screen.rb +186 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
- data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
- data/lib/kward/prompt_interface/stream_state.rb +65 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
- data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
- data/lib/kward/prompt_interface.rb +69 -1832
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts/templates.rb +3 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/question_contract.rb +66 -0
- data/lib/kward/resources/avatar_kward_logo.rb +2 -0
- data/lib/kward/resources/pixel_logo.rb +2 -0
- data/lib/kward/rpc/attachment_normalizer.rb +60 -0
- data/lib/kward/rpc/auth_manager.rb +65 -11
- data/lib/kward/rpc/config_manager.rb +11 -0
- data/lib/kward/rpc/prompt_bridge.rb +5 -26
- data/lib/kward/rpc/redactor.rb +3 -0
- data/lib/kward/rpc/runtime_payloads.rb +4 -1
- data/lib/kward/rpc/server.rb +37 -10
- data/lib/kward/rpc/session_manager.rb +123 -347
- data/lib/kward/rpc/session_metrics.rb +68 -0
- data/lib/kward/rpc/session_tree.rb +48 -0
- data/lib/kward/rpc/session_tree_rows.rb +208 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
- data/lib/kward/rpc/tool_metadata.rb +3 -0
- data/lib/kward/rpc/transcript_normalizer.rb +3 -0
- data/lib/kward/rpc/transport.rb +3 -0
- data/lib/kward/session_diff.rb +2 -0
- data/lib/kward/session_store.rb +125 -31
- data/lib/kward/session_trash.rb +1 -0
- data/lib/kward/session_tree_renderer.rb +8 -41
- data/lib/kward/session_tree_tool_display.rb +56 -0
- data/lib/kward/skills/registry.rb +3 -0
- data/lib/kward/starter_pack_installer.rb +1 -0
- data/lib/kward/steering.rb +2 -0
- data/lib/kward/telemetry/logger.rb +3 -0
- data/lib/kward/telemetry/stats.rb +3 -0
- data/lib/kward/tools/ask_user_question.rb +20 -32
- data/lib/kward/tools/base.rb +8 -0
- data/lib/kward/tools/code_search.rb +5 -0
- data/lib/kward/tools/edit_file.rb +5 -0
- data/lib/kward/tools/list_directory.rb +5 -0
- data/lib/kward/tools/read_file.rb +5 -0
- data/lib/kward/tools/read_skill.rb +5 -0
- data/lib/kward/tools/registry.rb +33 -2
- data/lib/kward/tools/run_shell_command.rb +5 -0
- data/lib/kward/tools/search/code.rb +7 -0
- data/lib/kward/tools/search/web.rb +17 -14
- data/lib/kward/tools/tool_call.rb +25 -5
- data/lib/kward/tools/web_search.rb +7 -1
- data/lib/kward/tools/write_file.rb +5 -0
- data/lib/kward/transcript_export.rb +2 -0
- data/lib/kward/version.rb +2 -1
- data/lib/kward/workspace.rb +45 -5
- metadata +43 -1
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
|
|
4
|
+
class CLI
|
|
5
|
+
# Interactive session list, resume, clone, tree, export, and copy helpers.
|
|
6
|
+
module Sessions
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def interactive_session_store(agent)
|
|
10
|
+
return @session_store if @session_store
|
|
11
|
+
return nil if agent
|
|
12
|
+
|
|
13
|
+
SessionStore.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def resume_last_session(session_store)
|
|
17
|
+
return nil unless session_auto_resume_enabled?
|
|
18
|
+
|
|
19
|
+
path = session_store.remembered_last_session_path if session_store.respond_to?(:remembered_last_session_path)
|
|
20
|
+
return nil if path.to_s.empty?
|
|
21
|
+
|
|
22
|
+
@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)
|
|
23
|
+
reset_session_diff(@active_session.path)
|
|
24
|
+
track_session(@active_session)
|
|
25
|
+
@resumed_last_session = true
|
|
26
|
+
build_interactive_agent(conversation)
|
|
27
|
+
rescue StandardError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def render_resumed_last_session_transcript(conversation)
|
|
32
|
+
restore_prompt_transcript do
|
|
33
|
+
@prompt.say("\nResumed session: #{@active_session.path}\n")
|
|
34
|
+
render_conversation_transcript(conversation)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def remember_active_session(session_store)
|
|
39
|
+
return unless session_store&.respond_to?(:remember_last_session)
|
|
40
|
+
return unless @active_session&.path && File.file?(@active_session.path)
|
|
41
|
+
|
|
42
|
+
session_store.remember_last_session(@active_session)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build_new_session_agent(session_store)
|
|
46
|
+
@active_session = track_session(session_store.create(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort))
|
|
47
|
+
reset_session_diff
|
|
48
|
+
conversation = new_conversation(workspace_root: session_store.cwd)
|
|
49
|
+
@active_session.attach(conversation)
|
|
50
|
+
build_interactive_agent(conversation)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def track_session(session)
|
|
54
|
+
@cleanup_sessions << session if session
|
|
55
|
+
session
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reset_session_diff(path = nil)
|
|
59
|
+
@session_diff = path ? SessionDiff.from_session_file(path) : SessionDiff.new
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def update_session_diff(content, tool_call: nil)
|
|
63
|
+
return unless mutation_tool_call?(tool_call)
|
|
64
|
+
return unless @session_diff&.add_tool_result(content)
|
|
65
|
+
|
|
66
|
+
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def mutation_tool_call?(tool_call)
|
|
70
|
+
["edit_file", "write_file", "edit", "write"].include?(ToolCall.name(tool_call).to_s)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def cleanup_unused_sessions
|
|
74
|
+
@cleanup_sessions.reverse_each do |session|
|
|
75
|
+
session.delete_if_unused if session.respond_to?(:delete_if_unused)
|
|
76
|
+
end
|
|
77
|
+
@cleanup_sessions.clear
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def cleanup_replaced_session(previous_session)
|
|
81
|
+
return unless previous_session
|
|
82
|
+
return if @active_session && File.expand_path(previous_session.path) == File.expand_path(@active_session.path)
|
|
83
|
+
|
|
84
|
+
previous_session.delete_if_unused if previous_session.respond_to?(:delete_if_unused)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def start_new_session(session_store)
|
|
88
|
+
return say_sessions_unavailable unless session_store
|
|
89
|
+
|
|
90
|
+
previous_session = @active_session
|
|
91
|
+
@active_session = track_session(session_store.create)
|
|
92
|
+
reset_session_diff
|
|
93
|
+
cleanup_replaced_session(previous_session)
|
|
94
|
+
conversation = new_conversation(workspace_root: session_store.cwd)
|
|
95
|
+
@active_session.attach(conversation)
|
|
96
|
+
update_assistant_prompt(conversation)
|
|
97
|
+
clear_prompt_transcript
|
|
98
|
+
print_visual_banner
|
|
99
|
+
build_interactive_agent(conversation)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def resume_session(session_store, argument)
|
|
103
|
+
return say_sessions_unavailable unless session_store
|
|
104
|
+
|
|
105
|
+
path = argument.to_s.strip
|
|
106
|
+
path = select_session_path(session_store) if path.empty?
|
|
107
|
+
return nil if path.to_s.empty?
|
|
108
|
+
|
|
109
|
+
previous_session = @active_session
|
|
110
|
+
@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
|
+
reset_session_diff(@active_session.path)
|
|
112
|
+
track_session(@active_session)
|
|
113
|
+
cleanup_replaced_session(previous_session)
|
|
114
|
+
update_assistant_prompt(conversation)
|
|
115
|
+
restore_prompt_transcript do
|
|
116
|
+
@prompt.say("\nResumed session: #{@active_session.path}\n")
|
|
117
|
+
render_conversation_transcript(conversation)
|
|
118
|
+
end
|
|
119
|
+
agent = build_interactive_agent(conversation)
|
|
120
|
+
@prompt.redraw if @prompt.respond_to?(:redraw) && !@prompt.respond_to?(:restore_transcript)
|
|
121
|
+
agent
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
@prompt.say("\nError: #{e.message}\n")
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def navigate_session_tree(session_store)
|
|
128
|
+
return say_sessions_unavailable unless session_store
|
|
129
|
+
unless @active_session
|
|
130
|
+
@prompt.say("\nNo active persisted session.\n")
|
|
131
|
+
return nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
tree_items = session_tree_items(session_store)
|
|
135
|
+
if tree_items.empty?
|
|
136
|
+
@prompt.say("\nNo session tree entries found.\n")
|
|
137
|
+
return nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
labels_by_entry_id = tree_items.to_h { |item| [item[:entry]["id"].to_s, item[:label]] }
|
|
141
|
+
current_leaf_id = @active_session.leaf_id || session_store.current_leaf(@active_session.path)
|
|
142
|
+
initial_index = tree_items.index { |item| item[:entry]["id"].to_s == current_leaf_id.to_s } || tree_items.length - 1
|
|
143
|
+
choice = select_session_tree_entry(labels_by_entry_id.values, initial_index: initial_index)
|
|
144
|
+
return nil unless choice
|
|
145
|
+
|
|
146
|
+
entry_id = labels_by_entry_id.key(choice)
|
|
147
|
+
entry = tree_items.find { |item| item[:entry]["id"].to_s == entry_id }&.fetch(:entry)
|
|
148
|
+
return nil unless entry
|
|
149
|
+
|
|
150
|
+
selected_text = apply_session_tree_entry(entry)
|
|
151
|
+
@prompt.say("\nMoved session tree position to #{entry["id"]}.\n")
|
|
152
|
+
if selected_text && !selected_text.empty?
|
|
153
|
+
if @prompt.respond_to?(:prefill_input)
|
|
154
|
+
@prompt.prefill_input(selected_text)
|
|
155
|
+
else
|
|
156
|
+
@prompt.say("\nSelected text for editing:\n#{selected_text}\n")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
agent = reload_active_session(session_store)
|
|
160
|
+
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
161
|
+
agent
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
@prompt.say("\nSession tree error: #{e.message}\n")
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def select_session_tree_entry(labels, initial_index: 0)
|
|
168
|
+
if @prompt.respond_to?(:select)
|
|
169
|
+
return @prompt.select("Tree>", labels, title: "Session Tree", initial_index: initial_index)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
numbered_labels = labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }
|
|
173
|
+
@prompt.say("\nSession tree:\n#{numbered_labels.join("\n")}\n")
|
|
174
|
+
answer = @prompt.ask("Tree entry number>").to_s.strip
|
|
175
|
+
answer.match?(/\A\d+\z/) ? labels[answer.to_i - 1] : nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def apply_session_tree_entry(entry)
|
|
179
|
+
message = entry["message"]
|
|
180
|
+
if message.is_a?(Hash) && message_role(message) == "user"
|
|
181
|
+
target_leaf = entry["parentId"]
|
|
182
|
+
target_leaf.to_s.empty? ? @active_session.reset_leaf : @active_session.branch(target_leaf)
|
|
183
|
+
return full_message_text(message)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
@active_session.branch(entry["id"])
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def reload_active_session(session_store)
|
|
191
|
+
@active_session, conversation = session_store.load(
|
|
192
|
+
@active_session.path,
|
|
193
|
+
workspace: configured_workspace(root: session_store.cwd),
|
|
194
|
+
provider: current_model_provider,
|
|
195
|
+
model: current_model_id,
|
|
196
|
+
reasoning_effort: current_reasoning_effort
|
|
197
|
+
)
|
|
198
|
+
reset_session_diff(@active_session.path)
|
|
199
|
+
track_session(@active_session)
|
|
200
|
+
update_assistant_prompt(conversation)
|
|
201
|
+
restore_prompt_transcript do
|
|
202
|
+
render_conversation_transcript(conversation)
|
|
203
|
+
end
|
|
204
|
+
build_interactive_agent(conversation)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def session_tree_items(session_store)
|
|
208
|
+
roots = session_store.session_tree(@active_session.path)
|
|
209
|
+
current_leaf_id = @active_session.leaf_id || session_store.current_leaf(@active_session.path)
|
|
210
|
+
SessionTreeRenderer.new(roots: roots, current_leaf_id: current_leaf_id).items
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def rename_session(argument)
|
|
214
|
+
unless @active_session
|
|
215
|
+
@prompt.say("\nNo active persisted session.\n")
|
|
216
|
+
return
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
@active_session.rename(argument)
|
|
220
|
+
label = @active_session.name ? "Named session: #{@active_session.name}" : "Cleared session name."
|
|
221
|
+
@prompt.say("\n#{label}\n")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def clone_session(session_store, agent)
|
|
225
|
+
return say_sessions_unavailable unless session_store
|
|
226
|
+
|
|
227
|
+
previous_session = @active_session
|
|
228
|
+
@active_session = track_session(session_store.create_from_conversation(agent.conversation, parent_session: previous_session))
|
|
229
|
+
reset_session_diff(@active_session.path)
|
|
230
|
+
cleanup_replaced_session(previous_session)
|
|
231
|
+
@prompt.say("\nCloned session: #{@active_session.path}\n")
|
|
232
|
+
render_conversation_transcript(agent.conversation)
|
|
233
|
+
agent
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def copy_session_text(conversation, argument)
|
|
237
|
+
target = copy_target(argument)
|
|
238
|
+
unless target
|
|
239
|
+
@prompt.say("\nUsage: /copy [last|transcript]\n")
|
|
240
|
+
return
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
content = copy_target_content(conversation, target)
|
|
244
|
+
if content.to_s.empty?
|
|
245
|
+
@prompt.say("\nNothing to copy.\n")
|
|
246
|
+
return
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
result = Clipboard.new(output: $stdout).copy(content)
|
|
250
|
+
if result.success?
|
|
251
|
+
@prompt.say("\nCopied #{copy_target_label(target)}.\n")
|
|
252
|
+
else
|
|
253
|
+
@prompt.say("\nCopy failed: #{result.message}.\n")
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def copy_target(argument)
|
|
258
|
+
target = argument.to_s.strip.downcase
|
|
259
|
+
target = "last" if target.empty?
|
|
260
|
+
return target if ["last", "transcript"].include?(target)
|
|
261
|
+
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def full_message_text(message)
|
|
266
|
+
CLITranscriptFormatter.full_text(message)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def copy_target_content(conversation, target)
|
|
270
|
+
case target
|
|
271
|
+
when "last"
|
|
272
|
+
last_assistant_copy_text(conversation)
|
|
273
|
+
when "transcript"
|
|
274
|
+
markdown_transcript(conversation)
|
|
275
|
+
else
|
|
276
|
+
""
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def last_assistant_copy_text(conversation)
|
|
281
|
+
message = conversation.messages.reverse.find { |item| message_role(item) == "assistant" }
|
|
282
|
+
return "" unless message
|
|
283
|
+
|
|
284
|
+
CLITranscriptFormatter.content_text(message_content(message))
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def copy_target_label(target)
|
|
288
|
+
target == "transcript" ? "transcript" : "last assistant response"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def export_session(conversation, argument)
|
|
292
|
+
path = export_path(argument)
|
|
293
|
+
File.write(path, markdown_transcript(conversation))
|
|
294
|
+
@prompt.say("\nExported session: #{path}\n")
|
|
295
|
+
rescue StandardError => e
|
|
296
|
+
@prompt.say("\nError: #{e.message}\n")
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def say_sessions_unavailable
|
|
300
|
+
@prompt.say("\nSessions are unavailable for this interactive loop.\n")
|
|
301
|
+
nil
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def clear_prompt_transcript
|
|
305
|
+
@prompt.clear_transcript if @prompt.respond_to?(:clear_transcript)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def restore_prompt_transcript(&block)
|
|
309
|
+
if @prompt.respond_to?(:restore_transcript)
|
|
310
|
+
@prompt.restore_transcript(&block)
|
|
311
|
+
else
|
|
312
|
+
block.call
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def select_session_path(session_store)
|
|
317
|
+
sessions = session_store.recent(limit: nil)
|
|
318
|
+
if sessions.empty?
|
|
319
|
+
@prompt.say("\nNo saved sessions found.\n")
|
|
320
|
+
return nil
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
labels = sessions.map { |session| session_label(session) }
|
|
324
|
+
if @prompt.respond_to?(:select)
|
|
325
|
+
choice = @prompt.select("Session>", labels)
|
|
326
|
+
return nil unless choice
|
|
327
|
+
|
|
328
|
+
selected = sessions[labels.index(choice)]
|
|
329
|
+
return selected&.path
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
numbered_labels = labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }
|
|
333
|
+
@prompt.say("\nRecent sessions:\n#{numbered_labels.join("\n")}\n")
|
|
334
|
+
answer = @prompt.ask("Session number or path>").to_s.strip
|
|
335
|
+
if answer.match?(/\A\d+\z/)
|
|
336
|
+
sessions[answer.to_i - 1]&.path
|
|
337
|
+
else
|
|
338
|
+
answer
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def session_label(session)
|
|
343
|
+
title = session.name.to_s.strip
|
|
344
|
+
title = session.first_message.to_s.strip if title.empty?
|
|
345
|
+
title = session.id if title.empty?
|
|
346
|
+
"#{session_tree_prefix(session)}#{title} — #{File.basename(session.path)}"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def session_tree_prefix(session)
|
|
350
|
+
depth = session.respond_to?(:depth) ? session.depth.to_i : 0
|
|
351
|
+
return "" if depth <= 0
|
|
352
|
+
|
|
353
|
+
ancestors = session.respond_to?(:ancestor_continues) ? Array(session.ancestor_continues) : []
|
|
354
|
+
prefix = ancestors.map { |continues| continues ? "│ " : " " }.join
|
|
355
|
+
branch = session.respond_to?(:is_last) && session.is_last ? "└─ " : "├─ "
|
|
356
|
+
prefix + branch
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def export_path(argument)
|
|
360
|
+
default_path = if @active_session
|
|
361
|
+
@active_session.path.sub(/\.jsonl\z/, ".md")
|
|
362
|
+
else
|
|
363
|
+
File.expand_path("kward-session-#{Time.now.utc.iso8601(3).tr(':', '-')}.md", Dir.pwd)
|
|
364
|
+
end
|
|
365
|
+
session_dir = @session_store&.session_dir || (@active_session && File.dirname(@active_session.path))
|
|
366
|
+
|
|
367
|
+
ExportPath.resolve(argument, workspace_root: Dir.pwd, default_path: default_path, session_dir: session_dir)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def markdown_transcript(conversation)
|
|
371
|
+
TranscriptExport.content(conversation)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|