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
@@ -265,6 +265,33 @@ module Kward
265
265
  @composer.cursor_logical_position
266
266
  end
267
267
 
268
+ def image_viewer_preview_rows(height, width: screen_width)
269
+ available_rows = max_project_browser_rows(height, width: width)
270
+ desired_rows = [(IMAGE_VIEWER_MAX_ROWS * image_viewer_zoom).round, 1].max
271
+ [available_rows, desired_rows].min
272
+ end
273
+
274
+ def image_viewer_rows(width, height: screen_height)
275
+ content_width = [width - 4, 1].max
276
+ preview_rows = image_viewer_preview_rows(height, width: width)
277
+ title = visible_truncate(" Image: #{@image_viewer_state[:display_path]} ", content_width)
278
+ rows = [
279
+ colored("╭", :primary_green) + title + colored("─" * [width - ANSI.strip(title).length - 2, 0].max, :primary_green) + colored("╮", :primary_green),
280
+ image_viewer_graphic_row(width, image_viewer_sequence(width, height))
281
+ ]
282
+ rows.concat(Array.new(preview_rows - 1) { box_content_row("", content_width) })
283
+ zoom = (image_viewer_zoom * 100).round
284
+ rows << footer_row(content_width, "Read-only image preview · #{zoom}% · +/- zoom · Esc/Q close")
285
+ rows << bottom_border(width)
286
+ rows
287
+ end
288
+
289
+ def image_viewer_graphic_row(width, sequence)
290
+ left_border = "#{colored("│", :primary_green)} "
291
+ right_border = "#{TerminalSequences.move_to_column(width)}#{colored("│", :primary_green)}"
292
+ "#{TTY::Cursor.clear_line}#{left_border}#{sequence}#{right_border}"
293
+ end
294
+
268
295
  end
269
296
  end
270
297
  end
@@ -992,8 +992,8 @@ module Kward
992
992
  return false if text.empty?
993
993
 
994
994
  @editor_state.push_kill(text)
995
- @output_io.print(TerminalSequences.osc52(text))
996
- @output_io.flush if @output_io.respond_to?(:flush)
995
+ print_output_locked(TerminalSequences.osc52(text))
996
+ flush_output_locked if @output_io.respond_to?(:flush)
997
997
  @editor_state.clear_selection
998
998
  @editor_state.status = "Copied selection"
999
999
  true
@@ -1103,16 +1103,16 @@ module Kward
1103
1103
  def enable_editor_mouse_reporting
1104
1104
  return if @editor_mouse_reporting_enabled
1105
1105
 
1106
- @output_io.print(TerminalSequences::MOUSE_REPORTING_ENABLE)
1107
- @output_io.flush if @output_io.respond_to?(:flush)
1106
+ print_output_locked(TerminalSequences::MOUSE_REPORTING_ENABLE)
1107
+ flush_output_locked if @output_io.respond_to?(:flush)
1108
1108
  @editor_mouse_reporting_enabled = true
1109
1109
  end
1110
1110
 
1111
1111
  def disable_editor_mouse_reporting(force: false)
1112
1112
  return unless force || @editor_mouse_reporting_enabled
1113
1113
 
1114
- @output_io.print(TerminalSequences::MOUSE_REPORTING_DISABLE)
1115
- @output_io.flush if @output_io.respond_to?(:flush)
1114
+ print_output_locked(TerminalSequences::MOUSE_REPORTING_DISABLE)
1115
+ flush_output_locked if @output_io.respond_to?(:flush)
1116
1116
  @editor_mouse_reporting_enabled = false
1117
1117
  end
1118
1118
 
@@ -240,8 +240,8 @@ module Kward
240
240
  end
241
241
 
242
242
  @editor_state.copy_for_kill_ring(range[0], range[1])
