kward 0.67.1 → 0.69.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.
Files changed (146) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +48 -0
  3. data/.yardopts +1 -0
  4. data/CHANGELOG.md +54 -0
  5. data/Gemfile.lock +8 -2
  6. data/README.md +37 -30
  7. data/Rakefile +14 -1
  8. data/doc/authentication.md +84 -43
  9. data/doc/code-search.md +55 -28
  10. data/doc/configuration.md +27 -2
  11. data/doc/extensibility.md +90 -129
  12. data/doc/getting-started.md +53 -57
  13. data/doc/memory.md +51 -118
  14. data/doc/personas.md +417 -0
  15. data/doc/plugins.md +55 -99
  16. data/doc/releasing.md +10 -9
  17. data/doc/rpc.md +7 -7
  18. data/doc/usage.md +125 -141
  19. data/doc/web-search.md +80 -14
  20. data/exe/kward +2 -0
  21. data/kward.gemspec +4 -0
  22. data/lib/kward/agent.rb +30 -3
  23. data/lib/kward/ansi.rb +3 -0
  24. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  25. data/lib/kward/auth/file.rb +2 -0
  26. data/lib/kward/auth/github_oauth.rb +3 -0
  27. data/lib/kward/auth/openai_oauth.rb +4 -0
  28. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  29. data/lib/kward/cancellation.rb +3 -0
  30. data/lib/kward/cli/auth_commands.rb +82 -0
  31. data/lib/kward/cli/commands.rb +229 -0
  32. data/lib/kward/cli/compaction.rb +25 -0
  33. data/lib/kward/cli/doctor.rb +121 -0
  34. data/lib/kward/cli/interactive_turn.rb +227 -0
  35. data/lib/kward/cli/memory_commands.rb +133 -0
  36. data/lib/kward/cli/plugins.rb +112 -0
  37. data/lib/kward/cli/prompt_interface.rb +134 -0
  38. data/lib/kward/cli/rendering.rb +378 -0
  39. data/lib/kward/cli/runtime_helpers.rb +170 -0
  40. data/lib/kward/cli/sessions.rb +376 -0
  41. data/lib/kward/cli/settings.rb +669 -0
  42. data/lib/kward/cli/slash_commands.rb +114 -0
  43. data/lib/kward/cli/stats.rb +64 -0
  44. data/lib/kward/cli/sysprompt.rb +57 -0
  45. data/lib/kward/cli/tool_summaries.rb +157 -0
  46. data/lib/kward/cli.rb +52 -2792
  47. data/lib/kward/cli_transcript_formatter.rb +40 -12
  48. data/lib/kward/clipboard.rb +1 -0
  49. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  50. data/lib/kward/compactor.rb +31 -9
  51. data/lib/kward/config_files.rb +78 -34
  52. data/lib/kward/conversation.rb +110 -13
  53. data/lib/kward/events.rb +2 -0
  54. data/lib/kward/export_path.rb +2 -0
  55. data/lib/kward/image_attachments.rb +2 -0
  56. data/lib/kward/markdown_transcript.rb +2 -0
  57. data/lib/kward/memory/manager.rb +144 -14
  58. data/lib/kward/message_access.rb +29 -2
  59. data/lib/kward/message_text.rb +45 -0
  60. data/lib/kward/model/chat_invocation.rb +2 -0
  61. data/lib/kward/model/client.rb +295 -77
  62. data/lib/kward/model/context_overflow.rb +2 -0
  63. data/lib/kward/model/context_usage.rb +14 -10
  64. data/lib/kward/model/model_info.rb +160 -4
  65. data/lib/kward/model/payloads.rb +254 -22
  66. data/lib/kward/model/retry_message.rb +2 -0
  67. data/lib/kward/model/stream_parser.rb +387 -25
  68. data/lib/kward/pan/server.rb +3 -1
  69. data/lib/kward/plugin_registry.rb +12 -0
  70. data/lib/kward/private_file.rb +2 -0
  71. data/lib/kward/prompt_interface/banner.rb +3 -0
  72. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  73. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  74. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  75. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  76. data/lib/kward/prompt_interface/layout.rb +31 -0
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  78. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  80. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  81. data/lib/kward/prompt_interface/screen.rb +186 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  83. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  84. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  85. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  86. data/lib/kward/prompt_interface/transcript_renderer.rb +151 -0
  87. data/lib/kward/prompt_interface.rb +69 -1832
  88. data/lib/kward/prompts/commands.rb +2 -0
  89. data/lib/kward/prompts/templates.rb +3 -0
  90. data/lib/kward/prompts.rb +63 -7
  91. data/lib/kward/question_contract.rb +66 -0
  92. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  93. data/lib/kward/resources/pixel_logo.rb +2 -0
  94. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  95. data/lib/kward/rpc/auth_manager.rb +65 -11
  96. data/lib/kward/rpc/config_manager.rb +11 -0
  97. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  98. data/lib/kward/rpc/redactor.rb +3 -0
  99. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  100. data/lib/kward/rpc/server.rb +43 -11
  101. data/lib/kward/rpc/session_manager.rb +139 -347
  102. data/lib/kward/rpc/session_metrics.rb +68 -0
  103. data/lib/kward/rpc/session_tree.rb +48 -0
  104. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  105. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  106. data/lib/kward/rpc/tool_metadata.rb +3 -0
  107. data/lib/kward/rpc/transcript_normalizer.rb +50 -0
  108. data/lib/kward/rpc/transport.rb +3 -0
  109. data/lib/kward/session_diff.rb +2 -0
  110. data/lib/kward/session_store.rb +154 -25
  111. data/lib/kward/session_trash.rb +1 -0
  112. data/lib/kward/session_tree_renderer.rb +8 -41
  113. data/lib/kward/session_tree_tool_display.rb +56 -0
  114. data/lib/kward/skills/registry.rb +3 -0
  115. data/lib/kward/starter_pack_installer.rb +3 -2
  116. data/lib/kward/steering.rb +2 -0
  117. data/lib/kward/telemetry/logger.rb +3 -0
  118. data/lib/kward/telemetry/stats.rb +3 -0
  119. data/lib/kward/tools/ask_user_question.rb +20 -32
  120. data/lib/kward/tools/base.rb +8 -0
  121. data/lib/kward/tools/code_search.rb +5 -0
  122. data/lib/kward/tools/edit_file.rb +5 -0
  123. data/lib/kward/tools/fetch_content.rb +41 -0
  124. data/lib/kward/tools/fetch_raw.rb +40 -0
  125. data/lib/kward/tools/list_directory.rb +5 -0
  126. data/lib/kward/tools/read_file.rb +5 -0
  127. data/lib/kward/tools/read_skill.rb +5 -0
  128. data/lib/kward/tools/registry.rb +42 -4
  129. data/lib/kward/tools/run_shell_command.rb +5 -0
  130. data/lib/kward/tools/search/code.rb +7 -0
  131. data/lib/kward/tools/search/web.rb +20 -17
  132. data/lib/kward/tools/search/web_fetch.rb +202 -0
  133. data/lib/kward/tools/tool_call.rb +27 -5
  134. data/lib/kward/tools/web_search.rb +7 -1
  135. data/lib/kward/tools/write_file.rb +5 -0
  136. data/lib/kward/transcript_export.rb +2 -0
  137. data/lib/kward/version.rb +2 -1
  138. data/lib/kward/workspace.rb +45 -5
  139. data/templates/default/fulldoc/html/css/kward.css +1501 -0
  140. data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
  141. data/templates/default/fulldoc/html/js/kward.js +296 -0
  142. data/templates/default/fulldoc/html/setup.rb +8 -0
  143. data/templates/default/layout/html/breadcrumb.erb +11 -0
  144. data/templates/default/layout/html/layout.erb +141 -0
  145. data/templates/default/layout/html/setup.rb +139 -0
  146. metadata +56 -1
