kward 0.69.0 → 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)
@@ -34,7 +34,12 @@ module Kward
34
34
  multiple_roots = visible_roots.length > 1
35
35
  result = []
36
36
 
37
- walk = lambda do |node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child|
37
+ stack = visible_roots.sort_by { |root| tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index.map do |root, index|
38
+ [root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots]
39
+ end.reverse
40
+
41
+ until stack.empty?
42
+ node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child = stack.pop
38
43
  entry = node[:source]["entry"] || {}
39
44
  entry_id = entry["id"].to_s
40
45
  formatted = tree_entry_display(entry, tool_calls_by_id)
@@ -66,14 +71,10 @@ module Kward
66
71
  end
67
72
  connector_position = [display_indent - 1, 0].max
68
73
  child_gutters = show_connector && !virtual_root_child ? gutters + [{ position: connector_position, show: !is_last }] : gutters
69
- children.each_with_index do |child, index|
70
- walk.call(child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false)
74
+ children.each_with_index.reverse_each do |child, index|
75
+ stack << [child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false]
71
76
  end
72
77
  end
73
-
74
- visible_roots.sort_by { |root| tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index do |root, index|
75
- walk.call(root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots)
76
- end
77
78
  result
78
79
  end
79
80
 
@@ -83,7 +84,9 @@ module Kward
83
84
  by_id = tree_entries_by_id(roots)
84
85
  ids = []
85
86
  current = by_id[leaf_id.to_s]
86
- while current
87
+ seen = {}
88
+ while current && !seen[current["id"].to_s]
89
+ seen[current["id"].to_s] = true
87
90
  ids << current["id"].to_s
88
91
  current = by_id[current["parentId"].to_s]
89
92
  end
@@ -93,8 +96,12 @@ module Kward
93
96
  def tree_entries_by_id(roots)
94
97
  roots.each_with_object({}) do |root, map|
95
98
  stack = [root]
99
+ seen = {}
96
100
  until stack.empty?
97
101
  node = stack.pop
102
+ next if seen[node.object_id]
103
+
104
+ seen[node.object_id] = true
98
105
  entry = node["entry"] || {}
99
106
  map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
100
107
  stack.concat(Array(node["children"]))
@@ -103,10 +110,29 @@ module Kward
103
110
  end
104
111
 
105
112
  def visible_tree_nodes(node)
106
- children = Array(node["children"]).flat_map { |child| visible_tree_nodes(child) }
107
- return children if hidden_tree_entry?(node["entry"] || {})
113
+ results = {}
114
+ stack = [[node, false, {}]]
115
+
116
+ until stack.empty?
117
+ current, visited, seen = stack.pop
118
+ node_key = current.object_id
119
+ next if seen[node_key]
120
+
121
+ if visited
122
+ children = Array(current["children"]).flat_map { |child| results[child.object_id] || [] }
123
+ results[node_key] = if hidden_tree_entry?(current["entry"] || {})
124
+ children
125
+ else
126
+ [{ source: current, children: children }]
127
+ end
128
+ else
129
+ branch_seen = seen.merge(node_key => true)
130
+ stack << [current, true, seen]
131
+ Array(current["children"]).reverse_each { |child| stack << [child, false, branch_seen] unless branch_seen[child.object_id] }
132
+ end
133
+ end
108
134
 
109
- [{ source: node, children: children }]
135
+ results[node.object_id] || []
110
136
  end
111
137
 
112
138
  def hidden_tree_entry?(entry)
@@ -126,15 +152,30 @@ module Kward
126
152
  end
127
153
 
128
154
  def tree_contains_active_path?(node, active_path)
129
- entry_id = (node[:source]["entry"] || {})["id"].to_s
130
- active_path.include?(entry_id) || node[:children].any? { |child| tree_contains_active_path?(child, active_path) }
155
+ stack = [node]
156
+ seen = {}
157
+ until stack.empty?
158
+ current = stack.pop
159
+ next if seen[current.object_id]
160
+
161
+ seen[current.object_id] = true
162
+ entry_id = (current[:source]["entry"] || {})["id"].to_s
163
+ return true if active_path.include?(entry_id)
164
+
165
+ stack.concat(current[:children])
166
+ end
167
+ false
131
168
  end
132
169
 
133
170
  def tree_tool_calls(roots)
134
171
  roots.each_with_object({}) do |root, tool_calls_by_id|
135
172
  stack = [root]
173
+ seen = {}
136
174
  until stack.empty?
137
175
  node = stack.pop
176
+ next if seen[node.object_id]
177
+
178
+ seen[node.object_id] = true
138
179
  entry = node["entry"] || {}
139
180
  message = entry["message"]
140
181
  if entry["type"] == "message" && message.is_a?(Hash) && MessageAccess.role(message) == "assistant"
@@ -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
@@ -592,7 +592,11 @@ module Kward
592
592
  next unless node
593
593
 
594
594
  parent = nodes[entry["parentId"].to_s]
595
- parent ? parent["children"] << node : roots << node
595
+ if parent && !parent.equal?(node)
596
+ parent["children"] << node unless parent["children"].include?(node)
597
+ else
598
+ roots << node unless roots.include?(node)
599
+ end
596
600
  end
597
601
  roots
598
602
  end
@@ -19,7 +19,12 @@ module Kward
19
19
  multiple_roots = visible_roots.length > 1
20
20
  result = []
21
21
 
22
- walk = lambda do |node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child|
22
+ stack = visible_roots.sort_by { |root| session_tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index.map do |root, index|
23
+ [root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots]
24
+ end.reverse
25
+
26
+ until stack.empty?
27
+ node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child = stack.pop
23
28
  entry = node[:source]["entry"] || {}
24
29
  display_indent = multiple_roots ? [indent - 1, 0].max : indent
25
30
  prefix = session_tree_visual_prefix(display_indent, gutters, show_connector && !virtual_root_child, is_last, !node[:children].empty?)
@@ -40,25 +45,40 @@ module Kward
40
45
  connector_position = [display_indent - 1, 0].max
41
46
  child_gutters = show_connector && !virtual_root_child ? gutters + [{ position: connector_position, show: !is_last }] : gutters
42
47
 
43
- children.each_with_index do |child, index|
44
- walk.call(child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false)
48
+ children.each_with_index.reverse_each do |child, index|
49
+ stack << [child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false]
45
50
  end
46
51
  end
47
52
 
48
- visible_roots.sort_by { |root| session_tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index do |root, index|
49
- walk.call(root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots)
50
- end
51
-
52
53
  result
53
54
  end
54
55
 
55
56
  private
56
57
 
57
58
  def visible_session_tree_nodes(node)
58
- children = Array(node["children"]).flat_map { |child| visible_session_tree_nodes(child) }
59
- return children if hidden_session_tree_entry?(node["entry"] || {})
59
+ results = {}
60
+ stack = [[node, false, {}]]
61
+
62
+ until stack.empty?
63
+ current, visited, seen = stack.pop
64
+ node_key = current.object_id
65
+ next if seen[node_key]
66
+
67
+ if visited
68
+ children = Array(current["children"]).flat_map { |child| results[child.object_id] || [] }
69
+ results[node_key] = if hidden_session_tree_entry?(current["entry"] || {})
70
+ children
71
+ else
72
+ [{ source: current, children: children }]
73
+ end
74
+ else
75
+ branch_seen = seen.merge(node_key => true)
76
+ stack << [current, true, seen]
77
+ Array(current["children"]).reverse_each { |child| stack << [child, false, branch_seen] unless branch_seen[child.object_id] }
78
+ end
79
+ end
60
80
 
61
- [{ source: node, children: children }]
81
+ results[node.object_id] || []
62
82
  end
63
83
 
64
84
  def hidden_session_tree_entry?(entry)
@@ -110,7 +130,8 @@ module Kward
110
130
  return "" if display_indent.to_i <= 0
111
131
 
112
132
  connector_position = show_connector ? display_indent - 1 : -1
113
- (0...(display_indent * 3)).map do |index|
133
+ indentation = " "
134
+ indentation + (0...(display_indent * 3)).map do |index|
114
135
  level = index / 3
115
136
  position = index % 3
116
137
  gutter = gutters.find { |candidate| candidate[:position] == level }
@@ -132,15 +153,28 @@ module Kward
132
153
  end
133
154
 
134
155
  def session_tree_contains_active_path?(node, active_path)
135
- entry_id = (node[:source]["entry"] || {})["id"].to_s
136
- active_path.include?(entry_id) || node[:children].any? { |child| session_tree_contains_active_path?(child, active_path) }
156
+ stack = [node]
157
+ seen = {}
158
+ until stack.empty?
159
+ current = stack.pop
160
+ next if seen[current.object_id]
161
+
162
+ seen[current.object_id] = true
163
+ entry_id = (current[:source]["entry"] || {})["id"].to_s
164
+ return true if active_path.include?(entry_id)
165
+
166
+ stack.concat(current[:children])
167
+ end
168
+ false
137
169
  end
138
170
 
139
171
  def session_tree_active_path(roots, leaf_id)
140
172
  by_id = session_tree_entries_by_id(roots)
141
173
  ids = []
142
174
  entry = by_id[leaf_id.to_s]
143
- while entry
175
+ seen = {}
176
+ while entry && !seen[entry["id"].to_s]
177
+ seen[entry["id"].to_s] = true
144
178
  ids << entry["id"].to_s
145
179
  entry = by_id[entry["parentId"].to_s]
146
180
  end
@@ -150,8 +184,12 @@ module Kward
150
184
  def session_tree_entries_by_id(roots)
151
185
  roots.each_with_object({}) do |root, map|
152
186
  stack = [root]
187
+ seen = {}
153
188
  until stack.empty?
154
189
  node = stack.pop
190
+ next if seen[node.object_id]
191
+
192
+ seen[node.object_id] = true
155
193
  entry = node["entry"] || {}
156
194
  map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
157
195
  stack.concat(Array(node["children"]))
@@ -162,8 +200,12 @@ module Kward
162
200
  def session_tree_tool_calls(roots)
163
201
  roots.each_with_object({}) do |root, tool_calls|
164
202
  stack = [root]
203
+ seen = {}
165
204
  until stack.empty?
166
205
  node = stack.pop
206
+ next if seen[node.object_id]
207
+
208
+ seen[node.object_id] = true
167
209
  entry = node["entry"] || {}
168
210
  message = entry["message"]
169
211
  if entry["type"] == "message" && message.is_a?(Hash) && message_role(message) == "assistant"
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.0"
4
+ VERSION = "0.70.0"
5
5
  end