kward 0.80.1 → 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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +2 -2
  5. data/Rakefile +44 -4
  6. data/doc/composer.md +6 -9
  7. data/doc/configuration.md +13 -3
  8. data/doc/files.md +8 -2
  9. data/doc/permissions.md +1 -1
  10. data/doc/releasing.md +71 -38
  11. data/doc/rpc.md +2 -1
  12. data/doc/sandboxing.md +4 -4
  13. data/doc/security.md +6 -9
  14. data/doc/shell.md +161 -196
  15. data/doc/skills.md +17 -5
  16. data/doc/tabs.md +1 -1
  17. data/doc/usage.md +6 -5
  18. data/kward.gemspec +1 -1
  19. data/lib/kward/adaptive_pty_output_sink.rb +183 -0
  20. data/lib/kward/ansi.rb +9 -1
  21. data/lib/kward/cli/commands.rb +7 -0
  22. data/lib/kward/cli/git.rb +5 -1
  23. data/lib/kward/cli/interactive_turn.rb +1 -1
  24. data/lib/kward/cli/plugins.rb +1 -1
  25. data/lib/kward/cli/project_skills.rb +99 -0
  26. data/lib/kward/cli/project_skills_commands.rb +87 -0
  27. data/lib/kward/cli/prompt_interface.rb +18 -4
  28. data/lib/kward/cli/rendering.rb +41 -3
  29. data/lib/kward/cli/runtime_helpers.rb +222 -21
  30. data/lib/kward/cli/sessions.rb +6 -4
  31. data/lib/kward/cli/settings.rb +5 -13
  32. data/lib/kward/cli/slash_commands.rb +6 -0
  33. data/lib/kward/cli/tabs.rb +62 -5
  34. data/lib/kward/cli.rb +21 -1
  35. data/lib/kward/config_files.rb +9 -4
  36. data/lib/kward/conversation.rb +8 -5
  37. data/lib/kward/ekwsh.rb +37 -16
  38. data/lib/kward/image_attachments.rb +98 -15
  39. data/lib/kward/interactive_pty_runner.rb +102 -30
  40. data/lib/kward/prompt_interface/composer_controller.rb +15 -3
  41. data/lib/kward/prompt_interface/composer_renderer.rb +27 -0
  42. data/lib/kward/prompt_interface/editor/controller.rb +10 -6
  43. data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
  44. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  45. data/lib/kward/prompt_interface/git_prompt.rb +1 -1
  46. data/lib/kward/prompt_interface/key_handler.rb +100 -43
  47. data/lib/kward/prompt_interface/overlay_renderer.rb +13 -0
  48. data/lib/kward/prompt_interface/project_browser.rb +162 -3
  49. data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
  50. data/lib/kward/prompt_interface/question_prompt.rb +1 -1
  51. data/lib/kward/prompt_interface/screen.rb +40 -15
  52. data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
  53. data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
  54. data/lib/kward/prompt_interface.rb +167 -44
  55. data/lib/kward/prompts/commands.rb +2 -0
  56. data/lib/kward/prompts.rb +6 -6
  57. data/lib/kward/pty_output_sink.rb +45 -0
  58. data/lib/kward/pty_transcript_normalizer.rb +93 -0
  59. data/lib/kward/rpc/server.rb +1 -0
  60. data/lib/kward/session_catalog.rb +87 -0
  61. data/lib/kward/session_store.rb +97 -7
  62. data/lib/kward/skills/registry.rb +52 -2
  63. data/lib/kward/skills/trust_coordinator.rb +45 -0
  64. data/lib/kward/skills/trust_store.rb +107 -0
  65. data/lib/kward/terminal_image_support.rb +116 -0
  66. data/lib/kward/terminal_sequences.rb +43 -0
  67. data/lib/kward/version.rb +1 -1
  68. metadata +11 -4
  69. data/.github/workflows/ci.yml +0 -48
  70. data/.github/workflows/pages.yml +0 -48
@@ -27,15 +27,40 @@ module Kward
27
27
  result.is_a?(String) ? true : result
