kward 0.81.0 → 0.82.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -1
  5. data/Rakefile +44 -4
  6. data/doc/composer.md +2 -2
  7. data/doc/files.md +8 -2
  8. data/doc/releasing.md +71 -38
  9. data/doc/security.md +1 -1
  10. data/doc/shell.md +3 -3
  11. data/kward.gemspec +1 -1
  12. data/lib/kward/adaptive_pty_output_sink.rb +183 -0
  13. data/lib/kward/cli/git.rb +5 -1
  14. data/lib/kward/cli/rendering.rb +14 -2
  15. data/lib/kward/cli/runtime_helpers.rb +103 -39
  16. data/lib/kward/cli/settings.rb +5 -13
  17. data/lib/kward/cli/tabs.rb +59 -4
  18. data/lib/kward/cli.rb +2 -0
  19. data/lib/kward/image_attachments.rb +98 -15
  20. data/lib/kward/interactive_pty_runner.rb +25 -19
  21. data/lib/kward/prompt_interface/composer_controller.rb +3 -3
  22. data/lib/kward/prompt_interface/composer_renderer.rb +27 -0
  23. data/lib/kward/prompt_interface/editor/controller.rb +6 -6
  24. data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
  25. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  26. data/lib/kward/prompt_interface/git_prompt.rb +1 -1
  27. data/lib/kward/prompt_interface/key_handler.rb +22 -0
  28. data/lib/kward/prompt_interface/overlay_renderer.rb +1 -0
  29. data/lib/kward/prompt_interface/project_browser.rb +162 -3
  30. data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
  31. data/lib/kward/prompt_interface/question_prompt.rb +1 -1
  32. data/lib/kward/prompt_interface/screen.rb +40 -20
  33. data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
  34. data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
  35. data/lib/kward/prompt_interface.rb +83 -32
  36. data/lib/kward/pty_output_sink.rb +45 -0
  37. data/lib/kward/pty_transcript_normalizer.rb +93 -0
  38. data/lib/kward/session_catalog.rb +87 -0
  39. data/lib/kward/session_store.rb +94 -5
  40. data/lib/kward/terminal_image_support.rb +116 -0
  41. data/lib/kward/terminal_sequences.rb +43 -0
  42. data/lib/kward/version.rb +1 -1
  43. metadata +7 -4
  44. data/.github/workflows/ci.yml +0 -48
  45. data/.github/workflows/pages.yml +0 -48
@@ -6,7 +6,6 @@ module Kward
6
6
  module RuntimeHelpers
7
7
  MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES = 1_048_576
8
8
  UNSAFE_TRANSCRIPT_CONTROL_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.freeze
9
- FROZEN_COMPOSER_COMMANDS = /\Agit\s+(?:fetch|ls-remote|push|remote|status)(?:\s|\z)/.freeze
10
9
 
11
10
  private
12
11
 
@@ -101,6 +100,7 @@ module Kward
101
100
  shell = bang_shell(agent)
102
101
  editor_result = shell.editor_command_result(command)
103
102
  if editor_result
103
+ record_tab_transient_shell_output(editor_result.output, render: false)
104
104
  @prompt.say(editor_result.output) unless editor_result.output.to_s.empty?
105
105
  open_ekwsh_editor(editor_result.open_editor_path, shell) if editor_result.open_editor_path
106
106
  return true
@@ -235,65 +235,101 @@ module Kward
235
235
 
236
236
  def run_user_interactive_pty_command(command, shell:, env:, cwd:, intro: nil)
237
237
  intro_message = intro || "$ #{command}\n"
238
+ record_tab_transient_shell_output(intro_message, render: false)
238
239
  if @prompt.respond_to?(:write_transcript)
239
240
  @prompt.write_transcript(intro_message)
240
241
  elsif @prompt.respond_to?(:say)
241
242
  @prompt.say(intro_message)
242
243
  end
