kward 0.72.0 → 0.73.1

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +30 -0
  3. data/CHANGELOG.md +59 -0
  4. data/Gemfile.lock +2 -2
  5. data/doc/configuration.md +1 -1
  6. data/doc/editor.md +23 -2
  7. data/doc/git.md +1 -0
  8. data/doc/rpc.md +2 -2
  9. data/doc/shell.md +56 -10
  10. data/doc/usage.md +27 -1
  11. data/lib/kward/ansi.rb +62 -23
  12. data/lib/kward/cli/plugins.rb +1 -1
  13. data/lib/kward/cli/rendering.rb +4 -1
  14. data/lib/kward/cli/runtime_helpers.rb +141 -7
  15. data/lib/kward/cli/settings.rb +0 -1
  16. data/lib/kward/cli/slash_commands.rb +213 -0
  17. data/lib/kward/cli/tabs.rb +34 -4
  18. data/lib/kward/cli/tool_summaries.rb +6 -0
  19. data/lib/kward/cli.rb +4 -12
  20. data/lib/kward/clipboard.rb +2 -3
  21. data/lib/kward/compactor.rb +7 -19
  22. data/lib/kward/config_files.rb +26 -4
  23. data/lib/kward/ekwsh.rb +239 -42
  24. data/lib/kward/image_attachments.rb +3 -1
  25. data/lib/kward/interactive_pty_runner.rb +151 -0
  26. data/lib/kward/local_command_runner.rb +155 -0
  27. data/lib/kward/local_pty_command_runner.rb +171 -0
  28. data/lib/kward/model/context_usage.rb +2 -2
  29. data/lib/kward/model/payloads.rb +2 -5
  30. data/lib/kward/prompt_history.rb +5 -3
  31. data/lib/kward/prompt_interface/editor/auto_indent.rb +5 -4
  32. data/lib/kward/prompt_interface/editor/controller.rb +262 -62
  33. data/lib/kward/prompt_interface/editor/modes/emacs.rb +21 -21
  34. data/lib/kward/prompt_interface/editor/modes/modern.rb +38 -37
  35. data/lib/kward/prompt_interface/editor/modes/vibe.rb +23 -173
  36. data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
  37. data/lib/kward/prompt_interface/editor/renderer.rb +6 -5
  38. data/lib/kward/prompt_interface/editor/state.rb +28 -6
  39. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +5 -3
  40. data/lib/kward/prompt_interface/git_prompt.rb +12 -23
  41. data/lib/kward/prompt_interface/interactive/controller.rb +1 -1
  42. data/lib/kward/prompt_interface/key_handler.rb +93 -51
  43. data/lib/kward/prompt_interface/question_prompt.rb +1 -6
  44. data/lib/kward/prompt_interface/screen.rb +3 -3
  45. data/lib/kward/prompt_interface/selection_prompt.rb +12 -6
  46. data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
  47. data/lib/kward/prompt_interface.rb +87 -221
  48. data/lib/kward/prompts/commands.rb +4 -0
  49. data/lib/kward/rpc/memory_methods.rb +83 -0
  50. data/lib/kward/rpc/server.rb +130 -83
  51. data/lib/kward/rpc/session_manager.rb +10 -74
  52. data/lib/kward/rpc/tool_metadata.rb +11 -0
  53. data/lib/kward/rpc/transcript_normalizer.rb +4 -39
  54. data/lib/kward/scratchpad_runner.rb +56 -0
  55. data/lib/kward/session_diff.rb +20 -3
  56. data/lib/kward/session_naming.rb +11 -0
  57. data/lib/kward/terminal_keys.rb +84 -0
  58. data/lib/kward/terminal_sequences.rb +42 -0
  59. data/lib/kward/tools/context_for_task.rb +2 -0
  60. data/lib/kward/version.rb +1 -1
  61. data/lib/kward/workers/git_guard.rb +25 -0
  62. data/lib/kward/workers/job.rb +99 -0
  63. data/lib/kward/workers/queue_runner.rb +166 -0
  64. data/lib/kward/workers/queue_store.rb +112 -0
  65. data/lib/kward/workers.rb +3 -0
  66. data/lib/kward/workspace.rb +15 -63
  67. data/templates/default/fulldoc/html/css/kward.css +33 -0
  68. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  69. data/templates/default/fulldoc/html/setup.rb +1 -0
  70. data/templates/default/layout/html/layout.erb +19 -32
  71. metadata +15 -1