28
28
  end
29
29
 
30
+ def handle_image_viewer_key(key)
31
+ return true if handle_bundled_key(key) { |token| handle_image_viewer_key(token) }
32
+
33
+ sequence = parse_csi_u_key(key)
34
+ if sequence
35
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
36
+ return close_image_viewer if sequence[:code] == 27
37
+
38
+ text = csi_u_printable_text(sequence)
39
+ return zoom_image_viewer(:in) if text == "+"
40
+ return zoom_image_viewer(:out) if text == "-"
41
+ return true
42
+ end
43
+
44
+ return zoom_image_viewer(:in) if key == "+"
45
+ return zoom_image_viewer(:out) if key == "-"
46
+
47
+ close_image_viewer if key == "\e" || key == "q" || key == "Q"
48
+ true
49
+ end
50
+
30
51
  def handle_key(key)
31
52
  return submit_input if key.nil?
32
53
  return handle_interactive_key(key) if interactive_active_locked?
54
+ return handle_image_viewer_key(key) if image_viewer_active?
33
55
  return handle_editor_input_key(key) if editor_active?
34
56
  return handle_project_browser_key(key) if project_browser_visible?
35
57
  return handle_history_search_key(key) if history_search_active?
36
58
  return true if handle_mouse_reporting_key(key)
37
59
  return if handle_bracketed_paste_key(key)
38
60
 
61
+ completion_overlay_result = handle_completion_overlay_key(key)
62
+ return completion_overlay_result unless completion_overlay_result == false
63
+
39
64
  csi_result = handle_csi_u_key(key)
40
65
  return csi_result unless csi_result == false
41
66
  return if handle_shift_enter_key(key)
@@ -44,9 +69,6 @@ module Kward
44
69
  completion_result = handle_completion_provider_key(key)
45
70
  return completion_result unless completion_result == false
46
71
 
47
- reasoning_result = handle_reasoning_key_binding(key)
48
- return reasoning_result unless reasoning_result == false
49
-
50
72
  tab_result = handle_tab_key_binding(key)
51
73
  return tab_result unless tab_result == false
52
74
 
@@ -81,7 +103,7 @@ module Kward
81
103
  when :ctrl_y
82
104
  yank_kill_buffer
83
105
  when :ctrl_l
84
- redraw_screen_locked
106
+ request_transcript_redraw
85
107
  when :ctrl_r
86
108
  start_history_search
87
109
  when :left
@@ -212,9 +234,6 @@ module Kward
212
234
  return true if sequence == "\e" && (dismiss_file_overlay || dismiss_slash_overlay)
213
235
  return true if handle_shift_enter_key(sequence)
214
236
 
215
- reasoning_result = handle_reasoning_key_binding(sequence)
216
- return reasoning_result unless reasoning_result == false
217
-
218
237
  tab_result = handle_tab_key_binding(sequence)
219
238
  return tab_result unless tab_result == false
220
239
 
@@ -323,10 +342,10 @@ module Kward
323
342
  if ctrl_modifier?(modifier)
324
343
  shift_modifier?(modifier) ? { tab_action: :previous } : { tab_action: :next }
325
344
  elsif shift_modifier?(modifier)
326
- handle_reasoning_key_binding(key) || handle_tab_completion_key
345
+ handle_tab_completion_key
327
346
  else
328
347
  completion_result = handle_completion_provider_key("\t")
329
- completion_result == false ? handle_reasoning_key_binding("\t") || handle_tab_completion_key : completion_result
348
+ completion_result == false ? handle_tab_completion_key : completion_result
330
349
  end
331
350
  when 13
332
351
  if modifier == 2
@@ -447,7 +466,7 @@ module Kward
447
466
  when 107
448
467
  kill_line_after_cursor
449
468
  when 108
450
- redraw_screen_locked
469
+ request_transcript_redraw
451
470
  when 114
452
471
  start_history_search
453
472
  when 117
@@ -580,61 +599,99 @@ module Kward
580
599
 