243
- output = +"".b
244
- output_truncated = false
245
- handoff_options = {}
246
- handoff_options[:preserve_composer] = true if frozen_composer_command?(command)
247
- result = run_interactive_pty_with_terminal_handoff(shell, command, env: env, cwd: cwd, **handoff_options) do |chunk|
248
- remaining = MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES - output.bytesize
249
- if chunk.bytesize > remaining
250
- output << chunk.byteslice(0, remaining) if remaining.positive?
251
- output_truncated = true
252
- else
253
- output << chunk
254
- end
255
- end
256
- @prompt.refresh_composer_status if @prompt.respond_to?(:refresh_composer_status)
257
- transcript_output = terminal_transcript_output(output, result, truncated: output_truncated)
258
- if transcript_output && @prompt.respond_to?(:record_transient_terminal_output)
259
- @prompt.record_transient_terminal_output(transcript_output)
244
+ result = run_interactive_pty_with_terminal_handoff(shell, command, env: env, cwd: cwd) do |sink, completed_result|
245
+ record_completed_pty_output(sink, completed_result)
260
246
  end
247
+ refresh_composer_status
261
248
  result
262
249
  rescue Errno::ENOENT => e
263
250
  runtime_output("Error: #{e.message}")
264
251
  nil
265
252
  end
266
253
 
267
- def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:, preserve_composer: false, &block)
254
+ def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:, &on_complete)
268
255
  runner = InteractivePtyRunner.new
269
- if @prompt.respond_to?(:with_terminal_handoff)
270
- if preserve_composer
271
- @prompt.with_terminal_handoff(preserve_composer: true) do |input, output|
272
- runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, output: output, &block)
273
- end
274
- else
275
- @prompt.with_terminal_handoff do |input, output|
276
- runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, output: output, &block)
277
- end
256
+ run_with_sink = lambda do |input, sink|
257
+ result = runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, sink: sink)
258
+ on_complete.call(sink, result) if on_complete
259
+ result
260
+ end
261
+
262
+ if @prompt.respond_to?(:with_inline_terminal_handoff)
263
+ @prompt.with_inline_terminal_handoff do |input, output, transition|
264
+ sink = AdaptivePtyOutputSink.new(
265
+ output: output,
266
+ on_exclusive: transition,
267
+ max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
268
+ )
269
+ run_with_sink.call(input, sink)
270
+ end
271
+ elsif @prompt.respond_to?(:with_terminal_handoff)
272
+ @prompt.with_terminal_handoff do |input, output|
273
+ sink = PassthroughPtyOutputSink.new(
274
+ output: output,
275
+ max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
276
+ )
277
+ run_with_sink.call(input, sink)
278
278
  end
279
279
  else
280
- runner.run(shell, "-c", command, env: env, cwd: cwd, &block)
280
+ sink = PassthroughPtyOutputSink.new(
281
+ output: $stdout,
282
+ max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
283
+ )
284
+ run_with_sink.call($stdin, sink)
281
285
  end
282
286
  end
283
287
 
284
- def frozen_composer_command?(command)
285
- FROZEN_COMPOSER_COMMANDS.match?(command.to_s.strip)
288
+ def record_completed_pty_output(sink, result)
289
+ classified_output = sink.respond_to?(:transcript_safe?)
290
+ return if classified_output && !sink.transcript_safe?
291
+
292
+ output = sink&.captured_output || +"".b
293
+ allow_input = classified_output &&
294
+ sink.respond_to?(:pre_input_capture_only?) &&
295
+ sink.pre_input_capture_only?
296
+ transcript_output = terminal_transcript_output(
297
+ output,
298
+ result,
299
+ truncated: sink&.truncated? || false,
300
+ allow_input: allow_input,
301
+ normalize_line_controls: classified_output
302
+ )
303
+ return unless transcript_output
304
+
305
+ record_tab_transient_shell_output(transcript_output, render: false)
306
+ if @prompt.respond_to?(:record_transient_terminal_output)
307
+ @prompt.record_transient_terminal_output(transcript_output, render: false)
308
+ end
286
309
  end