243
- @output_io.print(TerminalSequences.osc52(@editor_state.kill_buffer))
244
- @output_io.flush if @output_io.respond_to?(:flush)
243
+ print_output_locked(TerminalSequences.osc52(@editor_state.kill_buffer))
244
+ flush_output_locked if @output_io.respond_to?(:flush)
245
245
  @editor_state.clear_selection
246
246
  @editor_state.status = "Copied region"
247
247
  true
@@ -2239,8 +2239,8 @@ module Kward
2239
2239
  def vibe_copy_range(start_index, end_index, status, linewise: false)
2240
2240
  @editor_state.copy_range(start_index, end_index)
2241
2241
  @editor_state.vibe_kill_linewise = linewise
2242
- @output_io.print(TerminalSequences.osc52(@editor_state.kill_buffer))
2243
- @output_io.flush if @output_io.respond_to?(:flush)
2242
+ print_output_locked(TerminalSequences.osc52(@editor_state.kill_buffer))
2243
+ flush_output_locked if @output_io.respond_to?(:flush)
2244
2244
  @editor_state.status = status
2245
2245
  end
2246
2246
 
@@ -262,7 +262,7 @@ module Kward
262
262
  self.composer_cursor = 0
263
263
  @asking = true
264
264
  render_prompt_locked
265
- @output_io.flush
265
+ flush_output_locked
266
266
  end
267
267
  end
268
268
 
@@ -27,9 +27,31 @@ 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?
@@ -10,6 +10,7 @@ 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?
14
15
  return completion_overlay_rows(width, height: height) if @completion_overlay
15
16
  return history_search_overlay_rows(width, height: height) if history_search_active?
@@ -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
 
@@ -6,6 +6,28 @@ module Kward
6
6
  module Screen
7
7
  private
8
8
 
9
+ def terminal_owned_by_child_locked?
10
+ @terminal_owner == :child
11
+ end
12
+
13
+ def print_output_locked(*values)
14
+ return if terminal_owned_by_child_locked?
15
+
16
+ values.each { |value| @output_io.print(value) }
17
+ end
18
+
19
+ def puts_output_locked(*values)
20
+ return if terminal_owned_by_child_locked?
21
+
22
+ @output_io.puts(*values)
23
+ end
24
+
25
+ def flush_output_locked
26
+ return if terminal_owned_by_child_locked?
27
+
28
+ @output_io.flush
29
+ end
30
+
9
31
  def enter_raw_mode_locked
10
32
  return unless @input_io.respond_to?(:tty?) && @input_io.tty?
11
33
  return unless @input_io.respond_to?(:console_mode) && @input_io.respond_to?(:console_mode=)
@@ -31,19 +53,19 @@ module Kward
31
53
  end
32
54
 
33
55
  def with_synchronized_output_locked
34
- if @restoring_transcript || @synchronized_output_depth.positive?
56
+ if @restoring_transcript || @synchronized_output_depth.positive? || terminal_owned_by_child_locked?
35
57
  yield
36
58
  return
37
59
  end
38
60
 
39
61
  synchronized = true
40
62
  @synchronized_output_depth += 1
41
- @output_io.print(SYNCHRONIZED_OUTPUT_ENABLE)
63
+ print_output_locked(SYNCHRONIZED_OUTPUT_ENABLE)
42
64
  yield
43
65
  ensure
44
66
  if synchronized
45
67
  @synchronized_output_depth -= 1
46
- @output_io.print(SYNCHRONIZED_OUTPUT_DISABLE) if @synchronized_output_depth.zero?
68
+ print_output_locked(SYNCHRONIZED_OUTPUT_DISABLE) if @synchronized_output_depth.zero?
47
69
  end
48
70
  end
49
71
 
@@ -58,27 +80,28 @@ module Kward
58
80
  visible = select_editing_active? if @select_state
59
81
  visible = git_composing? if @git_state
60
82
  visible = project_browser_search_active? if project_browser_visible?
83
+ visible = false if image_viewer_active?
61
84
  set_cursor_visible_locked(visible)