@@ -0,0 +1,242 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Selection overlay implementation for list-style prompts.
4
+ class PromptInterface
5
+ # Selection-list overlay support for prompt choices.
6
+ module SelectionPrompt
7
+ private
8
+
9
+ def handle_select_key(key)
10
+ return select_current_choice if key.nil?
11
+ return if handle_select_bracketed_paste_key(key)
12
+
13
+ csi_result = handle_select_csi_u_key(key)
14
+ return csi_result unless csi_result == false
15
+
16
+ if key.is_a?(String) && key.length > 1
17
+ token = next_key_token(key)
18
+ if token.length < key.length
19
+ queue_pending_keys(key[token.length..])
20
+ return handle_select_key(token)
21
+ end
22
+ end
23
+
24
+ key_name = @reader.console.keys[key]
25
+ case key_name
26
+ when :return, :enter
27
+ select_current_choice
28
+ when :backspace
29
+ select_delete_before_cursor
30
+ when :delete
31
+ select_delete_at_cursor
32
+ when :left
33
+ self.composer_cursor -= 1 if composer_cursor.positive?
34
+ when :right
35
+ self.composer_cursor += 1 if composer_cursor < composer_input.length
36
+ when :home
37
+ self.composer_cursor = 0
38
+ when :end
39
+ self.composer_cursor = composer_input.length
40
+ when :up
41
+ select_previous_choice
42
+ when :down
43
+ select_next_choice
44
+ else
45
+ case key
46
+ when "\n", "\r"
47
+ select_current_choice
48
+ when "\b", "\x7F"
49
+ select_delete_before_cursor
50
+ when "\e"
51
+ handle_select_escape_sequence
52
+ else
53
+ select_insert_key(key)
54
+ end
55
+ end
56
+ end
57
+
58
+ def handle_select_csi_u_key(key)
59
+ match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
60
+ return false unless match
61
+
62
+ sequence = match[0]
63
+ code = match[1].to_i
64
+ queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
65
+
66
+ case code
67
+ when 13
68
+ select_current_choice
69
+ when 27
70
+ SELECT_CANCEL
71
+ when 8, 127
72
+ select_delete_before_cursor
73
+ nil
74
+ else
75
+ false
76
+ end
77
+ end
78
+
79
+ def handle_select_escape_sequence
80
+ sequence = read_pending_escape_sequence
81
+ return SELECT_CANCEL if sequence.empty?
82
+
83
+ key_name = @reader.console.keys["\e#{sequence}"]
84
+ case key_name
85
+ when :up
86
+ select_previous_choice
87
+ when :down
88
+ select_next_choice
89
+ when :left
90
+ self.composer_cursor -= 1 if composer_cursor.positive?
91
+ when :right
92
+ self.composer_cursor += 1 if composer_cursor < composer_input.length
93
+ end
94
+ true
95
+ end
96
+
97
+ def handle_select_bracketed_paste_key(key)
98
+ text = key.to_s
99
+ return false unless text.start_with?(BRACKETED_PASTE_START)
100
+
101
+ pasted = text[BRACKETED_PASTE_START.length..] || ""
102
+ until pasted.include?(BRACKETED_PASTE_END)
103
+ chunk = @reader.read_keypress(echo: false, raw: true)
104
+ break if chunk.nil?
105
+
106
+ pasted << chunk.to_s
107
+ end
108
+
109
+ content, remaining = pasted.split(BRACKETED_PASTE_END, 2)
110
+ select_insert_string(normalize_paste(content || ""))
111
+ queue_pending_keys(remaining) if remaining && !remaining.empty?
112
+ true
113
+ end
114
+
115
+ def select_current_choice
116
+ selected_selection_choice || custom_selection_choice || SELECT_CANCEL
117
+ end
118
+
119
+ def custom_selection_choice
120
+ return nil unless @select_state && @select_state[:custom]
121
+
122
+ value = composer_input.strip
123
+ value.empty? ? nil : value
124
+ end
125
+
126
+ def selected_selection_choice
127
+ matches = selection_matches
128
+ return nil if matches.empty?
129
+
130
+ matches[selection_index]
131
+ end
132
+
133
+ def select_previous_choice
134
+ matches = selection_matches
135
+ return if matches.empty?
136
+
137
+ @select_state[:selection_index] = (selection_index - 1) % matches.length
138
+ end
139
+
140
+ def select_next_choice
141
+ matches = selection_matches
142
+ return if matches.empty?
143
+
144
+ @select_state[:selection_index] = (selection_index + 1) % matches.length
145
+ end
146
+
147
+ def select_insert_key(key)
148
+ return unless key.is_a?(String) && key.length == 1 && key.match?(/[[:print:]]/)
149
+
150
+ select_insert_string(key)
151
+ end
152
+
153
+ def select_insert_string(string)
154
+ return if string.empty?
155
+
156
+ self.composer_input = composer_input[0...composer_cursor] + string + composer_input[composer_cursor..]
157
+ self.composer_cursor += string.length
158
+ @select_state[:selection_index] = 0 if @select_state
159
+ end
160
+
161
+ def select_delete_before_cursor
162
+ return unless composer_cursor.positive?
163
+
164
+ self.composer_input = composer_input[0...(composer_cursor - 1)] + composer_input[composer_cursor..]
165
+ self.composer_cursor -= 1
166
+ @select_state[:selection_index] = 0 if @select_state
167
+ end
168
+
169
+ def select_delete_at_cursor
170
+ return unless composer_cursor < composer_input.length
171
+
172
+ self.composer_input = composer_input[0...composer_cursor] + composer_input[(composer_cursor + 1)..]
173
+ @select_state[:selection_index] = 0 if @select_state
174
+ end
175
+
176
+ def selection_matches
177
+ choices = @select_state ? @select_state[:choices] : []
178
+ filter = composer_input.downcase.strip
179
+ matches = filter.empty? ? choices : choices.select { |choice| choice.downcase.include?(filter) }
180
+ clamp_selection_index(matches.length)
181
+ matches
182
+ end
183
+
184
+ def selection_index
185
+ @select_state ? @select_state[:selection_index].to_i : 0
186
+ end
187
+
188
+ def clamp_selection_index(count)
189
+ return unless @select_state
190
+
191
+ @select_state[:selection_index] = 0 if count <= 0
192
+ @select_state[:selection_index] = count - 1 if count.positive? && selection_index >= count
193
+ end
194
+
195
+ def finish_select_prompt
196
+ @mutex.synchronize do
197
+ @select_state = nil
198
+ clear_prompt_locked
199
+ self.composer_input = ""
200
+ self.composer_cursor = 0
201
+ @asking = false
202
+ @rendered_rows = 0
203
+ @cursor_rendered_row = 0
204
+ @output_io.flush
205
+ end
206
+ end
207
+
208
+ def selection_overlay_rows(width, height: screen_height)
209
+ matches = selection_matches
210
+ lines = [overlay_text_line("↑/↓ select · Enter open · Esc cancel", :muted), overlay_blank_line]
211
+ if matches.empty?
212
+ if @select_state && @select_state[:custom] && !composer_input.strip.empty?
213
+ lines << overlay_choice_line("Use custom: #{composer_input.strip}", selected: true)
214
+ else
215
+ lines << overlay_text_line("No matches", :muted)
216
+ end
217
+ return overlay_card_rows(selection_overlay_title, lines, width)
218
+ end
219
+
220
+ visible = visible_selection_matches(matches, height: height)
221
+ start_index = visible[:start]
222
+ visible[:choices].each_with_index do |choice, offset|
223
+ index = start_index + offset
224
+ lines << overlay_choice_line(choice, selected: index == selection_index)
225
+ end
226
+ overlay_card_rows(selection_overlay_title, lines, width)
227
+ end
228
+
229
+ def selection_overlay_title
230
+ title = @select_state && @select_state[:title].to_s
231
+ title && !title.empty? ? title : "Sessions"
232
+ end
233
+
234
+ 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
237
+ { start: start, choices: matches[start, max_rows] || [] }
238
+ end
239
+
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,102 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Slash-command completion overlay behavior.
4
+ class PromptInterface
5
+ # Slash-command completion overlay support.
6
+ module SlashOverlay
7
+ private
8
+
9
+ def reset_slash_selection
10
+ @slash_selection_index = 0
11
+ end
12
+
13
+ def dismiss_slash_overlay
14
+ return false unless slash_overlay_visible?
15
+
16
+ @slash_overlay_dismissed_input = composer_input.dup
17
+ reset_slash_selection
18
+ true
19
+ end
20
+
21
+ def normalize_slash_commands(commands)
22
+ commands.map do |command|
23
+ {
24
+ name: slash_command_value(command, :name).to_s,
25
+ description: slash_command_value(command, :description).to_s,
26
+ argument_hint: slash_command_value(command, :argument_hint).to_s
27
+ }
28
+ end.reject { |command| command[:name].empty? }.sort_by { |command| command[:name] }
29
+ end
30
+
31
+ def slash_command_value(command, key)
32
+ return command[key] if command.respond_to?(:key?) && command.key?(key)
33
+ return command[key.to_s] if command.respond_to?(:key?) && command.key?(key.to_s)
34
+ return command.public_send(key) if command.respond_to?(key)
35
+
36
+ ""
37
+ end
38
+
39
+ def slash_overlay_visible?
40
+ composer_input.match?(%r{\A/[^\s/]*\z}) && @slash_overlay_dismissed_input != composer_input && !slash_overlay_matches.empty?
41
+ end
42
+
43
+ def slash_overlay_matches
44
+ prefix = composer_input.delete_prefix("/").downcase
45
+ @slash_commands.select { |command| command[:name].downcase.start_with?(prefix) }.first(8)
46
+ end
47
+
48
+ def selected_slash_command
49
+ return nil unless slash_overlay_visible?
50
+
51
+ matches = slash_overlay_matches
52
+ return nil if matches.empty?
53
+
54
+ matches[[@slash_selection_index, matches.length - 1].min]
55
+ end
56
+
57
+ def select_previous_slash_command
58
+ matches = slash_overlay_matches
59
+ return if matches.empty?
60
+
61
+ @slash_selection_index = (@slash_selection_index - 1) % matches.length
62
+ end
63
+
64
+ def select_next_slash_command
65
+ matches = slash_overlay_matches
66
+ return if matches.empty?
67
+
68
+ @slash_selection_index = (@slash_selection_index + 1) % matches.length
69
+ end
70
+
71
+ def complete_selected_slash_command
72
+ command = selected_slash_command
73
+ return false unless command
74
+
75
+ replace_input("/#{command[:name]} ")
76
+ reset_slash_selection
77
+ true
78
+ end
79
+
80
+ def slash_overlay_rows(width, height: screen_height)
81
+ return [] unless slash_overlay_visible?
82
+
83
+ visible = visible_slash_overlay_matches(slash_overlay_matches, height: height)
84
+ start_index = visible[:start]
85
+ lines = visible[:commands].each_with_index.map do |command, offset|
86
+ index = start_index + offset
87
+ hint = command[:argument_hint].empty? ? "" : " #{command[:argument_hint]}"
88
+ description = command[:description].empty? ? "" : " — #{command[:description]}"
89
+ overlay_choice_line("/#{command[:name]}#{hint}#{description}", selected: index == @slash_selection_index)
90
+ end
91
+ overlay_card_rows("Slash commands", lines, width)
92
+ end
93
+
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
97
+ { start: start, commands: matches[start, max_rows] || [] }
98
+ end
99
+
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,65 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Cursor/column state for streamed assistant blocks.
4
+ class PromptInterface
5
+ # State object for streamed assistant output blocks.
6
+ class StreamState
7
+ attr_reader :block, :col
8
+
9
+ def initialize
10
+ reset
11
+ end
12
+
13
+ def reset
14
+ @block = nil
15
+ @col = 0
16
+ @pending_wrap = false
17
+ end
18
+
19
+ def start_block(label)
20
+ @block = label
21
+ end
22
+
23
+ def finish_block
24
+ @block = nil
25
+ end
26
+
27
+ def pending_wrap?
28
+ @pending_wrap
29
+ end
30
+
31
+ def reset_position_from_rows(rows, width)
32
+ last_length = rows.empty? ? 0 : ANSI.strip(rows.last).length
33
+ if last_length >= width
34
+ @col = 0
35
+ @pending_wrap = true
36
+ else
37
+ @col = last_length
38
+ @pending_wrap = false
39
+ end
40
+ end
41
+
42
+ def clear_pending_wrap
43
+ @col = 0
44
+ @pending_wrap = false
45
+ end
46
+
47
+ def update_position(text, width:)
48
+ ANSI.strip(text).each_char do |char|
49
+ case char
50
+ when "\n", "\r"
51
+ @col = 0
52
+ @pending_wrap = false
53
+ else
54
+ @pending_wrap = false
55
+ @col += 1
56
+ if @col >= width
57
+ @col = 0
58
+ @pending_wrap = true
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,85 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Bounded text buffer for transcript rendering.
4
+ class PromptInterface
5
+ # Bounded in-memory transcript buffer used by the prompt interface.
6
+ class TranscriptBuffer
7
+ attr_reader :text
8
+
9
+ def initialize(limit:)
10
+ @limit = limit
11
+ @text = +""
12
+ @display_rows_cache_width = nil
13
+ @display_rows_cache_banner_count = nil
14
+ @display_rows_cache = nil
15
+ end
16
+
17
+ def to_s
18
+ @text
19
+ end
20
+
21
+ def include?(*arguments)
22
+ @text.include?(*arguments)
23
+ end
24
+
25
+ def empty?
26
+ @text.empty?
27
+ end
28
+
29
+ def end_with?(*suffixes)
30
+ @text.end_with?(*suffixes)
31
+ end
32
+
33
+ def clear
34
+ @text = +""
35
+ invalidate_display_rows_cache
36
+ end
37
+
38
+ def append(text)
39
+ @text << ANSI.sanitize_transcript(text)
40
+ @text = @text[-@limit, @limit] if @text.length > @limit
41
+ invalidate_display_rows_cache
42
+ @text
43
+ end
44
+
45
+ def viewport_text(row_count, width, visual_banner_count:, banner_rows:)
46
+ viewport_rows(row_count, width, visual_banner_count: visual_banner_count, banner_rows: banner_rows).join("\n")
47
+ end
48
+
49
+ def viewport_rows(row_count, width, visual_banner_count:, banner_rows:)
50
+ return [] unless row_count.positive?
51
+
52
+ rows = display_rows(width, visual_banner_count: visual_banner_count, banner_rows: banner_rows).last(row_count)
53
+ rows = ([""] * (row_count - rows.length)) + rows if rows.length < row_count
54
+ rows
55
+ end
56
+
57
+ def display_rows(width, visual_banner_count:, banner_rows:)
58
+ if @display_rows_cache_width == width && @display_rows_cache_banner_count == visual_banner_count && @display_rows_cache
59
+ return @display_rows_cache
60
+ end
61
+
62
+ rows = []
63
+ visual_banner_count.times { rows.concat(banner_rows.call(width)) }
64
+ rows << "" if visual_banner_count.positive? && @text.empty?
65
+ rows.concat(text_display_rows(width))
66
+ @display_rows_cache_width = width
67
+ @display_rows_cache_banner_count = visual_banner_count
68
+ @display_rows_cache = rows
69
+ end
70
+
71
+ def text_display_rows(width)
72
+ @text.split(/\r\n|\r|\n/, -1).flat_map do |line|
73
+ chunks = ANSI.wrap_visible(line, width)
74
+ chunks.empty? ? [""] : chunks
75
+ end
76
+ end
77
+
78
+ def invalidate_display_rows_cache
79
+ @display_rows_cache_width = nil
80
+ @display_rows_cache_banner_count = nil
81
+ @display_rows_cache = nil
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,151 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Terminal transcript rendering helpers.
4
+ class PromptInterface
5
+ # Renderer for transcript entries in the terminal prompt interface.
6
+ module TranscriptRenderer
7
+ private
8
+
9
+ def write_stream_block_locked(label, delta, finish: false)
10
+ with_synchronized_output_locked do
11
+ prepare_transcript_output_locked unless @restoring_transcript
12
+ if label && @stream_state.block != label
13
+ ensure_transcript_block_separator_locked
14
+ write_transcript_text_locked("#{colored("#{transcript_label(label)}>", *label_styles(label))}\n")
15
+ @stream_state.start_block(label)
16
+ end
17
+ write_transcript_text_locked(delta) unless delta.empty?
18
+ write_transcript_text_locked("\n") if finish && @stream_state.block
19
+ @stream_state.finish_block if finish
20
+ restore_composer_cursor_locked unless @restoring_transcript
21
+ end
22
+ @output_io.flush unless @restoring_transcript
23
+ end
24
+
25
+ def write_transcript_text_locked(text)
26
+ append_transcript_buffer(text.to_s)
27
+ remember_transcript_viewport_locked unless text.to_s.empty?
28
+ write_visual_transcript_text_locked(text)
29
+ end
30
+
31
+ def write_visual_transcript_text_locked(text)
32
+ width, height = screen_size
33
+ output_text = terminal_newlines(text.to_s)
34
+ advance_pending_stream_wrap_locked(output_text, width: width, height: height)
35
+ @output_io.print(output_text)
36
+ update_stream_position(output_text, width: width)
37
+ end
38
+
39
+ def append_transcript_buffer(text)
40
+ @transcript_buffer.append(text.to_s)
41
+ end
42
+
43
+ def invalidate_transcript_display_rows_cache
44
+ @transcript_buffer.invalidate_display_rows_cache
45
+ end
46
+
47
+ def ensure_transcript_block_separator_locked
48
+ return if @transcript_buffer.empty? || @transcript_buffer.end_with?("\n\n")
49
+
50
+ write_transcript_text_locked(@transcript_buffer.end_with?("\n") ? "\n" : "\n\n")
51
+ end
52
+
53
+ def terminal_newlines(text)
54
+ text.gsub(/\r\n|\r|\n/, "\r\n")
55
+ end
56
+
57
+ def redraw_transcript_locked(width: screen_width, height: screen_height)
58
+ return unless transcript_renderable?
59
+
60
+ rows = transcript_viewport_rows(transcript_redraw_row_count(height), width)
61
+ clear_screen_rows_locked(1, rows.length)
62
+ return if rows.empty?
63
+
64
+ move_to_screen(1, 1)
65
+ @output_io.print(terminal_newlines(rows.join("\n")))
66
+ end
67
+
68
+ def transcript_viewport_text(row_count, width)
69
+ transcript_viewport_rows(row_count, width).join("\n")
70
+ end
71
+
72
+ def transcript_viewport_rows(row_count, width)
73
+ return [] unless row_count.positive?
74
+
75
+ rows = transcript_display_rows(width).last(row_count)
76
+ rows = ([""] * (row_count - rows.length)) + rows if rows.length < row_count
77
+ rows
78
+ end
79
+
80
+ def remember_transcript_viewport_locked(height = screen_height)
81
+ @transcript_viewport_rows = transcript_bottom_row(height)
82
+ end
83
+
84
+ def transcript_renderable?
85
+ @visual_banner_count.positive? || !@transcript_buffer.empty?
86
+ end
87
+
88
+ def transcript_display_rows(width)
89
+ @transcript_buffer.display_rows(width, visual_banner_count: @visual_banner_count, banner_rows: method(:banner_rows))
90
+ end
91
+
92
+ def transcript_text_display_rows(width)
93
+ @transcript_buffer.text_display_rows(width)
94
+ end
95
+
96
+ def reset_stream_position_from_transcript_locked(width = screen_width)
97
+ @stream_state.reset_position_from_rows(transcript_display_rows(width), width)
98
+ end
99
+
100
+ def move_to_transcript_cursor_locked(width: screen_width, height: screen_height)
101
+ if @stream_state.pending_wrap?
102
+ move_to_screen(transcript_bottom_row(height), width)
103
+ else
104
+ move_to_screen(transcript_bottom_row(height), [@stream_state.col + 1, width].min)
105
+ end
106
+ end
107
+
108
+ def advance_pending_stream_wrap_locked(output_text, width: screen_width, height: screen_height)
109
+ return unless @stream_state.pending_wrap?
110
+ return if output_text.empty? || output_text.start_with?("\r", "\n")
111
+
112
+ move_to_screen(transcript_bottom_row(height), width)
113
+ @output_io.print("\r\n")
114
+ @stream_state.clear_pending_wrap
115
+ end
116
+
117
+ def update_stream_position(text, width: screen_width)
118
+ @stream_state.update_position(text, width: width)
119
+ end
120
+
121
+ def transcript_label(label)
122
+ case label
123
+ when "Assistant"
124
+ @assistant_label
125
+ when "Tool failed"
126
+ "Tool"
127
+ else
128
+ label
129
+ end
130
+ end
131
+
132
+ def label_styles(label)
133
+ case label
134
+ when "Reasoning", "Compaction summary"
135
+ [:gray, :bold]
136
+ when "Assistant", "Kward"
137
+ [:green, :bold]
138
+ when "Tool", "Tool output"
139
+ [:cyan, :bold]
140
+ when "Tool failed"
141
+ [:red, :bold]
142
+ when "Retry"
143
+ [:yellow, :bold]
144
+ else
145
+ [:gray, :bold]
146
+ end
147
+ end
148
+
149
+ end
150
+ end
151
+ end