287
310
 
288
- def terminal_transcript_output(output, result, truncated:)
289
- return if truncated || result.input_forwarded
311
+ def terminal_transcript_output(
312
+ output,
313
+ result,
314
+ truncated:,
315
+ allow_input: false,
316
+ normalize_line_controls: false
317
+ )
318
+ return if truncated || (result.input_forwarded && !allow_input)
290
319
 
291
320
  text = ANSI.normalize_transcript_encoding(output).gsub("\r\n", "\n")
292
- return if text.empty? || text.include?("\r")
321
+ return if text.empty?
322
+
323
+ if normalize_line_controls
324
+ sanitized = PtyTranscriptNormalizer.normalize(text)
325
+ else
326
+ return if text.include?("\r")
293
327
 
294
- sanitized = ANSI.sanitize_transcript(text)
295
- return unless sanitized == text
296
- return if ANSI.strip_control_sequences(text).match?(UNSAFE_TRANSCRIPT_CONTROL_PATTERN)
328
+ sanitized = ANSI.sanitize_transcript(text)
329
+ return unless sanitized == text
330
+ end
331
+ return if sanitized.empty?
332
+ return if ANSI.strip_control_sequences(sanitized).match?(UNSAFE_TRANSCRIPT_CONTROL_PATTERN)
297
333
 
298
334
  sanitized
299
335
  end
@@ -329,8 +365,13 @@ module Kward
329
365
  break if input.nil?
330
366
 
331
367
  result = run_ekwsh_command(shell, input)
332
- @prompt.clear_transcript if result.clear && @prompt.respond_to?(:clear_transcript)
333
- @prompt.say(result.output) unless result.streamed || result.interactive_command || result.output.to_s.empty?
368
+ if result.clear
369
+ clear_active_tab_transient_shell_output
370
+ @prompt.clear_transcript if @prompt.respond_to?(:clear_transcript)
371
+ else
372
+ record_tab_transient_shell_output(result.output, render: false) unless result.interactive_command
373
+ @prompt.say(result.output) unless result.streamed || result.interactive_command || result.output.to_s.empty?
374
+ end
334
375
  return :tab_action if pending_tab_action?
335
376
 
336
377
  if result.open_editor_path
@@ -443,6 +484,21 @@ module Kward
443
484
  result
444
485
  end
445
486
 
487
+ def record_tab_transient_shell_output(text, render: true)
488
+ value = text.to_s
489
+ return if value.empty?
490
+
491
+ tab = active_tab if respond_to?(:active_tab, true)
492
+ tab&.append_transient_shell_entry(value)
493
+ if render && @prompt.respond_to?(:record_transient_terminal_output)
494
+ @prompt.record_transient_terminal_output(value)
495
+ end
496
+ end
497
+
498
+ def clear_active_tab_transient_shell_output
499
+ clear_active_tab_transient_shell_entries if respond_to?(:clear_active_tab_transient_shell_entries, true)
500
+ end
501
+
446
502
  def drain_ekwsh_chunks(chunks)
447
503
  loop do
448
504
  @prompt.write_transcript_delta(chunks.pop(true))
@@ -559,6 +615,14 @@ module Kward
559
615
  @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT
560
616
  end
561
617
 
618
+ def refresh_composer_status
619
+ if @prompt.respond_to?(:refresh_composer_status)
620
+ @prompt.refresh_composer_status
621
+ elsif @prompt.respond_to?(:redraw)
622
+ @prompt.redraw
623
+ end
624
+ end
625
+
562
626
  def reload_client_config
563
627
  @client.reload_config if @client.respond_to?(:reload_config)
564
628
  end
@@ -115,7 +115,7 @@ module Kward
115
115
  ConfigFiles.update_config("provider" => ModelInfo.config_provider_for_provider(provider))
116
116
  reload_client_config
117
117
  refresh_conversation_runtime(conversation)