62
85
  end
63
86
 
64
87
  def set_cursor_visible_locked(visible, force: false)
65
88
  return if !force && @cursor_visible == visible
66
89
 
67
- @output_io.print(visible ? CURSOR_SHOW : CURSOR_HIDE)
90
+ print_output_locked(visible ? CURSOR_SHOW : CURSOR_HIDE)
68
91
  @cursor_visible = visible
69
92
  end
70
93
 
71
94
  def set_editor_bar_cursor_locked
72
95
  return if @editor_bar_cursor_active
73
96
 
74
- @output_io.print(CURSOR_SHAPE_BAR)
97
+ print_output_locked(CURSOR_SHAPE_BAR)
75
98
  @editor_bar_cursor_active = true
76
99
  end
77
100
 
78
- def restore_editor_cursor_shape_locked
79
- return unless @editor_bar_cursor_active
101
+ def restore_editor_cursor_shape_locked(force: false)
102
+ return unless force || @editor_bar_cursor_active
80
103
 
81
- @output_io.print(CURSOR_SHAPE_DEFAULT)
104
+ print_output_locked(CURSOR_SHAPE_DEFAULT)
82
105
  @editor_bar_cursor_active = false
83
106
  end
84
107
 
@@ -87,11 +110,6 @@ module Kward
87
110
  ensure_scroll_region_locked(rows.length, width: width, height: height)
88
111
  end
89
112
 
90
- def make_room_for_composer_after_handoff_locked
91
- rows, = composer_layout(screen_width, screen_height)
92
- @output_io.print("\r\n" * rows.length)
93
- end
94
-
95
113
  def ensure_scroll_region_locked(row_count, redraw_transcript: true, width: screen_width, height: screen_height)
96
114
  new_reserved_rows = [[row_count, 1].max, [height - 1, 1].max].min
97
115
  return if @reserved_rows == new_reserved_rows && @last_height == height
@@ -100,13 +118,15 @@ module Kward
100
118
  old_top = [height - old_reserved_rows + 1, 1].max
101
119
  @reserved_rows = new_reserved_rows
102
120
  new_top = composer_top_row(height)
103
- @output_io.print(TerminalSequences.scroll_region(1, transcript_bottom_row(height)))
121
+ print_output_locked(TerminalSequences.scroll_region(1, transcript_bottom_row(height)))
104
122
  clear_screen_rows_locked(old_top, new_top - 1) if new_top > old_top
105
123
  @last_composer_rows = []
106
124
  redraw_transcript_locked(width: width, height: height) if redraw_transcript && new_reserved_rows < old_reserved_rows
107
125
  end
108
126
 
109
127
  def handle_resize_locked
128
+ return false if terminal_owned_by_child_locked?
129
+
110
130
  current_width, current_height = screen_size
111
131
  return false if current_width == @last_width && current_height == @last_height
112
132
 
@@ -127,7 +147,7 @@ module Kward
127
147
  end
128
148
 
129
149
  def restore_scroll_region_locked
130
- @output_io.print(TerminalSequences.restore_scroll_region)
150
+ print_output_locked(TerminalSequences.restore_scroll_region)
131
151
  @reserved_rows = 0
132
152
  end
133
153
 
@@ -143,15 +163,15 @@ module Kward
143
163
 
144
164
  move_to_screen(top + index, 1)
145
165
  if row
146
- @output_io.print(row)
166
+ print_output_locked(row)
147
167
  else
148
- @output_io.print(TTY::Cursor.clear_line)
168
+ print_output_locked(TTY::Cursor.clear_line)
149
169
  end
150
170
  end
151
171
 
152
172
  rows.length.upto(rows.length + rows_to_clear - 1) do |index|
153
173
  move_to_screen(top + index, 1)
154
- @output_io.print(TTY::Cursor.clear_line)
174
+ print_output_locked(TTY::Cursor.clear_line)
155
175
  end