@@ -125,15 +125,72 @@ module Kward
125
125
  shell = tab&.shell || build_ekwsh(agent)
126
126
  tab.shell = shell if tab
127
127
  runtime_output("Entering ekwsh. Type exit or press Ctrl+D on an empty prompt to return.") if entering
128
- run_ekwsh_loop(shell, tab: tab)
128
+ run_ekwsh_loop(shell, tab: tab, history: build_ekwsh_history(agent))
129
129
  end
130
130
 
131
131
  def build_ekwsh(agent)
132
132
  config = ConfigFiles.read_ekwsh_config
133
- Ekwsh.new(cwd: interactive_workspace_root(agent), configured_env: config[:env], aliases: config[:aliases])
133
+ Ekwsh.new(
134
+ cwd: interactive_workspace_root(agent),
135
+ configured_env: config[:env],
136
+ aliases: config[:aliases],
137
+ shell: config[:shell],
138
+ timeout_seconds: config[:timeout_seconds],
139
+ max_output_bytes: config[:max_output_bytes]
140
+ )
141
+ end
142
+
143
+ def build_ekwsh_history(agent)
144
+ config = ConfigFiles.read_ekwsh_config
145
+ PromptHistory.new(
146
+ cwd: interactive_workspace_root(agent),
147
+ limit: config[:history_limit],
148
+ kind: "shell"
149
+ )
150
+ end
151
+
152
+ def run_interactive_pty_command(command, agent)
153
+ command = command.to_s.strip
154
+ if command.empty?
155
+ runtime_output("Usage: /pty <command>")
156
+ return
157
+ end
158
+
159
+ config = ConfigFiles.read_ekwsh_config
160
+ env = interactive_pty_environment(config[:env])
161
+ cwd = interactive_workspace_root(agent)
162
+ @prompt.say("$ #{command}\n[interactive PTY session started]\n") if @prompt.respond_to?(:say)
163
+ result = run_interactive_pty_with_terminal_handoff(config[:shell], command, env: env, cwd: cwd)
164
+ @prompt.say("[interactive PTY session exited with status #{result.exit_status}]\n") if @prompt.respond_to?(:say)
165
+ rescue Errno::ENOENT => e
166
+ runtime_output("Error: #{e.message}")
167
+ end
168
+
169
+ def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:)
170
+ runner = InteractivePtyRunner.new
171
+ if @prompt.respond_to?(:with_terminal_handoff)
172
+ @prompt.with_terminal_handoff do |input, output|
173
+ runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, output: output)
174
+ end
175
+ else
176
+ runner.run(shell, "-c", command, env: env, cwd: cwd)
177
+ end
134
178
  end
135
179
 
136
- def run_ekwsh_loop(shell, tab: nil)
180
+ def interactive_pty_environment(configured_env)
181
+ ENV.to_h.merge(configured_env.to_h.transform_keys(&:to_s).transform_values(&:to_s)).tap do |env|
182
+ env.delete("GIT_PAGER") if env["GIT_PAGER"] == "cat"
183
+ env["TERM"] = "xterm-256color" if env["TERM"].to_s.empty? || env["TERM"] == "dumb"
184
+ end
185
+ end
186
+
187
+ def run_ekwsh_loop(shell, tab: nil, history: nil)
188
+ with_ekwsh_history(history) do
189
+ run_ekwsh_loop_with_history(shell, tab: tab)
190
+ end
191
+ end
192
+
193
+ def run_ekwsh_loop_with_history(shell, tab: nil)
137
194
  loop do