118
- @prompt.redraw if @prompt.respond_to?(:redraw)
118
+ refresh_composer_status
119
119
  end
120
120
 
121
121
  def provider_choices
@@ -166,7 +166,7 @@ module Kward
166
166
  ConfigFiles.update_config("local_backend" => backend, "local_base_url" => url)
167
167
  reload_client_config
168
168
  refresh_conversation_runtime(conversation)
169
- @prompt.redraw if @prompt.respond_to?(:redraw)
169
+ refresh_composer_status
170
170
  end
171
171
 
172
172
  def configure_account_settings
@@ -920,7 +920,7 @@ module Kward
920
920
  ConfigFiles.update_config(ModelInfo.config_values_for_selection(provider, model))
921
921
  reload_client_config
922
922
  refresh_conversation_runtime(conversation)
923
- @prompt.redraw if @prompt.respond_to?(:redraw)
923
+ refresh_composer_status
924
924
  end
925
925
 
926
926
  REASONING_CONFIG_DEBOUNCE_SECONDS = 0.5
@@ -953,18 +953,10 @@ module Kward
953
953
  queue_reasoning_config(effort, provider: provider, conversation: conversation) if queue_config
954
954
  if queue_config
955
955
  update_conversation_reasoning_effort(conversation, effort)
956
- refresh_reasoning_status
956
+ refresh_composer_status
957
957
  else
958
958
  refresh_conversation_runtime(conversation, reasoning_effort: effort)
959
- refresh_reasoning_status
960
- end
961
- end
962
-
963
- def refresh_reasoning_status
964
- if @prompt.respond_to?(:refresh_composer_status)
965
- @prompt.refresh_composer_status
966
- else
967
- @prompt.redraw if @prompt.respond_to?(:redraw)
959
+ refresh_composer_status
968
960
  end
969
961
  end
970
962
 
@@ -35,10 +35,13 @@ module Kward
35
35
  :unread,
36
36
  :pending_question,
37
37
  :shell,
38
+ :transient_shell_entries,
38
39
  :error_reported,
39
40
  :local_busy_activity,
40
41
  keyword_init: true
41
42
  ) do
43
+ TRANSIENT_SHELL_OUTPUT_LIMIT = 200_000
44
+
42
45
  def running?
43
46
  %w[queued running waiting_for_question].include?(status.to_s)
44
47
  end
@@ -54,6 +57,41 @@ module Kward
54
57
  def record_event(event)
55
58
  event_history << event
56
59
  end
60
+
61
+ def append_transient_shell_entry(text)
62
+ value = text.to_s
63
+ return if value.empty?
64
+
65
+ self.transient_shell_entries ||= []
66
+ transient_shell_entries << value
67
+ trim_transient_shell_entries
68
+ end
69
+
70
+ def clear_transient_shell_entries
71
+ self.transient_shell_entries = []
72
+ end
73
+
74
+ private
75
+
76
+ def trim_transient_shell_entries
77
+ combined = transient_shell_entries.join
78
+ return if combined.length <= TRANSIENT_SHELL_OUTPUT_LIMIT
79
+
80
+ target = (TRANSIENT_SHELL_OUTPUT_LIMIT * 0.9).floor
81
+ cutoff = combined.length - target
82
+ newline = combined.index("\n", cutoff)
83
+ cutoff = newline + 1 if newline
84
+ cutoff = transient_shell_escape_boundary(combined, cutoff)
85
+ self.transient_shell_entries = [combined[cutoff..].to_s]
86
+ end
87
+
88
+ def transient_shell_escape_boundary(text, cutoff)
89
+ escape_start = text.rindex("\e", cutoff)
90
+ return cutoff unless escape_start
91
+
92
+ escape = ANSI.escape_sequence_at(text, escape_start)
93
+ escape && escape_start + escape.length > cutoff ? escape_start + escape.length : cutoff
94
+ end
57
95
  end
58
96
 
59
97
  class TabQuestionPrompt
@@ -279,6 +317,7 @@ module Kward
279
317
  unread: false,