581
600
  CTRL_TAB_SEQUENCES = TerminalKeys::CTRL_TAB
582
601
  CTRL_SHIFT_TAB_SEQUENCES = TerminalKeys::CTRL_SHIFT_TAB
583
- SHIFT_TAB_SEQUENCES = TerminalKeys::SHIFT_TAB
602
+
603
+ def handle_completion_overlay_key(key)
604
+ return false unless @completion_overlay
605
+
606
+ return handle_completion_provider_key("\t") if completion_tab_key?(key)
607
+
608
+ dismiss_completion_overlay
609
+ false
610
+ end
611
+
612
+ def completion_tab_key?(key)
613
+ return true if key == "\t"
614
+
615
+ sequence = parse_csi_u_key(key)
616
+ return false unless sequence && sequence[:code] == 9
617
+
618
+ completion = !ctrl_modifier?(sequence[:modifier]) && !shift_modifier?(sequence[:modifier])
619
+ queue_pending_keys(sequence[:remaining]) if completion && sequence[:remaining] && !sequence[:remaining].empty?
620
+ completion
621
+ end
622
+
623
+ def dismiss_completion_overlay
624
+ @completion_overlay = nil
625
+ @completion_cycle = nil
626
+ end
584
627
 
585
628
  def handle_completion_provider_key(key)
586
629
  return false unless key == "\t" && @completion_provider
587
630
 
631
+ if (cycle = matching_completion_cycle)
632
+ cycle_completion(cycle)
633
+ return true
634
+ end
635
+
588
636
  result = @completion_provider.call(composer_input, composer_cursor)
637
+ return false if result == false
589
638
  return true unless result
590
639
 
591
640
  apply_completion_result(result)
592
641
  true
593
642
  end
594
643
 
644
+ def matching_completion_cycle
645
+ cycle = @completion_cycle
646
+ return nil unless cycle
647
+ return cycle if cycle[:input] == composer_input && cycle[:cursor] == composer_cursor
648
+
649
+ @completion_cycle = nil
650
+ @completion_overlay = nil
651
+ nil
652
+ end
653
+
654
+ def cycle_completion(cycle)
655
+ cycle[:index] = (cycle[:index] + 1) % cycle[:candidates].length
656
+ apply_completion_cycle_candidate(cycle)
657
+ end
658
+
595
659
  def apply_completion_result(result)
596
660
  range = result[:range] || result["range"] || result.range
597
661
  replacement = result[:replacement] || result["replacement"] || result.replacement
598
- candidates = result[:candidates] || result["candidates"] || result.candidates
662
+ candidates = (result[:candidates] || result["candidates"] || result.candidates).to_a
599
663
  original = composer_input
600
664
  before = original[0...range.begin].to_s
601
665
  after = original[range.end..].to_s
666
+
667
+ if candidates.length > 1
668
+ @completion_cycle = {
669
+ candidates: candidates.map(&:to_s),
670
+ index: 0,
671
+ before: before,
672
+ after: after
673
+ }
674
+ apply_completion_cycle_candidate(@completion_cycle)
675
+ show_completion_overlay(candidates)
676
+ return
677
+ end
678
+
679
+ @completion_cycle = nil
602
680
  self.composer_input = "#{before}#{replacement}#{after}"
603
681
  self.composer_cursor = before.length + replacement.to_s.length
604
- show_completion_candidates(candidates, replacement) if candidates.to_a.length > 1 && replacement.to_s == original[range]
605
682
  end
606
683
 
607
- def show_completion_candidates(candidates, replacement)
608
- lines = candidates.to_a.first(40)
609
- text = ["completions:", *lines.map { |candidate| " #{candidate}" }].join("\n")
610
- write_completion_transcript_locked(text)
611
- end
612
-
613
- def write_completion_transcript_locked(text)
614
- with_synchronized_output_locked do
615
- clear_prompt_for_output_locked
616
- write_transcript_text_locked("\n#{text}\n")
617
- render_prompt_after_output_locked
618
- end
684
+ def show_completion_overlay(candidates)
685
+ @completion_overlay = { candidates: candidates.map(&:to_s), index: 0 }
619
686
  end