138
195
  if @prompt.respond_to?(:editing_file?) && @prompt.editing_file?
139
196
  editor_result = @prompt.run_editor
@@ -152,13 +209,19 @@ module Kward
152
209
 
153
210
  result = run_ekwsh_command(shell, input)
154
211
  @prompt.clear_transcript if result.clear && @prompt.respond_to?(:clear_transcript)
155
- @prompt.say(result.output) unless result.output.to_s.empty?
212
+ @prompt.say(result.output) unless result.streamed || result.interactive_command || result.output.to_s.empty?
213
+ return :tab_action if pending_tab_action?
214
+
156
215
  if result.open_editor_path
157
216
  editor_result = open_ekwsh_editor(result.open_editor_path, shell)
158
217
  return :tab_action if editor_result == :tab_action
159
218
 
160
219
  next
161
220
  end
221
+ if result.interactive_command
222
+ run_ekwsh_interactive_pty_command(shell, result)
223
+ next
224
+ end
162
225
  if result.exit_shell
163
226
  tab.shell = nil if tab
164
227
  runtime_output("Shell exited.")
@@ -170,6 +233,14 @@ module Kward
170
233
  :exited
171
234
  end
172
235
 
236
+ def with_ekwsh_history(history)
237
+ if history && @prompt.respond_to?(:with_prompt_history)
238
+ @prompt.with_prompt_history(history) { yield }
239
+ else
240
+ yield
241
+ end
242
+ end
243
+
173
244
  def open_ekwsh_editor(path, shell)
174
245
  unless @prompt.respond_to?(:edit_file)
175
246
  runtime_output("Integrated editor is unavailable in this prompt.")
@@ -188,21 +259,82 @@ module Kward
188
259
  def ask_ekwsh(shell)
189
260
  provider = ->(input, cursor) { shell.complete(input, cursor) }
190
261
  if @prompt.respond_to?(:with_completion_provider)
191
- @prompt.with_completion_provider(provider) { @prompt.ask(shell.prompt_label) }
262
+ @prompt.with_completion_provider(provider, slash_overlay: false) { @prompt.ask(shell.prompt_label) }
192
263
  else
193
264
  @prompt.ask(shell.prompt_label)
194
265
  end
195
266
  end
196
267
 
268
+ def run_ekwsh_interactive_pty_command(shell, result)
269
+ @prompt.say(result.output) unless result.output.to_s.empty?
270
+ pty_result = run_interactive_pty_with_terminal_handoff(
271
+ shell.command_shell,
272
+ result.interactive_command,
273
+ env: shell.child_env(interactive: true),
274
+ cwd: shell.cwd
275
+ )
276
+ @prompt.say("[interactive PTY session exited with status #{pty_result.exit_status}]\n") if @prompt.respond_to?(:say)
277
+ end
278
+
197
279
  def run_ekwsh_command(shell, input)
198
280
  if @prompt.respond_to?(:begin_busy_input)
199
281
  @prompt.begin_busy_input(shell.prompt_label, activity: "running")
200
282
  end
201
- shell.run(input)
283
+ if @prompt.respond_to?(:write_transcript_delta) && @prompt.respond_to?(:poll_input)
284
+ run_streaming_ekwsh_command(shell, input)
285
+ elsif @prompt.respond_to?(:write_transcript_delta)
286
+ shell.run(input) { |chunk| @prompt.write_transcript_delta(chunk) }
287
+ else
288
+ shell.run(input)
289
+ end
202
290
  ensure
203
291
  @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
204
292
  end
205
293
 
