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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ac5f4222e4587d469ce5cb838e4fb0404309083a85a144378f16e75fdfa76ba
4
- data.tar.gz: 325995cade98eb049a465bbfa9d12f2c7ffc9ed1fec0b14be889d2810741fe93
3
+ metadata.gz: 12e9e888bceffd64f87f36ea3aa79e4108901e92a193db7c86e588abef789f04
4
+ data.tar.gz: 4b69dcc0fca28d026931ec809bba3f2b64ad12b37930df51454ad80003104ac0
5
5
  SHA512:
6
- metadata.gz: 7fe3449248d4ae757422cd8e8113ad23b1bdb9dd6555015c64ea628911a8aa937a336b109945029b94f3f5e3fa4846b017af9455e839188ae3b2eb9ca1254cac
7
- data.tar.gz: a92899f7e09d769220246923442450312b27852a801e64f273c3237594ab831b578ad72bde87f6fc6611c4b6fb5a02f4e6080bb7ecb2cd7cc3130e4804e76fc0
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 `/resume` command and RPC `sessions/resume` work regardless of this automatic resume setting.
191
+ The `/sessions` command, `/resume` alias, and RPC `sessions/resume` work regardless of this automatic resume setting.
192
192
 
193
193
  ## Memory
194
194
 
@@ -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
- /resume resume a previous session
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
- | `/resume` | continue a previous session. |
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
- /resume
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
@@ -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 = apply_session_tree_entry(entry)
151
- runtime_output("Moved session tree position to #{entry["id"]}.")
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
- agent = reload_active_session(session_store)
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
- target_leaf.to_s.empty? ? @active_session.reset_leaf : @active_session.branch(target_leaf)
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
- sessions = session_store.recent(limit: nil)
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
- [true, run_busy_local_command_and_requeue do
48
- path = argument.to_s.strip
49
- path = select_session_path(session_store) if session_store && path.empty?
50
- resume_session(session_store, path)
51
- end]
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, run_busy_local_command_and_requeue { navigate_session_tree(session_store) }]
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) }
@@ -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 = OPENAI_MODEL_CHOICES.map { |model| "openai/#{model}" }.freeze
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
- nil
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
- handle_resize_locked
13
- width, height = screen_size
14
- rows, cursor_row, cursor_col = composer_layout(width, height)
15
- ensure_scroll_region_locked(rows.length, width: width, height: height)
16
- @rendered_rows = rows.length
17
- render_composer_rows_locked(rows, height: height)
18
- @cursor_rendered_row = cursor_row
19
- @last_width = width
20
- @last_height = height
21
- move_to_screen(composer_top_row(height) + cursor_row, cursor_col + 1)
22
- render_cursor_visibility_locked
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
- rows_to_clear = [old_reserved_rows, new_reserved_rows].max
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
- clear_composer_region_locked(rows_to_clear, height: height)
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
- @output_io.print(TTY::Cursor.clear_line)
119
- @output_io.print(row) unless row.to_s.empty?
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 - 1) % matches.length
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 + 1) % matches.length
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 = false
202
- @rendered_rows = 0
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 = [[height - 7, 1].max, 8].min
236
- start = [[selection_index - max_rows + 1, 0].max, [matches.length - max_rows, 0].max].min
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 - 1) % matches.length
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 + 1) % matches.length
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 = [[height - 7, 1].max, 8].min
96
- start = [[@slash_selection_index - max_rows + 1, 0].max, [matches.length - max_rows, 0].max].min
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