620
687
 
621
- def handle_reasoning_key_binding(key)
622
- return false if @busy || @select_state || @question_state
623
- return false if file_overlay_visible? || slash_overlay_visible?
624
- return false if @slash_overlay_dismissed_input && @slash_overlay_dismissed_input == composer_input
625
- mention_token = active_file_mention_token
626
- open_token = active_file_open_token
627
- return false if mention_token && @file_overlay_dismissed_token == mention_token
628
- return false if open_token && @file_open_dismissed_token == open_token
629
-
630
- case key
631
- when "\t"
632
- { reasoning_action: :next }
633
- when *SHIFT_TAB_SEQUENCES
634
- { reasoning_action: :previous }
635
- else
636
- false
637
- end
688
+ def apply_completion_cycle_candidate(cycle)
689
+ replacement = cycle[:candidates][cycle[:index]]
690
+ self.composer_input = "#{cycle[:before]}#{replacement}#{cycle[:after]}"
691
+ self.composer_cursor = cycle[:before].length + replacement.length
692
+ @completion_overlay[:index] = cycle[:index] if @completion_overlay
693
+ cycle[:input] = composer_input
694
+ cycle[:cursor] = composer_cursor
638
695
  end
639
696
 
640
697
  def handle_tab_key_binding(key)
@@ -710,7 +767,7 @@ module Kward
710
767
  when TerminalKeys::CTRL_K
711
768
  kill_line_after_cursor
712
769
  when TerminalKeys::CTRL_L
713
- redraw_screen_locked
770
+ request_transcript_redraw
714
771
  when TerminalKeys::CTRL_U
715
772
  kill_line_before_cursor
716
773
  when TerminalKeys::CTRL_W
@@ -10,13 +10,26 @@ module Kward
10
10
  return question_overlay_rows(width) if @question_state
11
11
  return selection_overlay_rows(width, height: height) if @select_state
12
12
  return git_overlay_rows(width, height: height) if @git_state
13
+ return image_viewer_rows(width, height: height) if image_viewer_active?
13
14
  return project_browser_rows(width, height: height) if project_browser_visible?
15
+ return completion_overlay_rows(width, height: height) if @completion_overlay
14
16
  return history_search_overlay_rows(width, height: height) if history_search_active?
15
17
  return file_overlay_rows(width, height: height) if file_overlay_visible?
16
18
 
17
19
  slash_overlay_rows(width, height: height)
18
20
  end
19
21
 
22
+ def completion_overlay_rows(width, height: screen_height)
23
+ candidates = @completion_overlay[:candidates]
24
+ max_rows = max_overlay_list_rows(height)
25
+ selected = @completion_overlay[:index].to_i
26
+ start = centered_list_window_start(selected, candidates.length, max_rows)
27
+ rows = (candidates[start, max_rows] || []).each_with_index.map do |candidate, offset|
28
+ overlay_choice_line(candidate, selected: start + offset == selected)
29
+ end
30
+ overlay_card_rows("Completions", rows, width)
31
+ end
32
+
20
33
  def history_search_overlay_rows(width, height: screen_height)
21
34
  matches = history_search_matches
22
35
  if matches.empty?
@@ -34,6 +34,10 @@ module Kward
34
34
  }.freeze
35
35
  PROJECT_BROWSER_DIRECTORY_ICON = ""
36
36
  PROJECT_BROWSER_FILE_ICON = ""
37
+ IMAGE_VIEWER_CELL_HEIGHT_TO_WIDTH = 2.0
38
+ IMAGE_VIEWER_ZOOM_STEP = 0.25
39
+ IMAGE_VIEWER_MIN_ZOOM = 0.25
40
+ IMAGE_VIEWER_MAX_ZOOM = 4.0
37
41
 
38
42
  def open_project_browser
39
43
  @mutex.synchronize do
@@ -51,7 +55,8 @@ module Kward
51
55
  expanded: restored_project_browser_expanded_paths(paths, saved_state),