294
+ def run_streaming_ekwsh_command(shell, input)
295
+ cancellation = Cancellation.new
296
+ chunks = Queue.new
297
+ queued_inputs = []
298
+ result = nil
299
+ error = nil
300
+ worker = Thread.new do
301
+ result = shell.run(input, cancellation: cancellation) { |chunk| chunks << chunk }
302
+ rescue StandardError => e
303
+ error = e
304
+ end
305
+ worker.report_on_exception = false
306
+
307
+ while worker.alive?
308
+ drain_ekwsh_chunks(chunks)
309
+ poll_result = collect_queued_input(queued_inputs)
310
+ if poll_result == PromptInterface::CANCEL_INPUT
311
+ cancellation.cancel!
312
+ elsif poll_result.is_a?(Hash) && poll_result[:tab_action]
313
+ (@pending_inputs ||= []).unshift(poll_result)
314
+ cancellation.cancel!
315
+ end
316
+ sleep 0.01
317
+ end
318
+ worker.join
319
+ drain_ekwsh_chunks(chunks)
320
+ raise error if error
321
+
322
+ queued_inputs.reverse_each { |pending_input| (@pending_inputs ||= []).unshift(pending_input) }
323
+ result
324
+ end
325
+
326
+ def drain_ekwsh_chunks(chunks)
327
+ loop do
328
+ @prompt.write_transcript_delta(chunks.pop(true))
329
+ rescue ThreadError
330
+ break
331
+ end
332
+ end
333
+
334
+ def pending_tab_action?
335
+ @pending_inputs&.first.is_a?(Hash) && @pending_inputs.first[:tab_action]
336
+ end
337
+
206
338
  def configured_workspace(root: current_workspace_root)
207
339
  Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
208
340
  end
@@ -279,7 +411,9 @@ module Kward
279
411
  def refresh_conversation_runtime(conversation, reasoning_effort: current_reasoning_effort, refresh_system_message: true)
280
412
  return unless conversation&.respond_to?(:update_runtime_context!)
281
413
 
414
+ runtime_changed = [conversation.provider, conversation.model, conversation.reasoning_effort] != [current_model_provider, current_model_id, reasoning_effort]
282
415
  conversation.update_runtime_context!(provider: current_model_provider, model: current_model_id, reasoning_effort: reasoning_effort, refresh: refresh_system_message)
416
+ conversation.persist_runtime_context! if runtime_changed && conversation.respond_to?(:persist_runtime_context!)
283
417
  update_assistant_prompt(conversation)
284
418
  end
285
419
 
@@ -292,7 +426,7 @@ module Kward
292
426
  end
293
427
 
294
428
  def default_session_name(input)
295
- input.to_s.gsub(/\s+/, " ").strip.slice(0, 120).to_s
429
+ SessionNaming.default_name(input)
296
430
  end
297
431
 
298
432
  end
@@ -734,7 +734,6 @@ module Kward
734
734
  refresh_reasoning_status
735
735
  else
736
736
  refresh_conversation_runtime(conversation, reasoning_effort: effort)
737
- conversation.persist_runtime_context! if conversation&.respond_to?(:persist_runtime_context!)
738
737
  @prompt.redraw if @prompt.respond_to?(:redraw)
739
738
  end
740
739
  end
@@ -25,12 +25,21 @@ module Kward
25
25
  when "git"
26
26
  handle_git_command(agent)
27
27
  [true, nil]
28
+ when "diff"
29
+ open_session_diff
30
+ [true, nil]
28
31
  when "files"
29
32
  open_project_files_browser
30
33
  [true, nil]
31
34
  when "shell"
32
35
  run_ekwsh(agent)
33
36
  [true, nil]
37
+ when "scratchpad"
38
+ open_scratchpad_command(argument)
39
+ [true, nil]
40
+ when "pty"
41
+ run_interactive_pty_command(argument, agent)
42
+ [true, nil]
34
43
  when "workers"
35
44
  unless experimental_workers_enabled?
36
45
  runtime_output("Workers are experimental. Start Kward with --experimental-workers to enable /workers.")
@@ -38,6 +47,13 @@ module Kward
38
47
  end