280
318
  pending_question: nil,
281
319
  shell: nil,
320
+ transient_shell_entries: [],
282
321
  error_reported: false,
283
322
  local_busy_activity: nil
284
323
  ).tap { |tab| assign_tab_question_prompt(agent, tab) }
@@ -424,6 +463,7 @@ module Kward
424
463
  tab.queued_inputs.clear
425
464
  tab.steering = nil
426
465
  tab.shell = nil
466
+ tab.clear_transient_shell_entries
427
467
  tab.stream_state = new_tab_stream_state(tab.driver)
428
468
  tab.markdown_chunks.clear
429
469
  update_prompt_workspace_root(current_workspace_root)
@@ -451,7 +491,7 @@ module Kward
451
491
  end
452
492
  return snapshot_method.call unless supports_transcript_option
453
493
 
454
- snapshot_method.call(include_transcript: tab.running? || !tab.shell.nil?)
494
+ snapshot_method.call(include_transcript: tab.running?)
455
495
  end
456
496
 
457
497
  def activate_tab(index, render: true)
@@ -484,8 +524,8 @@ module Kward
484
524
  tab.agent
485
525
  end
486
526
 
487
- def render_tab(tab)
488
- if tab.snapshot && @prompt.respond_to?(:restore_tab_view_snapshot) && (tab.running? || tab.shell)
527
+ def render_tab(tab, restore_composer: true)
528
+ if restore_composer && tab.snapshot && @prompt.respond_to?(:restore_tab_view_snapshot) && tab.running?
489
529
  @prompt.restore_tab_view_snapshot(tab.snapshot)
490
530
  return
491
531
  end
@@ -497,8 +537,23 @@ module Kward
497
537
  render_transcript_messages(tab.driver.messages)
498
538
  end
499
539
  report_tab_runtime_error(tab) if %w[failed cancelled].include?(tab.status.to_s)
540
+ render_tab_transient_shell_entries(tab)
500
541
  end
501
- restore_tab_composer_snapshot(tab.snapshot)
542
+ restore_tab_composer_snapshot(tab.snapshot) if restore_composer
543
+ end
544
+
545
+ def render_tab_transient_shell_entries(tab)
546
+ Array(tab.transient_shell_entries).each do |entry|
547
+ if @prompt.respond_to?(:say)
548
+ @prompt.say(entry)
549
+ elsif @prompt.respond_to?(:write_transcript)
550
+ @prompt.write_transcript(entry)
551
+ end
552
+ end
553
+ end
554
+
555
+ def clear_active_tab_transient_shell_entries
556
+ active_tab&.clear_transient_shell_entries
502
557
  end
503
558
 
504
559
  def restore_tab_composer_snapshot(snapshot)
data/lib/kward/cli.rb CHANGED
@@ -16,6 +16,8 @@ require_relative "events"
16
16
  require_relative "export_path"
17
17
  require_relative "ekwsh"
18
18
  require_relative "interactive_pty_runner"
19
+ require_relative "adaptive_pty_output_sink"
20
+ require_relative "pty_transcript_normalizer"
19
21
  require_relative "auth/anthropic_oauth"
20
22
  require_relative "auth/api_key_store"
21
23
  require_relative "auth/github_oauth"
@@ -1,8 +1,10 @@
1
1
  require "base64"
2
2
  require "cgi/escape"
3
+ require "open3"
3
4
  require "shellwords"
4
5
  require "tmpdir"
5
6
  require "uri"
7
+ require_relative "terminal_image_support"
6
8
 
7
9
  # Namespace for the Kward CLI agent runtime.
8
10
  module Kward
@@ -291,6 +293,71 @@ module Kward
291
293
  MIME_TYPES.key?(File.extname(path.to_s).downcase)
292
294
  end
293
295
 
