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.
@@ -131,7 +131,7 @@ module Kward
131
131
  @banner = Banner.new(message: banner_message, pixels: banner_pixels, screen_height: method(:screen_height))
132
132
  end
133
133
 
134
- def start
134
+ def start(render: true)
135
135
  @mutex.synchronize do
136
136
  return if @started
137
137
 
@@ -140,7 +140,7 @@ module Kward
140
140
  @asking = true
141
141
  @output_io.print(KEYBOARD_PROTOCOL_ENABLE)
142
142
  @output_io.print(BRACKETED_PASTE_ENABLE)
143
- render_prompt_locked
143
+ render_prompt_locked if render
144
144
  end
145
145
  end
146
146
 
@@ -198,9 +198,10 @@ module Kward
198
198
  end
199
199
 
200
200
  def restore_transcript
201
+ start(render: false) unless @started
201
202
  @mutex.synchronize do
202
- clear_prompt_for_output_locked
203
203
  @output_io.print(SYNCHRONIZED_OUTPUT_ENABLE)
204
+ clear_prompt_for_output_locked unless @rendered_rows.zero? && @last_composer_rows.empty?
204
205
  @transcript_buffer.clear
205
206
  @visual_banner_count = 0
206
207
  @transcript_viewport_rows = 0
@@ -213,9 +214,9 @@ module Kward
213
214
  ensure
214
215
  @mutex.synchronize do
215
216
  @restoring_transcript = false
216
- @output_io.print(SYNCHRONIZED_OUTPUT_DISABLE)
217
217
  width, height = screen_size
218
218
  redraw_screen_locked(width: width, height: height)
219
+ @output_io.print(SYNCHRONIZED_OUTPUT_DISABLE)
219
220
  @output_io.flush
220
221
  end
221
222
  end
@@ -270,6 +271,10 @@ module Kward
270
271
  answer.start_with?("y")
271
272
  end
272
273
 
274
+ def picker_choice_width
275
+ [overlay_card_width(screen_width) - 6, 1].max
276
+ end
277
+
273
278
  def select(message, choices, title: "Sessions", custom: false, initial_index: 0)
274
279
  return nil if choices.empty? && !custom
275
280
 
@@ -8,10 +8,12 @@ module Kward
8
8
  { name: "exit", description: "Exit the interactive session.", argument_hint: "" },
9
9
  { name: "quit", description: "Exit the interactive session.", argument_hint: "" },
10
10
  { name: "new", description: "Start a new session.", argument_hint: "" },
11
- { name: "resume", description: "Resume a saved session.", argument_hint: "[path]" },
11
+ { name: "sessions", description: "Open the saved sessions picker.", argument_hint: "[path]" },
12
+ { name: "resume", description: "Alias for /sessions.", argument_hint: "[path]" },
12
13
  { name: "name", description: "Name or clear the current session.", argument_hint: "[name]" },
13
14
  { name: "clone", description: "Clone the current session.", argument_hint: "" },
14
- { name: "tree", description: "Navigate the current session tree.", argument_hint: "" },
15
+ { name: "rewind", description: "Revisit an earlier prompt and try a different direction.", argument_hint: "" },
16
+ { name: "tree", description: "Inspect and navigate the full technical session tree.", argument_hint: "" },
15
17
  { name: "copy", description: "Copy clean session text to the clipboard.", argument_hint: "[last|transcript]" },
16
18
  { name: "export", description: "Export the current session as Markdown.", argument_hint: "[path]" },
17
19
  { name: "compact", description: "Compact the current conversation context.", argument_hint: "[instructions]" },
@@ -251,8 +251,8 @@ module Kward
251
251
  if summarize
252
252
  summary = summarize_branch(rpc_session, from_id: previous_leaf, to_id: target_leaf, custom_instructions: custom_instructions)
253
253
  target_leaf = rpc_session.session.append_branch_summary(target_leaf, from_id: previous_leaf, summary: summary, details: {})
254
- else
255
- target_leaf ? rpc_session.session.branch(target_leaf) : rpc_session.session.reset_leaf
254
+ elsif target_leaf
255
+ rpc_session.session.branch(target_leaf)
256
256
  end
257
257
 
258
258
  reload_rpc_session(rpc_session)
@@ -4,11 +4,18 @@ require "json"
4
4
  module Kward
5
5
  # Counts unified-diff additions and deletions for summaries.
6
6
  class SessionDiff
7
- attr_reader :additions, :deletions
8
-
9
7
  def initialize(additions: 0, deletions: 0)
10
- @additions = additions.to_i
11
- @deletions = deletions.to_i
8
+ @base_additions = additions.to_i
9
+ @base_deletions = deletions.to_i
10
+ @file_changes = Hash.new { |changes, path| changes[path] = { removed: [], added: [] } }
11
+ end
12
+
13
+ def additions
14
+ @base_additions + @file_changes.values.sum { |changes| changes[:added].length }
15
+ end
16
+
17
+ def deletions
18
+ @base_deletions + @file_changes.values.sum { |changes| changes[:removed].length }
12
19
  end
13
20
 
14
21
  def self.from_session_file(path)
@@ -65,7 +72,7 @@ module Kward
65
72
  end
66
73
 
67
74
  def empty?
68
- @additions.zero? && @deletions.zero?
75
+ additions.zero? && deletions.zero?
69
76
  end
70
77
 
71
78
  def add_tool_result(content)