39
48
 
40
49
  [true, handle_workers_command(argument, agent, session_store)]
50
+ when "queue"
51
+ unless experimental_workers_enabled?
52
+ runtime_output("Worker queues are experimental. Start Kward with --experimental-workers to enable /queue.")
53
+ return [true, nil]
54
+ end
55
+
56
+ [true, handle_worker_queue_command(argument, agent, session_store)]
41
57
  when "tab"
42
58
  [true, handle_tab_command(argument, session_store)]
43
59
  when "settings"
@@ -125,6 +141,44 @@ module Kward
125
141
  PromptCommands.parse(command) || [nil, ""]
126
142
  end
127
143
 
144
+ def open_session_diff
145
+ unless @active_session&.path
146
+ runtime_output("No active persisted session.")
147
+ return
148
+ end
149
+
150
+ content = SessionDiff.content_from_session_file(@active_session.path)
151
+ if content.empty?
152
+ runtime_output("No file changes recorded in this session.")
153
+ return
154
+ end
155
+
156
+ if @prompt.respond_to?(:open_modal_diff_viewer)
157
+ @prompt.open_modal_diff_viewer("Session diff", content)
158
+ elsif @prompt.respond_to?(:open_diff_viewer)
159
+ @prompt.open_diff_viewer("Session diff", content)
160
+ else
161
+ runtime_output(content)
162
+ end
163
+ end
164
+
165
+ def open_scratchpad_command(argument)
166
+ if @prompt.respond_to?(:scratchpad)
167
+ @prompt.scratchpad(scratchpad_language_argument(argument))
168
+ else
169
+ runtime_output("The scratchpad is only available in the interactive prompt.")
170
+ end
171
+ end
172
+
173
+ def scratchpad_language_argument(argument)
174
+ value = argument.to_s.strip.downcase
175
+ return :text if value.empty? || value == "text"
176
+ return :markdown if ["markdown", "md"].include?(value)
177
+ return :ruby if ["ruby", "rb"].include?(value)
178
+
179
+ :text
180
+ end
181
+
128
182
  def open_project_files_browser
129
183
  if @prompt.respond_to?(:open_project_browser)
130
184
  @prompt.open_project_browser
@@ -164,6 +218,165 @@ module Kward
164
218
  nil
165
219
  end
166
220
 
