kward 0.69.1 → 0.70.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 +22 -0
- data/doc/configuration.md +1 -1
- data/doc/getting-started.md +3 -1
- data/doc/usage.md +9 -2
- data/lib/kward/cli/prompt_interface.rb +5 -1
- data/lib/kward/cli/sessions.rb +197 -7
- data/lib/kward/cli/slash_commands.rb +16 -7
- data/lib/kward/cli.rb +1 -0
- data/lib/kward/conversation.rb +15 -0
- data/lib/kward/model/model_info.rb +9 -2
- data/lib/kward/prompt_interface/overlay_renderer.rb +24 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +13 -11
- data/lib/kward/prompt_interface/screen.rb +9 -4
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -9
- data/lib/kward/prompt_interface/slash_overlay.rb +4 -4
- data/lib/kward/prompt_interface.rb +9 -4
- data/lib/kward/prompts/commands.rb +4 -2
- data/lib/kward/rpc/session_manager.rb +2 -2
- data/lib/kward/session_diff.rb +106 -9
- data/lib/kward/session_tree_renderer.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/templates/default/fulldoc/html/css/kward.css +314 -71
- data/templates/default/fulldoc/html/js/kward.js +100 -97
- data/templates/default/layout/html/layout.erb +21 -6
- data/templates/default/layout/html/setup.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 12e9e888bceffd64f87f36ea3aa79e4108901e92a193db7c86e588abef789f04
|
|
4
|
+
data.tar.gz: 4b69dcc0fca28d026931ec809bba3f2b64ad12b37930df51454ad80003104ac0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d372a52d9eb15695e33f6723d060ea051a8cda166e3692c5b3f634a1d7ca5cd7b533dc8afc41f88f39228af095af049c8b767c3d9696e846fb99b58218fdae5f
|
|
7
|
+
data.tar.gz: 7ff32c27130676339ab62bccbbc2777c5a382c8adf66f4d1fec525d4c12ecea0aa8d563814c331f409f8c030f0236cc3dd727405955c4bedcb96705c94d42aec
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ All notable changes to Kward will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.70.0] - 2026-06-18
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added `z-ai/glm-5.2` to the static OpenRouter model choices.
|
|
12
|
+
- Added `/rewind` for revisiting earlier user prompts and continuing from there as a branch, while `/tree` remains the advanced full session tree navigator.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Changed TUI list navigation to keep long `/sessions` and `/tree` pickers centered while scrolling, and removed wrap-around at list edges.
|
|
17
|
+
- Changed the saved session picker slash command to `/sessions`, with `/resume` kept as an alias.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Fixed overlay open/close rendering so the interactive composer stays visible instead of briefly blinking away.
|
|
22
|
+
- Fixed interactive session diff totals to show the net workspace diff instead of counting repeated edits to the same lines multiple times.
|
|
23
|
+
- Fixed `/sessions` picker cancellation so Escape closes smoothly without a blink, while keeping the loading spinner visible as saved sessions are loaded.
|
|
24
|
+
- Fixed root-prompt session tree navigation so it no longer persists an empty active branch that makes cloned sessions resume with a blank transcript.
|
|
25
|
+
- Fixed `/resume` session picker entries to show cloned session ancestry as a tree.
|
|
26
|
+
- Made `/tree` branch indentation more visible in the terminal session picker.
|
|
27
|
+
- Fixed `Encoding::CompatibilityError` crash during compaction when tool results contained ASCII-8BIT (BINARY) strings from HTTP response bodies or shell output. Tool content is now normalized to UTF-8 on append.
|
|
28
|
+
|
|
7
29
|
## [0.69.1] - 2026-06-18
|
|
8
30
|
|
|
9
31
|
### Fixed
|
data/doc/configuration.md
CHANGED
|
@@ -188,7 +188,7 @@ Interactive CLI and RPC clients start fresh by default. To automatically resume
|
|
|
188
188
|
}
|
|
189
189
|
```
|
|
190
190
|
|
|
191
|
-
The `/
|
|
191
|
+
The `/sessions` command, `/resume` alias, and RPC `sessions/resume` work regardless of this automatic resume setting.
|
|
192
192
|
|
|
193
193
|
## Memory
|
|
194
194
|
|
data/doc/getting-started.md
CHANGED
|
@@ -96,7 +96,9 @@ Inside interactive Kward:
|
|
|
96
96
|
/login sign in or save provider credentials
|
|
97
97
|
/model choose a model
|
|
98
98
|
/status show session and context status
|
|
99
|
-
/
|
|
99
|
+
/sessions open the saved sessions picker
|
|
100
|
+
/resume alias for /sessions
|
|
101
|
+
/rewind revisit an earlier prompt
|
|
100
102
|
/export notes.md export the transcript
|
|
101
103
|
/compact summarize older context when a chat gets long
|
|
102
104
|
/exit leave Kward
|
data/doc/usage.md
CHANGED
|
@@ -97,9 +97,12 @@ Use slash commands for local actions that should not go to the model:
|
|
|
97
97
|
| `/reasoning` | choose reasoning effort. |
|
|
98
98
|
| `/status` | see session, model, and context status. |
|
|
99
99
|
| `/new` | start a fresh session. |
|
|
100
|
-
| `/
|
|
100
|
+
| `/sessions` | open the saved sessions picker or continue a previous session by path. |
|
|
101
|
+
| `/resume` | alias for `/sessions`. |
|
|
101
102
|
| `/name <name>` | name the current session. |
|
|
102
103
|
| `/clone` | copy the current session into a new branch. |
|
|
104
|
+
| `/rewind` | revisit an earlier prompt and try a different direction. |
|
|
105
|
+
| `/tree` | inspect and navigate the full technical session tree. |
|
|
103
106
|
| `/copy last` | copy the latest assistant answer. |
|
|
104
107
|
| `/copy transcript` | copy the transcript as Markdown. |
|
|
105
108
|
| `/export notes.md` | write the transcript to a Markdown file. |
|
|
@@ -132,9 +135,13 @@ Typical flow:
|
|
|
132
135
|
Later:
|
|
133
136
|
|
|
134
137
|
```text
|
|
135
|
-
/
|
|
138
|
+
/sessions
|
|
136
139
|
```
|
|
137
140
|
|
|
141
|
+
`/resume` remains available as an alias.
|
|
142
|
+
|
|
143
|
+
Use `/rewind` when you want to go back to an earlier prompt and try a different direction. Kward shows user prompts from the current session, lets you edit a prior prompt, and continues from that point as a branch. Use `/tree` when you need the full technical session tree, including branches, assistant entries, tool calls, and tool results.
|
|
144
|
+
|
|
138
145
|
Use `/compact` when a conversation gets long. Kward summarizes older context and keeps recent context active. After compaction, it may need to re-read files before editing them again.
|
|
139
146
|
|
|
140
147
|
## One-shot prompts
|
|
@@ -25,7 +25,11 @@ module Kward
|
|
|
25
25
|
banner_pixels: banner_enabled ? Kward::PromptInterface::BANNER_LOGO_PIXELS : nil,
|
|
26
26
|
banner_message: banner_enabled ? Kward::PromptInterface::BANNER_MESSAGE : nil
|
|
27
27
|
)
|
|
28
|
-
@prompt.start
|
|
28
|
+
if @prompt.method(:start).parameters.any? { |kind, name| [:key, :keyreq].include?(kind) && name == :render }
|
|
29
|
+
@prompt.start(render: false)
|
|
30
|
+
else
|
|
31
|
+
@prompt.start
|
|
32
|
+
end
|
|
29
33
|
end
|
|
30
34
|
|
|
31
35
|
def load_prompt_interface
|
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.
|
|
@@ -131,7 +133,7 @@ module Kward
|
|
|
131
133
|
return nil
|
|
132
134
|
end
|
|
133
135
|
|
|
134
|
-
tree_items = session_tree_items(session_store)
|
|
136
|
+
tree_items = run_busy_local_command_and_requeue { session_tree_items(session_store) }
|
|
135
137
|
if tree_items.empty?
|
|
136
138
|
runtime_output("No session tree entries found.")
|
|
137
139
|
return nil
|
|
@@ -147,8 +149,12 @@ module Kward
|
|
|
147
149
|
entry = tree_items.find { |item| item[:entry]["id"].to_s == entry_id }&.fetch(:entry)
|
|
148
150
|
return nil unless entry
|
|
149
151
|
|
|
150
|
-
selected_text =
|
|
151
|
-
|
|
152
|
+
selected_text = nil
|
|
153
|
+
agent = run_busy_local_command_and_requeue do
|
|
154
|
+
selected_text = apply_session_tree_entry(entry)
|
|
155
|
+
runtime_output("Moved session tree position to #{entry["id"]}.")
|
|
156
|
+
reload_active_session(session_store)
|
|
157
|
+
end
|
|
152
158
|
if selected_text && !selected_text.empty?
|
|
153
159
|
if @prompt.respond_to?(:prefill_input)
|
|
154
160
|
@prompt.prefill_input(selected_text)
|
|
@@ -156,8 +162,7 @@ module Kward
|
|
|
156
162
|
runtime_output("Selected text for editing:\n#{selected_text}")
|
|
157
163
|
end
|
|
158
164
|
end
|
|
159
|
-
|
|
160
|
-
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
165
|
+
@prompt.redraw if @prompt.respond_to?(:redraw) && !@prompt.respond_to?(:restore_transcript)
|
|
161
166
|
agent
|
|
162
167
|
rescue StandardError => e
|
|
163
168
|
runtime_output("Session tree error: #{e.message}")
|
|
@@ -175,11 +180,193 @@ module Kward
|
|
|
175
180
|
answer.match?(/\A\d+\z/) ? labels[answer.to_i - 1] : nil
|
|
176
181
|
end
|
|
177
182
|
|
|
183
|
+
def rewind_session(session_store)
|
|
184
|
+
return say_sessions_unavailable unless session_store
|
|
185
|
+
unless @active_session
|
|
186
|
+
runtime_output("No active persisted session.")
|
|
187
|
+
return nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
points = rewind_points(session_store)
|
|
191
|
+
if points.empty?
|
|
192
|
+
runtime_output("No prompts to rewind to.")
|
|
193
|
+
return nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
labels = points.map { |point| point[:label] }
|
|
197
|
+
choice = select_rewind_point(labels)
|
|
198
|
+
return nil unless choice
|
|
199
|
+
|
|
200
|
+
point = points[labels.index(choice)]
|
|
201
|
+
return nil unless point
|
|
202
|
+
|
|
203
|
+
if point[:return_leaf_id]
|
|
204
|
+
@active_session.branch(point[:return_leaf_id])
|
|
205
|
+
@rewind_return_leaf_id = nil
|
|
206
|
+
else
|
|
207
|
+
@rewind_return_leaf_id = @active_session.leaf_id || session_store.current_leaf(@active_session.path)
|
|
208
|
+
selected_text = apply_session_tree_entry(point[:entry])
|
|
209
|
+
if selected_text && !selected_text.empty?
|
|
210
|
+
if @prompt.respond_to?(:prefill_input)
|
|
211
|
+
@prompt.prefill_input(selected_text)
|
|
212
|
+
else
|
|
213
|
+
runtime_output("Selected prompt for editing:\n#{selected_text}")
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
agent = reload_active_session(session_store)
|
|
218
|
+
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
219
|
+
agent
|
|
220
|
+
rescue StandardError => e
|
|
221
|
+
runtime_output("Rewind error: #{e.message}")
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def select_rewind_point(labels)
|
|
226
|
+
if @prompt.respond_to?(:select)
|
|
227
|
+
return @prompt.select("Rewind>", labels, title: "Rewind")
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
numbered_labels = labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }
|
|
231
|
+
runtime_output((["Rewind to:"] + numbered_labels).join("\n"))
|
|
232
|
+
answer = @prompt.ask("Rewind point number>").to_s.strip
|
|
233
|
+
answer.match?(/\A\d+\z/) ? labels[answer.to_i - 1] : nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def rewind_points(session_store)
|
|
237
|
+
entries = session_store.session_entries(@active_session.path)
|
|
238
|
+
current_leaf_id = @active_session.leaf_id || session_store.current_leaf(@active_session.path)
|
|
239
|
+
active_path = active_session_tree_entry_ids(entries, current_leaf_id)
|
|
240
|
+
user_entries = entries.select { |entry| rewind_entry?(entry) }
|
|
241
|
+
points = user_entries.reverse_each.with_index.map do |entry, index|
|
|
242
|
+
{
|
|
243
|
+
entry: entry,
|
|
244
|
+
label: rewind_point_label(entry, index, active_path.include?(entry["id"].to_s)),
|
|
245
|
+
timestamp: entry["timestamp"]
|
|
246
|
+
}
|
|
247
|
+
end
|
|
248
|
+
return_point = rewind_return_point(entries, current_leaf_id)
|
|
249
|
+
points = [return_point] + points if return_point
|
|
250
|
+
align_rewind_point_timestamps(points, picker_choice_width)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def rewind_return_point(entries, current_leaf_id)
|
|
254
|
+
return nil if @rewind_return_leaf_id.to_s.empty?
|
|
255
|
+
return nil if @rewind_return_leaf_id.to_s == current_leaf_id.to_s
|
|
256
|
+
|
|
257
|
+
entry = entries.find { |candidate| candidate["id"].to_s == @rewind_return_leaf_id.to_s }
|
|
258
|
+
return nil unless entry
|
|
259
|
+
|
|
260
|
+
{
|
|
261
|
+
return_leaf_id: @rewind_return_leaf_id,
|
|
262
|
+
label: "Return to where I was: #{truncate_rewind_text(rewind_return_text(entry))}",
|
|
263
|
+
timestamp: entry["timestamp"]
|
|
264
|
+
}
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def align_rewind_point_timestamps(points, width)
|
|
268
|
+
labels = points.map { |point| point[:label].to_s }
|
|
269
|
+
label_width = labels.map(&:length).max.to_i
|
|
270
|
+
points.each do |point|
|
|
271
|
+
timestamp = relative_rewind_time(point[:timestamp])
|
|
272
|
+
next if timestamp.empty?
|
|
273
|
+
|
|
274
|
+
point[:label] = right_aligned_picker_metadata(point[:label], timestamp, width: width, minimum_label_width: label_width)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def right_aligned_picker_metadata(label, metadata, width:, minimum_label_width: 0)
|
|
279
|
+
label = label.to_s
|
|
280
|
+
metadata = metadata.to_s
|
|
281
|
+
fallback_width = minimum_label_width + metadata.length + 2
|
|
282
|
+
target_width = width.to_i.positive? ? width.to_i : fallback_width
|
|
283
|
+
label_width = [target_width - metadata.length - 2, 1].max
|
|
284
|
+
"#{truncate_picker_label(label, label_width).ljust(label_width)} #{metadata}"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def truncate_picker_label(label, width)
|
|
288
|
+
return "" if width <= 0
|
|
289
|
+
|
|
290
|
+
text = label.to_s
|
|
291
|
+
return text if text.length <= width
|
|
292
|
+
return text.slice(0, width) if width <= 3
|
|
293
|
+
|
|
294
|
+
"#{text.slice(0, width - 3)}..."
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def relative_rewind_time(timestamp)
|
|
298
|
+
time = Time.iso8601(timestamp.to_s).utc
|
|
299
|
+
seconds = [(Time.now.utc - time).to_i, 0].max
|
|
300
|
+
case seconds
|
|
301
|
+
when 0...60
|
|
302
|
+
"just now"
|
|
303
|
+
when 60...3600
|
|
304
|
+
minutes = seconds / 60
|
|
305
|
+
"#{minutes} min ago"
|
|
306
|
+
when 3600...86_400
|
|
307
|
+
hours = seconds / 3600
|
|
308
|
+
"#{hours} h ago"
|
|
309
|
+
else
|
|
310
|
+
days = seconds / 86_400
|
|
311
|
+
"#{days} d ago"
|
|
312
|
+
end
|
|
313
|
+
rescue ArgumentError
|
|
314
|
+
""
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def rewind_return_text(entry)
|
|
318
|
+
message = entry["message"]
|
|
319
|
+
text = full_message_text(message) if message.is_a?(Hash)
|
|
320
|
+
text.to_s.empty? ? entry["id"].to_s : text
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def rewind_entry?(entry)
|
|
324
|
+
return false unless entry["type"] == "message"
|
|
325
|
+
|
|
326
|
+
message = entry["message"]
|
|
327
|
+
message.is_a?(Hash) && message_role(message) == "user" && !full_message_text(message).empty?
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def rewind_point_label(entry, index, active)
|
|
331
|
+
marker = active ? "• " : ""
|
|
332
|
+
prefix = case index
|
|
333
|
+
when 0 then "Last prompt"
|
|
334
|
+
when 1 then "2 turns ago"
|
|
335
|
+
else "#{index + 1} turns ago"
|
|
336
|
+
end
|
|
337
|
+
"#{marker}#{prefix}: #{truncate_rewind_text(full_message_text(entry["message"] || {}))}"
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def active_session_tree_entry_ids(entries, leaf_id)
|
|
341
|
+
by_id = entries.to_h { |entry| [entry["id"].to_s, entry] }
|
|
342
|
+
ids = []
|
|
343
|
+
seen = {}
|
|
344
|
+
current = by_id[leaf_id.to_s]
|
|
345
|
+
while current && !seen[current["id"].to_s]
|
|
346
|
+
seen[current["id"].to_s] = true
|
|
347
|
+
ids << current["id"].to_s
|
|
348
|
+
current = by_id[current["parentId"].to_s]
|
|
349
|
+
end
|
|
350
|
+
ids
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def truncate_rewind_text(text)
|
|
354
|
+
text.to_s.gsub(/\s+/, " ").strip
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def picker_choice_width
|
|
358
|
+
if @prompt.respond_to?(:picker_choice_width)
|
|
359
|
+
@prompt.picker_choice_width
|
|
360
|
+
else
|
|
361
|
+
96
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
178
365
|
def apply_session_tree_entry(entry)
|
|
179
366
|
message = entry["message"]
|
|
180
367
|
if message.is_a?(Hash) && message_role(message) == "user"
|
|
181
368
|
target_leaf = entry["parentId"]
|
|
182
|
-
|
|
369
|
+
@active_session.branch(target_leaf) unless target_leaf.to_s.empty?
|
|
183
370
|
return full_message_text(message)
|
|
184
371
|
end
|
|
185
372
|
|
|
@@ -314,7 +501,10 @@ module Kward
|
|
|
314
501
|
end
|
|
315
502
|
|
|
316
503
|
def select_session_path(session_store)
|
|
317
|
-
|
|
504
|
+
select_session_path_from_sessions(session_store.recent_tree(limit: nil))
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def select_session_path_from_sessions(sessions)
|
|
318
508
|
if sessions.empty?
|
|
319
509
|
runtime_output("No saved sessions found.")
|
|
320
510
|
return nil
|
|
@@ -43,19 +43,28 @@ module Kward
|
|
|
43
43
|
[true, nil]
|
|
44
44
|
when "new"
|
|
45
45
|
[true, run_busy_local_command_and_requeue { start_new_session(session_store) }]
|
|
46
|
-
when "resume"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
when "sessions", "resume"
|
|
47
|
+
unless session_store
|
|
48
|
+
say_sessions_unavailable
|
|
49
|
+
return [true, nil]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
path = argument.to_s.strip
|
|
53
|
+
if path.empty?
|
|
54
|
+
sessions = run_busy_local_command_and_requeue { session_store.recent_tree(limit: nil) }
|
|
55
|
+
path = select_session_path_from_sessions(sessions)
|
|
56
|
+
end
|
|
57
|
+
replacement_agent = path.to_s.empty? ? nil : run_busy_local_command_and_requeue { resume_session(session_store, path) }
|
|
58
|
+
[true, replacement_agent]
|
|
52
59
|
when "name"
|
|
53
60
|
run_busy_local_command_and_requeue { rename_session(argument) }
|
|
54
61
|
[true, nil]
|
|
55
62
|
when "clone"
|
|
56
63
|
[true, run_busy_local_command_and_requeue { clone_session(session_store, agent) }]
|
|
64
|
+
when "rewind"
|
|
65
|
+
[true, run_busy_local_command_and_requeue { rewind_session(session_store) }]
|
|
57
66
|
when "tree"
|
|
58
|
-
[true,
|
|
67
|
+
[true, navigate_session_tree(session_store)]
|
|
59
68
|
when "copy"
|
|
60
69
|
run_busy_local_command_and_requeue { copy_session_text(agent.conversation, argument) }
|
|
61
70
|
[true, nil]
|
data/lib/kward/cli.rb
CHANGED
|
@@ -311,6 +311,7 @@ module Kward
|
|
|
311
311
|
input = expanded_input || input
|
|
312
312
|
@footer_conversation = agent.conversation
|
|
313
313
|
begin
|
|
314
|
+
@rewind_return_leaf_id = nil
|
|
314
315
|
auto_name_active_session(display_input || input)
|
|
315
316
|
pending_inputs = run_interactive_turn(agent, input, display_input: display_input)
|
|
316
317
|
pending_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
|
data/lib/kward/conversation.rb
CHANGED
|
@@ -111,6 +111,7 @@ module Kward
|
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
def append_tool(tool_call_id:, name:, content:)
|
|
114
|
+
content = normalize_tool_content(content) if content.is_a?(String)
|
|
114
115
|
append_message({
|
|
115
116
|
role: "tool",
|
|
116
117
|
tool_call_id: tool_call_id,
|
|
@@ -241,5 +242,19 @@ module Kward
|
|
|
241
242
|
message
|
|
242
243
|
end
|
|
243
244
|
|
|
245
|
+
# Tool results may arrive as ASCII-8BIT (BINARY) strings, e.g. from
|
|
246
|
+
# Net::HTTP response bodies or shell command output. When such a string
|
|
247
|
+
# is later concatenated with a UTF-8 string containing non-ASCII bytes
|
|
248
|
+
# (during compaction or JSON serialization), Ruby raises
|
|
249
|
+
# Encoding::CompatibilityError. Re-tag BINARY strings as UTF-8 when the
|
|
250
|
+
# bytes are valid UTF-8; otherwise scrub so the content is always
|
|
251
|
+
# serializable and concatenable.
|
|
252
|
+
def normalize_tool_content(string)
|
|
253
|
+
return string unless string.encoding == Encoding::ASCII_8BIT
|
|
254
|
+
|
|
255
|
+
probe = string.dup.force_encoding(Encoding::UTF_8)
|
|
256
|
+
probe.valid_encoding? ? probe : probe.scrub
|
|
257
|
+
end
|
|
258
|
+
|
|
244
259
|
end
|
|
245
260
|
end
|
|
@@ -10,7 +10,10 @@ module Kward
|
|
|
10
10
|
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6"
|
|
11
11
|
DEFAULT_REASONING_EFFORT = "medium"
|
|
12
12
|
OPENAI_MODEL_CHOICES = %w[gpt-5.5 gpt-5.4 gpt-5.4-mini gpt-5.3-codex-spark].freeze
|
|
13
|
-
OPENROUTER_MODEL_CHOICES =
|
|
13
|
+
OPENROUTER_MODEL_CHOICES = [
|
|
14
|
+
*OPENAI_MODEL_CHOICES.map { |model| "openai/#{model}" },
|
|
15
|
+
"z-ai/glm-5.2"
|
|
16
|
+
].freeze
|
|
14
17
|
ANTHROPIC_MODEL_CHOICES = %w[
|
|
15
18
|
claude-opus-4-8
|
|
16
19
|
claude-sonnet-4-6
|
|
@@ -115,6 +118,9 @@ module Kward
|
|
|
115
118
|
GEMINI_CONTEXT_WINDOWS = [
|
|
116
119
|
[/\Agemini-(?:2\.5-pro|3(?:\.1)?-pro|3(?:\.5)?-flash)/, 1_048_576]
|
|
117
120
|
].freeze
|
|
121
|
+
OPENROUTER_CONTEXT_WINDOWS = [
|
|
122
|
+
[/\Az-ai\/glm-5\.2\z/, 1_048_576]
|
|
123
|
+
].freeze
|
|
118
124
|
|
|
119
125
|
module_function
|
|
120
126
|
|
|
@@ -252,7 +258,7 @@ module Kward
|
|
|
252
258
|
return anthropic_context_window(text.delete_prefix("anthropic/")) if text.start_with?("anthropic/")
|
|
253
259
|
return pattern_context_window(GEMINI_CONTEXT_WINDOWS, text.delete_prefix("google/")) if text.start_with?("google/")
|
|
254
260
|
|
|
255
|
-
|
|
261
|
+
pattern_context_window(OPENROUTER_CONTEXT_WINDOWS, text)
|
|
256
262
|
end
|
|
257
263
|
|
|
258
264
|
def copilot_context_window(id)
|
|
@@ -298,6 +304,7 @@ module Kward
|
|
|
298
304
|
|
|
299
305
|
def openai_reasoning_effort_choices(id)
|
|
300
306
|
text = id.to_s.delete_prefix("openai/")
|
|
307
|
+
return REASONING_EFFORT_CHOICES if text == "z-ai/glm-5.2"
|
|
301
308
|
return REASONING_EFFORT_CHOICES if text.match?(/\Agpt-5\.[23]-codex/)
|
|
302
309
|
|
|
303
310
|
OPENAI_REASONING_EFFORT_CHOICES
|
|
@@ -73,6 +73,30 @@ module Kward
|
|
|
73
73
|
(" " * left) + row + (" " * right)
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
+
def next_list_selection_index(index, count)
|
|
77
|
+
return 0 if count <= 0
|
|
78
|
+
|
|
79
|
+
[index + 1, count - 1].min
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def previous_list_selection_index(index, count)
|
|
83
|
+
return 0 if count <= 0
|
|
84
|
+
|
|
85
|
+
[index - 1, 0].max
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def centered_list_window_start(index, count, max_rows)
|
|
89
|
+
return 0 if count <= max_rows
|
|
90
|
+
|
|
91
|
+
last_start = count - max_rows
|
|
92
|
+
middle_offset = max_rows / 2
|
|
93
|
+
[[index - middle_offset, 0].max, last_start].min
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def max_overlay_list_rows(height)
|
|
97
|
+
[[height - 7, 1].max, 8].min
|
|
98
|
+
end
|
|
99
|
+
|
|
76
100
|
def overlay_left_padding(width, row_width)
|
|
77
101
|
padding = [width - row_width, 0].max
|
|
78
102
|
case @overlay_settings["alignment"]
|
|
@@ -9,17 +9,19 @@ module Kward
|
|
|
9
9
|
def render_prompt_locked
|
|
10
10
|
return unless @started && @asking
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
12
|
+
with_synchronized_output_locked do
|
|
13
|
+
handle_resize_locked
|
|
14
|
+
width, height = screen_size
|
|
15
|
+
rows, cursor_row, cursor_col = composer_layout(width, height)
|
|
16
|
+
ensure_scroll_region_locked(rows.length, width: width, height: height)
|
|
17
|
+
@rendered_rows = rows.length
|
|
18
|
+
render_composer_rows_locked(rows, height: height)
|
|
19
|
+
@cursor_rendered_row = cursor_row
|
|
20
|
+
@last_width = width
|
|
21
|
+
@last_height = height
|
|
22
|
+
move_to_screen(composer_top_row(height) + cursor_row, cursor_col + 1)
|
|
23
|
+
render_cursor_visibility_locked
|
|
24
|
+
end
|
|
23
25
|
@output_io.flush
|
|
24
26
|
end
|
|
25
27
|
|
|
@@ -75,10 +75,12 @@ module Kward
|
|
|
75
75
|
return if @reserved_rows == new_reserved_rows && @last_height == height
|
|
76
76
|
|
|
77
77
|
old_reserved_rows = @reserved_rows
|
|
78
|
-
|
|
78
|
+
old_top = [height - old_reserved_rows + 1, 1].max
|
|
79
79
|
@reserved_rows = new_reserved_rows
|
|
80
|
+
new_top = composer_top_row(height)
|
|
80
81
|
@output_io.print("\e[1;#{transcript_bottom_row(height)}r")
|
|
81
|
-
|
|
82
|
+
clear_screen_rows_locked(old_top, new_top - 1) if new_top > old_top
|
|
83
|
+
@last_composer_rows = []
|
|
82
84
|
redraw_transcript_locked(width: width, height: height) if redraw_transcript && new_reserved_rows < old_reserved_rows
|
|
83
85
|
end
|
|
84
86
|
|
|
@@ -115,8 +117,11 @@ module Kward
|
|
|
115
117
|
next if row == previous
|
|
116
118
|
|
|
117
119
|
move_to_screen(top + index, 1)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
+
if row
|
|
121
|
+
@output_io.print(row)
|
|
122
|
+
else
|
|
123
|
+
@output_io.print(TTY::Cursor.clear_line)
|
|
124
|
+
end
|
|
120
125
|
end
|
|
121
126
|
|
|
122
127
|
rows.length.upto(rows.length + rows_to_clear - 1) do |index|
|
|
@@ -78,7 +78,7 @@ module Kward
|
|
|
78
78
|
|
|
79
79
|
def handle_select_escape_sequence
|
|
80
80
|
sequence = read_pending_escape_sequence
|
|
81
|
-
return SELECT_CANCEL if sequence.empty?
|
|
81
|
+
return SELECT_CANCEL if sequence.empty? || sequence.start_with?("\e")
|
|
82
82
|
|
|
83
83
|
key_name = @reader.console.keys["\e#{sequence}"]
|
|
84
84
|
case key_name
|
|
@@ -134,14 +134,14 @@ module Kward
|
|
|
134
134
|
matches = selection_matches
|
|
135
135
|
return if matches.empty?
|
|
136
136
|
|
|
137
|
-
@select_state[:selection_index] = (selection_index
|
|
137
|
+
@select_state[:selection_index] = previous_list_selection_index(selection_index, matches.length)
|
|
138
138
|
end
|
|
139
139
|
|
|
140
140
|
def select_next_choice
|
|
141
141
|
matches = selection_matches
|
|
142
142
|
return if matches.empty?
|
|
143
143
|
|
|
144
|
-
@select_state[:selection_index] = (selection_index
|
|
144
|
+
@select_state[:selection_index] = next_list_selection_index(selection_index, matches.length)
|
|
145
145
|
end
|
|
146
146
|
|
|
147
147
|
def select_insert_key(key)
|
|
@@ -195,12 +195,10 @@ module Kward
|
|
|
195
195
|
def finish_select_prompt
|
|
196
196
|
@mutex.synchronize do
|
|
197
197
|
@select_state = nil
|
|
198
|
-
clear_prompt_locked
|
|
199
198
|
self.composer_input = ""
|
|
200
199
|
self.composer_cursor = 0
|
|
201
|
-
@asking =
|
|
202
|
-
|
|
203
|
-
@cursor_rendered_row = 0
|
|
200
|
+
@asking = true
|
|
201
|
+
render_prompt_locked
|
|
204
202
|
@output_io.flush
|
|
205
203
|
end
|
|
206
204
|
end
|
|
@@ -232,8 +230,8 @@ module Kward
|
|
|
232
230
|
end
|
|
233
231
|
|
|
234
232
|
def visible_selection_matches(matches, height: screen_height)
|
|
235
|
-
max_rows =
|
|
236
|
-
start =
|
|
233
|
+
max_rows = max_overlay_list_rows(height)
|
|
234
|
+
start = centered_list_window_start(selection_index, matches.length, max_rows)
|
|
237
235
|
{ start: start, choices: matches[start, max_rows] || [] }
|
|
238
236
|
end
|
|
239
237
|
|
|
@@ -58,14 +58,14 @@ module Kward
|
|
|
58
58
|
matches = slash_overlay_matches
|
|
59
59
|
return if matches.empty?
|
|
60
60
|
|
|
61
|
-
@slash_selection_index = (@slash_selection_index
|
|
61
|
+
@slash_selection_index = previous_list_selection_index(@slash_selection_index, matches.length)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def select_next_slash_command
|
|
65
65
|
matches = slash_overlay_matches
|
|
66
66
|
return if matches.empty?
|
|
67
67
|
|
|
68
|
-
@slash_selection_index = (@slash_selection_index
|
|
68
|
+
@slash_selection_index = next_list_selection_index(@slash_selection_index, matches.length)
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
def complete_selected_slash_command
|
|
@@ -92,8 +92,8 @@ module Kward
|
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
def visible_slash_overlay_matches(matches, height: screen_height)
|
|
95
|
-
max_rows =
|
|
96
|
-
start =
|
|
95
|
+
max_rows = max_overlay_list_rows(height)
|
|
96
|
+
start = centered_list_window_start(@slash_selection_index, matches.length, max_rows)
|
|
97
97
|
{ start: start, commands: matches[start, max_rows] || [] }
|
|
98
98
|
end
|
|
99
99
|
|