296
+ def image_part_from_path(path)
297
+ expanded_path = File.expand_path(path.to_s)
298
+ return nil unless File.file?(expanded_path)
299
+
300
+ media_type = mime_type(expanded_path)
301
+ return nil unless media_type
302
+
303
+ size = File.size(expanded_path)
304
+ return nil if size > MAX_IMAGE_BYTES
305
+
306
+ {
307
+ type: "image",
308
+ media_type: media_type,
309
+ data: Base64.strict_encode64(File.binread(expanded_path)),
310
+ path: expanded_path,
311
+ size_bytes: size
312
+ }
313
+ rescue SystemCallError
314
+ nil
315
+ end
316
+
317
+ def terminal_image_part(part, protocol)
318
+ return part unless protocol == :kitty
319
+ return part if image_media_type(part) == "image/png"
320
+
321
+ convert_image_part_to_png(part)
322
+ end
323
+
324
+ def png_dimensions(part)
325
+ encoded = part[:data] || part["data"]
326
+ header = Base64.strict_decode64(encoded.to_s[0, 32])
327
+ return nil unless header.start_with?("\x89PNG\r\n\x1A\n".b)
328
+ return nil unless header.byteslice(12, 4) == "IHDR"
329
+
330
+ width, height = header.byteslice(16, 8).unpack("NN")
331
+ return nil unless width.positive? && height.positive?
332
+
333
+ [width, height]
334
+ rescue ArgumentError, NoMethodError
335
+ nil
336
+ end
337
+
338
+ def convert_image_part_to_png(part)
339
+ path = part[:path] || part["path"]
340
+ return nil unless path && File.file?(path)
341
+
342
+ stdout, _stderr, status = Open3.capture3(
343
+ "magick",
344
+ "-limit", "memory", "128MiB",
345
+ "-limit", "map", "256MiB",
346
+ "#{path}[0]",
347
+ "png:-"
348
+ )
349
+ return nil unless status.success?
350
+ return nil if stdout.empty? || stdout.bytesize > MAX_IMAGE_BYTES
351
+
352
+ part.merge(
353
+ media_type: "image/png",
354
+ data: Base64.strict_encode64(stdout),
355
+ size_bytes: stdout.bytesize
356
+ )
357
+ rescue SystemCallError
358
+ nil
359
+ end
360
+
294
361
  def mime_type(path)
295
362
  MIME_TYPES[File.extname(path.to_s).downcase]
296
363
  end
@@ -300,36 +367,52 @@ module Kward
300
367
  "data:#{media_type};base64,#{part[:data] || part["data"]}"
301
368
  end
302
369
 
303
- def terminal_image_sequence(part, width: DEFAULT_TERMINAL_IMAGE_WIDTH, env: ENV)
370
+ def terminal_image_sequence(part, width: DEFAULT_TERMINAL_IMAGE_WIDTH, height: nil, protocol: nil, image_id: nil, move_cursor: true, env: ENV)
304
371
  data = part[:data] || part["data"]
305
372
  return nil if data.to_s.empty?
306
373
 
374
+ protocol ||= terminal_image_protocol(env)
307
375
  name = part[:path] || part["path"]
308
- if iterm_image_protocol?(env)
309
- iterm_image_sequence(data, name, width)
310
- elsif kitty_image_protocol?(env)
311
- kitty_image_sequence(data, name, width)
376
+ case protocol
377
+ when :iterm2
378
+ iterm_image_sequence(data, name, width, height, env: env)
379
+ when :kitty
380
+ return nil unless image_media_type(part) == "image/png"
381
+
382
+ kitty_image_sequence(data, name, width, height, image_id, move_cursor: move_cursor, env: env)
312
383
  end
313
384
  end
314
385
 
386
+ def terminal_image_protocol(env)
387
+ TerminalImageSupport.static_protocol(env)
388
+ end
389
+
315
390
  def iterm_image_protocol?(env)
316
- env["TERM_PROGRAM"] == "iTerm.app"
391
+ TerminalImageSupport.iterm2_hint?(env)
317
392
  end
318
393
 
319
394
  def kitty_image_protocol?(env)