221
+ def handle_worker_queue_command(argument, agent, session_store)
222
+ action, value = argument.to_s.strip.split(/\s+/, 2)
223
+ case action
224
+ when nil, "", "status", "list"
225
+ show_worker_queue
226
+ when "add", "enqueue"
227
+ enqueue_active_tab(value, agent)
228
+ when "open", "show"
229
+ open_worker_queue_job(session_store, value)
230
+ when "run", "start"
231
+ run_worker_queue(session_store)
232
+ when "resume"
233
+ value.to_s.strip.empty? ? run_worker_queue(session_store) : resume_worker_queue_job(session_store, value)
234
+ when "suspend", "pause"
235
+ suspend_worker_queue_job(session_store, value)
236
+ when "next"
237
+ run_next_worker_queue_job(session_store)
238
+ else
239
+ runtime_output("Usage: /queue [add|list|status|run|suspend <id>|resume <id>]")
240
+ end
241
+ end
242
+
243
+ def worker_queue_store
244
+ @worker_queue_store ||= Workers::QueueStore.new
245
+ end
246
+
247
+ def enqueue_active_tab(title, agent)
248
+ session = @active_session
249
+ unless session&.path
250
+ runtime_output("No active persisted session to queue.")
251
+ return
252
+ end
253
+
254
+ job = worker_queue_store.enqueue(
255
+ title: worker_queue_title(title, session, agent),
256
+ session_path: session.path,
257
+ workspace_root: session.cwd || current_workspace_root
258
+ )
259
+ runtime_output("Queued worker #{job.id}: #{job.title}")
260
+ end
261
+
262
+ def worker_queue_title(title, session, agent)
263
+ explicit = title.to_s.strip
264
+ return explicit unless explicit.empty?
265
+
266
+ session_name = session&.name.to_s.strip
267
+ return session_name unless session_name.empty?
268
+
269
+ last_user = if agent&.respond_to?(:conversation)
270
+ agent.conversation.messages.reverse.find { |message| MessageAccess.role(message) == "user" }
271
+ end
272
+ content = MessageAccess.content(last_user).to_s.strip.gsub(/\s+/, " ")
273
+ content.empty? ? "Queued worker" : content[0, 80]
274
+ end
275
+
276
+ def open_worker_queue_job(session_store, id)
277
+ unless session_store
278
+ runtime_output("Worker queue requires persisted sessions.")
279
+ return nil
280
+ end
281
+
282
+ id = id.to_s.strip
283
+ return runtime_output("Usage: /queue open <id>") if id.empty?
284
+
285
+ record = worker_queue_store.find(id)
286
+ return runtime_output("Unknown worker job: #{id}") unless record
287
+
288
+ load_session(session_store, record.fetch("session_path"), message: "Showing queued worker #{record.fetch('id')}")
289
+ end
290
+
291
+ def run_worker_queue(session_store)
292
+ unless session_store
293
+ runtime_output("Worker queue requires persisted sessions.")
294
+ return
295
+ end
296
+
297
+ results = worker_queue_runner(session_store).run_all
298
+ if results.empty?
299
+ runtime_output("Worker queue has no queued jobs.")
300
+ return
301
+ end
302
+
303
+ summary = results.map { |record| "#{record.fetch('id')} #{record.fetch('status')}" }.join(", ")
304
+ runtime_output("Worker queue run finished: #{summary}")
305
+ end
306
+
307
+ def run_next_worker_queue_job(session_store)
308
+ unless session_store
309
+ runtime_output("Worker queue requires persisted sessions.")
310
+ return
311
+ end
312
+
313
+ record = worker_queue_runner(session_store).run_next
314
+ if record
315
+ runtime_output("Worker #{record.fetch('id')} finished with status #{record.fetch('status')}.")
316
+ else
317
+ runtime_output("Worker queue has no queued jobs.")
318
+ end
319
+ end
320
+
321
+ def suspend_worker_queue_job(session_store, id)
322
+ unless session_store
323
+ runtime_output("Worker queue requires persisted sessions.")
324
+ return
325
+ end
326
+
327
+ id = id.to_s.strip
328
+ return runtime_output("Usage: /queue suspend <id>") if id.empty?
329
+
330
+ record = worker_queue_runner(session_store).suspend(id)
331
+ runtime_output("Worker #{record.fetch('id')} suspended.")
332
+ end
333
+
334
+ def resume_worker_queue_job(session_store, id)
335
+ unless session_store
336
+ runtime_output("Worker queue requires persisted sessions.")
337
+ return
338
+ end
339
+
340
+ id = id.to_s.strip
341
+ return runtime_output("Usage: /queue resume <id>") if id.empty?
342
+
343
+ record = worker_queue_runner(session_store).resume(id)
344
+ runtime_output("Worker #{record.fetch('id')} finished with status #{record.fetch('status')}.")
345
+ end
346
+
347
+ def worker_queue_runner(session_store)
348
+ Workers::QueueRunner.new(
349
+ queue_store: worker_queue_store,
350
+ session_store: session_store,
351
+ client_factory: -> { Client.new },
352
+ prompt: @prompt,
353
+ workspace_root: current_workspace_root,
354
+ provider: current_model_provider,
355
+ model: current_model_id,
356
+ reasoning_effort: current_reasoning_effort,
357
+ write_lock: (@worker_write_lock ||= Workers::WriteLock.new)
358
+ )
359
+ end
360
+
361
+ def show_worker_queue
362
+ jobs = worker_queue_store.list
363
+ if jobs.empty?
364
+ runtime_output("Worker queue is empty.")
365
+ return
366
+ end
367
+
368
+ lines = ["Worker queue:"]
369
+ jobs.each do |job|
370
+ details = [job.fetch("id"), "[#{job.fetch('status')}]"]
371
+ details << "##{job['position']}" if job["position"]
372
+ details << job.fetch("title")
373
+ details << "commit #{job['commit_sha']}" unless job["commit_sha"].to_s.empty?
374
+ details << "error: #{job['error']}" unless job["error"].to_s.empty?
375
+ lines << "- #{details.join(' ')}"
376
+ end
377
+ runtime_output(lines.join("\n"))
378
+ end
379
+
167
380
  def handle_workers_command(argument, agent, session_store)