156
176
 
157
177
  @last_composer_rows = rows.dup
@@ -191,12 +211,12 @@ module Kward
191
211
  def clear_screen_rows_locked(top, bottom)
192
212
  top.upto(bottom) do |row|
193
213
  move_to_screen(row, 1)
194
- @output_io.print(TTY::Cursor.clear_line)
214
+ print_output_locked(TTY::Cursor.clear_line)
195
215
  end
196
216
  end
197
217
 
198
218
  def move_to_screen(row, col)
199
- @output_io.print(TerminalSequences.move_to(row, col))
219
+ print_output_locked(TerminalSequences.move_to(row, col))
200
220
  end
201
221
 
202
222
  def screen_size
@@ -324,7 +324,7 @@ module Kward
324
324
  @select_state[:selection_index] = result[:selection_index].to_i if result.key?(:selection_index)
325
325
  end
326
326
  render_prompt_locked
327
- @output_io.flush
327
+ flush_output_locked
328
328
  end
329
329
  SELECT_CONTINUE
330
330
  end
@@ -342,7 +342,7 @@ module Kward
342
342
  @asking = true
343
343
  reset_spinner_locked
344
344
  render_prompt_locked
345
- @output_io.flush
345
+ flush_output_locked
346
346
  end
347
347
  end
348
348
 
@@ -352,7 +352,7 @@ module Kward
352
352
  @busy_activity = "streaming"
353
353
  @select_state&.delete(:busy_activity)
354
354
  render_prompt_locked if @asking
355
- @output_io.flush
355
+ flush_output_locked
356
356
  end
357
357
  end
358
358
 
@@ -362,7 +362,7 @@ module Kward
362
362
  spun = tick_spinner_locked
363
363
  footer_refreshed = tick_footer_locked
364
364
  render_prompt_locked if resized || spun || footer_refreshed
365
- @output_io.flush if resized || spun || footer_refreshed
365
+ flush_output_locked if resized || spun || footer_refreshed
366
366
  end
367
367
  end
368
368
 
@@ -656,7 +656,7 @@ module Kward
656
656
  self.composer_cursor = 0
657
657
  @asking = true
658
658
  render_prompt_locked if render
659
- @output_io.flush
659
+ flush_output_locked
660
660
  end
661
661
  end
662
662
 
@@ -19,7 +19,7 @@ module Kward
19
19
  @stream_state.finish_block if finish
20
20
  restore_composer_cursor_locked unless @restoring_transcript
21
21
  end
22
- @output_io.flush unless @restoring_transcript
22
+ flush_output_locked unless @restoring_transcript
23
23
  end
24
24
 
25
25
  def write_transcript_text_locked(text)
@@ -32,7 +32,7 @@ module Kward
32
32
  width, height = screen_size
33
33
  output_text = terminal_newlines(text.to_s)
34
34
  advance_pending_stream_wrap_locked(output_text, width: width, height: height)
35
- @output_io.print(output_text)
35
+ print_output_locked(output_text)
36
36
  update_stream_position(output_text, width: width)
37
37
  end
38
38
 
@@ -58,7 +58,7 @@ module Kward
58
58
  return if rows.empty?
59
59
 
60
60
  move_to_screen(1, 1)
61
- @output_io.print(terminal_newlines(rows.join("\n")))
61
+ print_output_locked(terminal_newlines(rows.join("\n")))
62
62
  end
63
63
 
64
64
  def transcript_viewport_rows(row_count, width)
@@ -98,7 +98,7 @@ module Kward
98
98
  return if output_text.empty? || output_text.start_with?("\r", "\n")
99
99
 
100
100
  move_to_screen(transcript_bottom_row(height), width)
101
- @output_io.print("\r\n")
101
+ print_output_locked("\r\n")
102
102
  @stream_state.clear_pending_wrap
103
103
  end
104
104