320
- env["KITTY_WINDOW_ID"].to_s != "" || env["TERM"].to_s.include?("kitty") || env["TERM_PROGRAM"] == "WezTerm"
395
+ TerminalImageSupport.kitty_hint?(env)
396
+ end
397
+
398
+ def iterm_image_sequence(data, name, width, height = nil, env: ENV)
399
+ sequence = TerminalSequences.iterm2_image(data, name: name, width: width, height: height)
400
+ env["TMUX"].to_s.empty? ? sequence : TerminalSequences.tmux_passthrough(sequence)
321
401
  end
322
402
 
323
- def iterm_image_sequence(data, name, width)
324
- params = ["inline=1", "preserveAspectRatio=1", "width=#{width}"]
325
- params << "name=#{Base64.strict_encode64(File.basename(name))}" if name
326
- "\e]1337;File=#{params.join(";")}:#{data}\a"
403
+ def kitty_image_sequence(data, _name, width, height = nil, image_id = nil, move_cursor: true, env: ENV)
404
+ sequence = TerminalSequences.kitty_graphics(
405
+ data,
406
+ image_id: image_id,
407
+ columns: width,
408
+ rows: height,
409
+ move_cursor: move_cursor
410
+ )
411
+ env["TMUX"].to_s.empty? ? sequence : TerminalSequences.tmux_passthrough(sequence)
327
412
  end
328
413
 
329
- def kitty_image_sequence(data, name, width)
330
- params = ["inline=1", "preserveAspectRatio=1", "width=#{width}"]
331
- params << "name=#{Base64.strict_encode64(File.basename(name))}" if name
332
- "\e_G#{params.join(";")}:#{data}\e\\"
414
+ def image_media_type(part)
415
+ part[:media_type] || part["media_type"] || part[:mimeType] || part["mimeType"]
333
416
  end
334
417
  end
335
418
  end
@@ -2,10 +2,11 @@ require "io/console"
2
2
  require "pty"
3
3
  require "timeout"
4
4
  require_relative "local_pty_command_runner"
5
+ require_relative "pty_output_sink"
5
6
 
6
7
  # Namespace for the Kward CLI agent runtime.
7
8
  module Kward
8
- # Runs a command in a PTY while forwarding caller-owned input and output IOs.
9
+ # Runs a command in a PTY while forwarding caller-owned input and output sink.
9
10
  # This is intentionally low level: UI orchestration decides when terminal
10
11
  # ownership is handed to the child process and how the result is presented.
11
12
  class InteractivePtyRunner
@@ -18,7 +19,7 @@ module Kward
18
19
  @window_size = nil
19
20
  end
20
21
 
21
- def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, output: $stdout, &block)
22
+ def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, sink:)
22
23
  pid = nil
23
24
  status = nil
24
25
  writer = nil
@@ -29,9 +30,11 @@ module Kward
29
30
  writer = child_writer
30
31
  update_window_size(reader, pid)
31
32
  with_raw_input(input) do
32
- drain_initial_input(input, writer)
33
- status, input_forwarded = forward_io(reader, writer, pid, input, output, &block)
33
+ input_forwarded = drain_initial_input(input, writer, sink)
34
+ status, forwarded = forward_io(reader, writer, pid, input, sink)
35
+ input_forwarded ||= forwarded
34
36
  end
37
+ sink.finish if sink.respond_to?(:finish)
35
38
  status ||= wait_for_status(pid)
36
39
  end
37
40
 
@@ -57,19 +60,23 @@ module Kward
57
60
  yield
58
61
  end
59
62
 
60
- def drain_initial_input(input, writer)
63
+ def drain_initial_input(input, writer, sink)
64
+ forwarded = false
61
65
  loop do
62
66
  chunk = input.read_nonblock(READ_SIZE, exception: false)
63
67
  break if chunk.nil? || chunk == :wait_readable
64
68
 
69
+ sink.input_forwarded if sink.respond_to?(:input_forwarded)
65
70
  writer.write(chunk)