168
381
  action, value = argument.to_s.strip.split(/\s+/, 2)
169
382
  replacement_agent = case action
@@ -27,6 +27,7 @@ module Kward
27
27
  :unread,
28
28
  :pending_question,
29
29
  :shell,
30
+ :error_reported,
30
31
  keyword_init: true
31
32
  ) do
32
33
  def running?
@@ -148,7 +149,8 @@ module Kward
148
149
  label: label,
149
150
  unread: false,
150
151
  pending_question: nil,
151
- shell: nil
152
+ shell: nil,
153
+ error_reported: false
152
154
  ).tap { |tab| assign_tab_question_prompt(agent, tab) }
153
155
  end
154
156
 
@@ -271,6 +273,7 @@ module Kward
271
273
  tab.error = nil
272
274
  tab.answer = nil
273
275
  tab.unread = false
276
+ tab.error_reported = false
274
277
  tab.event_history.clear
275
278
  tab.seen_events = 0
276
279
  tab.queued_inputs.clear
@@ -326,7 +329,7 @@ module Kward
326
329
  else
327
330
  render_conversation_transcript(tab.agent.conversation)
328
331
  end
329
- render_tab_error(tab) if tab.status == "failed"
332
+ report_tab_runtime_error(tab) if %w[failed cancelled].include?(tab.status.to_s)
330
333
  end
331
334
  restore_tab_composer_snapshot(tab.snapshot)
332
335
  end
@@ -344,8 +347,32 @@ module Kward
344
347
  end
345
348
  end
346
349
 
347
- def render_tab_error(tab)
348
- runtime_output("Tab #{active_tab_number} error: #{tab.error}") unless tab.error.to_s.empty?
350
+ def report_tab_runtime_error(tab)
351
+ return if tab.error_reported
352
+
353
+ message = tab_runtime_error_message(tab)
354
+ return if message.empty?
355
+
356
+ tab.error_reported = true
357
+ runtime_output(message)
358
+ end
359
+
360
+ def tab_runtime_error_message(tab)
361
+ number = tab_number(tab)
362
+ case tab.status.to_s
363
+ when "failed"
364
+ error = tab.error.to_s.strip
365
+ error.empty? ? "Tab #{number} error." : "Tab #{number} error: #{error}"
366
+ when "cancelled"
367
+ "Tab #{number} cancelled."
368
+ else
369
+ ""
370
+ end
371
+ end
372
+
373
+ def tab_number(tab)
374
+ index = @tabs&.index(tab)
375
+ index ? index + 1 : active_tab_number
349
376
  end
350
377
 
351
378
  def submit_tab_input(tab, input, display_input: nil)
@@ -366,6 +393,7 @@ module Kward
366
393
  tab.steering = steering_supported? ? Steering.new : nil
367
394
  tab.error = nil
368
395
  tab.answer = nil
396
+ tab.error_reported = false
369
397
  tab.event_history.clear
370
398
  tab.seen_events = 0
371
399
  tab.markdown_chunks.clear
@@ -390,10 +418,12 @@ module Kward
390
418
  rescue Cancellation::CancelledError
391
419
  tab.status = "cancelled"