52
56
  selection_index: 0,
53
57
  search_active: false,
54
- query: ""
58
+ query: "",
59
+ status: nil
55
60
  }
56
61
  restore_project_browser_selection(saved_state["selected_path"])
57
62
  self.composer_input = ""
@@ -63,7 +68,11 @@ module Kward
63
68
  private
64
69
 
65
70
  def project_browser_visible?
66
- !@project_browser_state.nil? && !editor_active?
71
+ !@project_browser_state.nil? && !editor_active? && !image_viewer_active?
72
+ end
73
+
74
+ def image_viewer_active?
75
+ !@image_viewer_state.nil?
67
76
  end
68
77
 
69
78
  def dismiss_project_browser
@@ -243,6 +252,8 @@ module Kward
243
252
  if row[:directory]
244
253
  toggle_project_browser_directory(row[:path])
245
254
  true
255
+ elsif Kward::ImageAttachments.image_extension?(row[:path])
256
+ open_project_browser_image(row[:path])
246
257
  else
247
258
  persist_project_browser_state unless project_browser_search_active?
248
259
  @project_browser_restore_after_editor = true if open_editor(row[:path])
@@ -250,6 +261,152 @@ module Kward
250
261
  end
251
262
  end
252
263
 
264
+ def open_project_browser_image(path)
265
+ full_path = File.expand_path(path.to_s, prompt_workspace_root)
266
+ part = Kward::ImageAttachments.image_part_from_path(full_path)
267
+ unless part
268
+ @project_browser_state[:status] = "Cannot preview #{path}: unreadable or larger than 20 MB"
269
+ return false
270
+ end
271
+
272
+ protocol = terminal_image_protocol
273
+ unless protocol
274
+ @project_browser_state[:status] = "Cannot preview #{path}: terminal does not support inline images"
275
+ return false
276
+ end
277
+
278
+ part = Kward::ImageAttachments.terminal_image_part(part, protocol)
279
+ unless part
280
+ @project_browser_state[:status] = "Cannot preview #{path}: terminal cannot render this image format"
281
+ return false
282
+ end
283
+
284
+ image_id = next_terminal_image_id
285
+ columns = image_viewer_columns(screen_width)
286
+ rows = image_viewer_preview_rows(screen_height, width: screen_width)
287
+ terminal_columns, terminal_rows = image_viewer_terminal_size(part, protocol, columns, rows)
288
+ sequence = Kward::ImageAttachments.terminal_image_sequence(
289
+ part,
290
+ width: terminal_columns,
291
+ height: terminal_rows,
292
+ protocol: protocol,
293
+ image_id: image_id,
294
+ move_cursor: false,
295
+ env: ENV
296
+ )
297
+ unless sequence
298
+ @project_browser_state[:status] = "Cannot preview #{path}: terminal cannot render this image format"
299
+ return false
300
+ end
301
+
302
+ @image_viewer_state = {
303
+ path: full_path,
304
+ display_path: path.to_s,
305
+ protocol: protocol,
306
+ image_id: image_id,
307
+ part: part,
308
+ zoom: 1.0,
309
+ columns: columns,
310
+ rows: rows,
311
+ sequence: sequence
312
+ }
313
+ @pending_keys.clear
314
+ @asking = true
315
+ true
316
+ end
317
+
318
+ def close_image_viewer
319
+ return false unless image_viewer_active?
320
+
321
+ clear_image_viewer_output_locked
322
+ @image_viewer_state = nil
323
+ sync_project_browser_query_input
324
+ @asking = true
325
+ true
326
+ end
327
+
328
+ def terminal_image_protocol
329
+ return @terminal_image_protocol if @terminal_image_protocol
330
+
331
+ detected = TerminalImageSupport.detect(input: @input_io, output: @output_io)
332
+ @terminal_image_protocol = detected if detected
333
+ detected
334
+ end
335
+
336
+ def next_terminal_image_id
337
+ @next_terminal_image_id ||= 0
338
+ @next_terminal_image_id += 1
339
+ @next_terminal_image_id = 1 if @next_terminal_image_id > 4_294_967_295
340
+ @next_terminal_image_id
341
+ end
342
+
343
+ def image_viewer_zoom
344
+ @image_viewer_state&.fetch(:zoom, 1.0) || 1.0
345
+ end
346
+
347
+ def zoom_image_viewer(direction)
348
+ return false unless image_viewer_active?
349
+
350
+ delta = direction == :in ? IMAGE_VIEWER_ZOOM_STEP : -IMAGE_VIEWER_ZOOM_STEP
351
+ zoom = [[image_viewer_zoom + delta, IMAGE_VIEWER_MIN_ZOOM].max, IMAGE_VIEWER_MAX_ZOOM].min
352
+ @image_viewer_state[:zoom] = zoom.round(2)
353
+ @asking = true
354
+ true
355
+ end
356
+
357
+ def image_viewer_columns(width = screen_width)
358
+ available_columns = [width - 4, 1].max
359
+ desired_columns = [(ImageAttachments::DEFAULT_TERMINAL_IMAGE_WIDTH.to_i * image_viewer_zoom).round, 1].max
360
+ [desired_columns, available_columns].min
361
+ end
362
+
363
+ def image_viewer_sequence(width, height)
364
+ columns = image_viewer_columns(width)
365
+ rows = image_viewer_preview_rows(height, width: width)
366
+ state = @image_viewer_state
367
+ return state[:sequence] if state[:columns] == columns && state[:rows] == rows
368
+
369
+ terminal_columns, terminal_rows = image_viewer_terminal_size(state[:part], state[:protocol], columns, rows)
370
+ state[:columns] = columns
371
+ state[:rows] = rows
372
+ sequence = Kward::ImageAttachments.terminal_image_sequence(
373
+ state[:part],
374
+ width: terminal_columns,
375
+ height: terminal_rows,
376
+ protocol: state[:protocol],
377
+ image_id: state[:image_id],
378
+ move_cursor: false,
379
+ env: ENV
380
+ )
381
+ delete_sequence = image_viewer_delete_sequence(state[:image_id]) if state[:protocol] == TerminalImageSupport::KITTY
382
+ state[:sequence] = "#{delete_sequence}#{sequence}"
383
+ end
384
+
385
+ def image_viewer_terminal_size(part, protocol, columns, rows)
386
+ return [columns, rows] unless protocol == TerminalImageSupport::KITTY
387
+
388
+ dimensions = Kward::ImageAttachments.png_dimensions(part)
389
+ return [nil, rows] unless dimensions
390
+
391
+ image_width, image_height = dimensions
392
+ image_aspect_ratio = image_width.to_f / image_height
393
+ available_aspect_ratio = columns.to_f / (rows * IMAGE_VIEWER_CELL_HEIGHT_TO_WIDTH)
394
+ image_aspect_ratio > available_aspect_ratio ? [columns, nil] : [nil, rows]
395
+ end
396
+
397
+ def image_viewer_delete_sequence(image_id)
398
+ sequence = TerminalSequences.kitty_delete(image_id)
399
+ ENV["TMUX"].to_s.empty? ? sequence : TerminalSequences.tmux_passthrough(sequence)
400
+ end
401
+
402
+ def clear_image_viewer_output_locked
403
+ return unless image_viewer_active?
404
+ return unless @image_viewer_state[:protocol] == TerminalImageSupport::KITTY
405
+ return unless @started
406
+
407
+ print_output_locked(image_viewer_delete_sequence(@image_viewer_state[:image_id]))
408
+ end
409
+
253
410
  def expand_selected_project_browser_row