71
+ forwarded = true
66
72
  end
67
73
  writer.flush
74
+ forwarded
68
75
  rescue Errno::EIO, Errno::EPIPE, IOError
69
- nil
76
+ forwarded
70
77
  end
71
78
 
72
- def forward_io(reader, writer, pid, input, output, &block)
79
+ def forward_io(reader, writer, pid, input, sink)
73
80
  input_open = true
74
81
  input_forwarded = false
75
82
  loop do
@@ -77,9 +84,9 @@ module Kward
77
84
  readers = [reader]
78
85
  readers << input if input_open
79
86
  readable = IO.select(readers, nil, nil, 0.02)&.first || []
80
- forward_pty_output(reader, output, &block) if readable.include?(reader)
87
+ forward_pty_output(reader, sink) if readable.include?(reader)
81
88
  if input_open && readable.include?(input)
82
- input_open, forwarded = forward_input(input, writer)
89
+ input_open, forwarded = forward_input(input, writer, sink)
83
90
  input_forwarded ||= forwarded
84
91
  end
85
92
 
@@ -88,23 +95,22 @@ module Kward
88
95
 
89
96
  return [finish_stopped_process(pid), input_forwarded] if status.stopped?
90
97
 
91
- drain_pty_output(reader, output, &block)
98
+ drain_pty_output(reader, sink)
92
99
  return [status, input_forwarded]
93
100
  rescue Errno::EIO
94
101
  return [wait_for_status(pid), input_forwarded]
95
102
  end
96
103
  end
97
104
 
98
- def forward_pty_output(reader, output)
105
+ def forward_pty_output(reader, sink)
99
106
  chunk = reader.read_nonblock(READ_SIZE, exception: false)
100
107
  return if chunk.nil? || chunk == :wait_readable
101
108
 
102
- output.write(chunk)
103
- output.flush if output.respond_to?(:flush)
104
- yield chunk if block_given?
109
+ sink.write(chunk)
110
+ sink.flush if sink.respond_to?(:flush)
105
111
  end
106
112
 
107
- def drain_pty_output(reader, output)
113
+ def drain_pty_output(reader, sink)
108
114
  loop do
109
115
  readable, = IO.select([reader], nil, nil, 0.02)
110
116
  break unless readable
@@ -112,19 +118,19 @@ module Kward
112
118
  chunk = reader.read_nonblock(READ_SIZE, exception: false)
113
119
  break if chunk.nil? || chunk == :wait_readable
114
120
 
115
- output.write(chunk)
116
- yield chunk if block_given?
121
+ sink.write(chunk)
117
122
  end
118
- output.flush if output.respond_to?(:flush)
123
+ sink.flush if sink.respond_to?(:flush)
119
124
  rescue Errno::EIO
120
125
  nil
121
126
  end
122
127
 
123
- def forward_input(input, writer)
128
+ def forward_input(input, writer, sink)
124
129
  chunk = input.read_nonblock(READ_SIZE, exception: false)
125
130
  return [false, false] if chunk.nil?
126
131
  return [true, false] if chunk == :wait_readable
127
132
 
133
+ sink.input_forwarded if sink.respond_to?(:input_forwarded)
128
134
  writer.write(chunk)
129
135
  writer.flush
130
136
  [true, true]
@@ -257,12 +257,12 @@ module Kward
257
257
  value = submitted_input
258
258
  add_history(composer_input)
259
259
  if !@busy && value.strip.empty?
260
- @output_io.flush
260
+ flush_output_locked
261
261
  return value
262
262
  end
263
263
 
264
264
  clear_submitted_input_locked
265
- @output_io.flush
265
+ flush_output_locked
266
266
  value
267
267
  end
268
268
 
@@ -306,7 +306,7 @@ module Kward
306
306
 
307
307
  def exit_input
308
308
  clear_finished_input_locked
309
- @output_io.flush
309
+ flush_output_locked
310
310
  EXIT_INPUT
311
311
  end
312
312