@@ -76,11 +83,19 @@ module Kward
76
83
  end
77
84
 
78
85
  def add_diff(diff)
79
- counts = self.class.count(diff)
80
- return false if counts[:additions].zero? && counts[:deletions].zero?
86
+ if self.class.truncated_diff_stats(diff) || self.class.truncated_diff?(diff)
87
+ counts = self.class.count(diff)
88
+ return false if counts[:additions].zero? && counts[:deletions].zero?
81
89
 
82
- @additions += counts[:additions]
83
- @deletions += counts[:deletions]
90
+ @base_additions += counts[:additions]
91
+ @base_deletions += counts[:deletions]
92
+ return true
93
+ end
94
+
95
+ changes = self.class.changed_lines_by_file(diff)
96
+ return false if changes.empty?
97
+
98
+ changes.each { |path, lines| apply_file_change(path, lines) }
84
99
  true
85
100
  end
86
101
 
@@ -113,12 +128,94 @@ module Kward
113
128
  previous.last
114
129
  end
115
130
 
131
+ def self.changed_lines_by_file(diff)
132
+ current_path = nil
133
+ changes = Hash.new { |file_changes, path| file_changes[path] = { removed: [], added: [] } }
134
+ removed = []
135
+ added = []
136
+ flush = lambda do
137
+ unmatched = unmatched_lines(removed, added)
138
+ changes[current_path][:removed].concat(unmatched[:removed]) if current_path
139
+ changes[current_path][:added].concat(unmatched[:added]) if current_path
140
+ removed.clear
141
+ added.clear
142
+ end
143
+
144
+ diff.to_s.each_line do |line|
145
+ if line.start_with?("--- ")
146
+ flush.call
147
+ current_path = line[4..].to_s.chomp
148
+ elsif line.start_with?("+") && !line.start_with?("+++")
149
+ added << line[1..]
150
+ elsif line.start_with?("-") && !line.start_with?("---")
151
+ removed << line[1..]
152
+ else
153
+ flush.call
154
+ end
155
+ end
156
+ flush.call
157
+ changes.reject { |_path, lines| lines[:removed].empty? && lines[:added].empty? }
158
+ end
159
+
160
+ def self.unmatched_lines(left, right)
161
+ matches = common_line_indexes(left, right)
162
+ left_matches = matches.map(&:first)
163
+ right_matches = matches.map(&:last)
164
+ {
165
+ removed: left.each_index.reject { |index| left_matches.include?(index) }.map { |index| left[index] },
166
+ added: right.each_index.reject { |index| right_matches.include?(index) }.map { |index| right[index] }
167
+ }
168
+ end
169
+
170
+ def self.common_line_indexes(left, right)
171
+ lengths = Array.new(left.length + 1) { Array.new(right.length + 1, 0) }
172
+ left.each_with_index do |left_line, left_index|
173
+ right.each_with_index do |right_line, right_index|
174
+ lengths[left_index + 1][right_index + 1] = if left_line == right_line
175
+ lengths[left_index][right_index] + 1
176
+ else
177
+ [lengths[left_index + 1][right_index], lengths[left_index][right_index + 1]].max
178
+ end
179
+ end
180
+ end
181
+
182
+ indexes = []
183
+ left_index = left.length
184
+ right_index = right.length
185
+ while left_index.positive? && right_index.positive?
186
+ if left[left_index - 1] == right[right_index - 1]
187
+ indexes.unshift([left_index - 1, right_index - 1])
188
+ left_index -= 1
189
+ right_index -= 1
190
+ elsif lengths[left_index - 1][right_index] >= lengths[left_index][right_index - 1]
191
+ left_index -= 1
192
+ else
193
+ right_index -= 1
194
+ end
195
+ end
196
+ indexes
197
+ end
198
+
116
199
  def self.parse_record(line)
117
200
  JSON.parse(line)
118
201
  rescue JSON::ParserError
119
202
  nil
120
203
  end
121
204
 
205
+ def apply_file_change(path, lines)
206
+ remove_reverted_lines(@file_changes[path][:added], lines[:removed])
207
+ remove_reverted_lines(@file_changes[path][:removed], lines[:added])
208
+ @file_changes[path][:removed].concat(lines[:removed])
209
+ @file_changes[path][:added].concat(lines[:added])
210
+ end
211
+
212
+ def remove_reverted_lines(previous_lines, current_lines)
213
+ current_lines.delete_if do |line|
214
+ index = previous_lines.index(line)
215
+ previous_lines.delete_at(index) if index
216
+ end
217
+ end
218
+
122
219
  def extract_unified_diff(text)
123
220
  index = text.index(/^--- /)
124
221
  index ? text[index..] : nil
@@ -130,7 +130,8 @@ module Kward
130
130
  return "" if display_indent.to_i <= 0
131
131
 
132
132
  connector_position = show_connector ? display_indent - 1 : -1
133
- (0...(display_indent * 3)).map do |index|
133
+ indentation = " "
134
+ indentation + (0...(display_indent * 3)).map do |index|
134
135
  level = index / 3
135
136
  position = index % 3
136
137
  gutter = gutters.find { |candidate| candidate[:position] == level }
data/lib/kward/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # Namespace for the Kward CLI agent runtime.
2
2
  module Kward
3
3
  # Current gem version.
4
- VERSION = "0.69.1"
4
+ VERSION = "0.70.0"
5
5
  end