254
411
  row = selected_project_browser_row
255
412
  return false unless row&.fetch(:directory, false)
@@ -318,7 +475,8 @@ module Kward
318
475
  expanded: restored_project_browser_expanded_paths(paths, saved_state),
319
476
  selection_index: 0,
320
477
  search_active: false,
321
- query: ""
478
+ query: "",
479
+ status: nil
322
480
  }
323
481
  restore_project_browser_selection(saved_state["selected_path"])
324
482
  end
@@ -341,6 +499,7 @@ module Kward
341
499
  lines << overlay_choice_line(project_browser_row_text(row), selected: index == @project_browser_state[:selection_index])
342
500
  end
343
501
  end
502
+ lines << overlay_text_line(@project_browser_state[:status], :muted) if @project_browser_state[:status]
344
503
  lines << overlay_blank_line
345
504
  lines << overlay_text_line(project_browser_help_text, :muted)
346
505
  overlay_card_rows(title, lines, width)
@@ -7,12 +7,12 @@ module Kward
7
7
  private
8
8
 
9
9
  def render_prompt_locked(synchronized: false)
10
- return unless @started && @asking
10
+ return unless @started && @asking && !terminal_owned_by_child_locked?
11
11
 
12
12
  width, height = screen_size
13
13
  if width != @last_width || height != @last_height