392
420
  tab.unread = false
421
+ report_tab_runtime_error(tab)
393
422
  rescue StandardError => e
394
423
  tab.error = e.message
395
424
  tab.status = "failed"
396
425
  tab.unread = false
426
+ report_tab_runtime_error(tab)
397
427
  ensure
398
428
  finish_tab_turn(tab)
399
429
  end
@@ -21,6 +21,8 @@ module Kward
21
21
  shell_command_summary(args, text)
22
22
  when "web_search"
23
23
  web_search_summary(args, text)
24
+ when "read_skill"
25
+ read_skill_summary(text)
24
26
  else
25
27
  generic_tool_summary(name, text)
26
28
  end
@@ -76,6 +78,10 @@ module Kward
76
78
  lines.join("\n")
77
79
  end
78
80
 
81
+ def read_skill_summary(content)
82
+ "read_skill:\n#{content}"
83
+ end
84
+
79
85
  def error_tool_summary(name, args, content)
80
86
  path = args["path"] || args[:path]
81
87
  command = args["command"] || args[:command]
data/lib/kward/cli.rb CHANGED
@@ -9,11 +9,13 @@ require_relative "model/client"
9
9
  require_relative "compactor"
10
10
  require_relative "config_files"
11
11
  require_relative "clipboard"
12
+ require_relative "cancellation"
12
13
  require_relative "cli_transcript_formatter"
13
14
  require_relative "model/context_usage"
14
15
  require_relative "events"
15
16
  require_relative "export_path"
16
17
  require_relative "ekwsh"
18
+ require_relative "interactive_pty_runner"
17
19
  require_relative "auth/anthropic_oauth"
18
20
  require_relative "auth/github_oauth"
19
21
  require_relative "auth/openrouter_api_key"
@@ -30,6 +32,7 @@ require_relative "model/retry_message"
30
32
  require_relative "rpc/server"
31
33
  require_relative "session_diff"
32
34
  require_relative "session_store"
35
+ require_relative "session_naming"
33
36
  require_relative "tab_store"
34
37
  require_relative "session_trash"
35
38
  require_relative "session_tree_renderer"
@@ -185,17 +188,6 @@ module Kward
185
188
  return
186
189
  end
187
190
 
188
- if @argv.first == "count-tests"
189
- if help_option_arguments?(@argv[1..] || [])
190
- print_command_help("count-tests")
191
- return
192
- end
193
- raise ArgumentError, command_usage("count-tests") unless @argv.length == 1
194
-
195
- print_test_count
196
- return
197
- end
198
-
199
191
  if @argv.first == "sysprompt"
200
192
  if help_option_arguments?(@argv[1..] || [])
201
193
  print_command_help("sysprompt")
@@ -383,7 +375,7 @@ module Kward
383
375
 
384
376
  loop do
385
377
  if @pending_inputs.empty? && active_tab&.shell
386
- run_ekwsh_loop(active_tab.shell, tab: active_tab)
378
+ run_ekwsh_loop(active_tab.shell, tab: active_tab, history: build_ekwsh_history(active_tab.agent))
387
379
  end
388
380
  input = @pending_inputs.shift || (active_tab ? poll_active_tab_input : @prompt.ask("You>"))
389
381
  if input.is_a?(Hash) && input[:tab_action]
@@ -1,5 +1,5 @@
1
- require "base64"
2
1
  require "open3"
2
+ require_relative "terminal_sequences"
3
3
  require "rbconfig"
4
4
 
5
5
  # Namespace for the Kward CLI agent runtime.
@@ -39,8 +39,7 @@ module Kward
39
39
  end
40
40
 
41
41
  def write_osc52(content)
42
- encoded = Base64.strict_encode64(content)
43
- @output.print("\e]52;c;#{encoded}\a")
42
+ @output.print(TerminalSequences.osc52(content))
44
43
  @output.flush if @output.respond_to?(:flush)
45
44
  end
46
45