14
14
  with_synchronized_output_locked { render_prompt_body_locked }
15
- @output_io.flush
15
+ flush_output_locked
16
16
  return
17
17
  end
18
18
 
@@ -23,10 +23,12 @@ module Kward
23
23
  else
24
24
  render_prompt_layout_locked(rows, cursor_row, cursor_col, width, height)
25
25
  end
26
- @output_io.flush
26
+ flush_output_locked
27
27
  end
28
28
 
29
29
  def render_prompt_body_locked
30
+ return if terminal_owned_by_child_locked?
31
+
30
32
  handle_resize_locked
31
33
  width, height = screen_size
32
34
  rows, cursor_row, cursor_col = composer_layout(width, height)
@@ -49,6 +51,8 @@ module Kward
49
51
  end
50
52
 
51
53
  def clear_prompt_locked
54
+ return if terminal_owned_by_child_locked?
55
+
52
56
  handle_resize_locked
53
57
  width, height = screen_size
54
58
  clear_composer_region_locked(height: height)
@@ -58,6 +62,8 @@ module Kward
58
62
  end
59
63
 
60
64
  def clear_prompt_for_output_locked
65
+ return if terminal_owned_by_child_locked?
66
+
61
67
  handle_resize_locked
62
68
  width, height = screen_size
63
69
  reserve_composer_region_locked(width: width, height: height) if @started && @asking
@@ -68,6 +74,8 @@ module Kward
68
74
  end
69
75
 
70
76
  def prepare_transcript_output_locked
77
+ return if terminal_owned_by_child_locked?
78
+
71
79
  handle_resize_locked
72
80
  width, height = screen_size
73
81
  hide_cursor_for_transcript_output_locked
@@ -76,7 +84,7 @@ module Kward
76
84
  end
77
85
 
78
86
  def restore_composer_cursor_locked
79
- return unless @started && @asking
87
+ return unless @started && @asking && !terminal_owned_by_child_locked?
80
88
 
81
89
  width, height = screen_size
82
90
  _rows, cursor_row, cursor_col = composer_layout(width, height)
@@ -85,10 +93,11 @@ module Kward
85
93
  end
86
94
 
87
95
  def redraw_screen_locked(width: screen_width, height: screen_height)
88
- return unless @started
96
+ return unless @started && !terminal_owned_by_child_locked?
89
97
 
98
+ clear_image_viewer_output_locked if image_viewer_active?
90
99
  restore_scroll_region_locked
91
- @output_io.print(TTY::Cursor.clear_screen)
100
+ print_output_locked(TTY::Cursor.clear_screen)
92
101
  move_to_screen(1, 1)
93
102
  @reserved_rows = 0
94
103
  @last_composer_rows = []
@@ -76,7 +76,7 @@ module Kward
76
76
  @steered_count = saved_state[:steered_count]
77
77
  @pending_keys = saved_state[:pending_keys]
78
78
  render_prompt_locked if @started && @asking
79
- @output_io.flush
79
+ flush_output_locked
80
80
  end
81
81
  end
82
82