kward 0.81.0 → 0.83.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -1
  5. data/Rakefile +44 -4
  6. data/doc/agent-tools.md +1 -0
  7. data/doc/composer.md +2 -2
  8. data/doc/configuration.md +18 -3
  9. data/doc/editor.md +20 -10
  10. data/doc/files.md +12 -5
  11. data/doc/permissions.md +1 -0
  12. data/doc/releasing.md +79 -38
  13. data/doc/rpc.md +2 -2
  14. data/doc/sandboxing.md +5 -1
  15. data/doc/security.md +2 -2
  16. data/doc/shell.md +42 -18
  17. data/doc/tabs.md +3 -0
  18. data/doc/usage.md +3 -2
  19. data/kward.gemspec +1 -1
  20. data/lib/kward/adaptive_pty_output_sink.rb +183 -0
  21. data/lib/kward/agent.rb +1 -0
  22. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  23. data/lib/kward/cli/git.rb +5 -1
  24. data/lib/kward/cli/interactive_turn.rb +101 -19
  25. data/lib/kward/cli/rendering.rb +14 -2
  26. data/lib/kward/cli/runtime_helpers.rb +252 -46
  27. data/lib/kward/cli/settings.rb +5 -13
  28. data/lib/kward/cli/slash_commands.rb +12 -1
  29. data/lib/kward/cli/tabs.rb +71 -9
  30. data/lib/kward/cli.rb +12 -0
  31. data/lib/kward/compaction/token_estimator.rb +12 -6
  32. data/lib/kward/config_files.rb +17 -0
  33. data/lib/kward/editor_prompt.rb +46 -0
  34. data/lib/kward/editor_prompt_session.rb +28 -0
  35. data/lib/kward/ekwsh.rb +70 -10
  36. data/lib/kward/image_attachments.rb +98 -15
  37. data/lib/kward/interactive_pty_runner.rb +25 -19
  38. data/lib/kward/model/client.rb +1 -17
  39. data/lib/kward/model/model_info.rb +3 -2
  40. data/lib/kward/model/payloads.rb +0 -2
  41. data/lib/kward/persistent_shell_session.rb +750 -0
  42. data/lib/kward/project_files.rb +18 -5
  43. data/lib/kward/prompt_interface/composer_controller.rb +3 -3
  44. data/lib/kward/prompt_interface/composer_renderer.rb +28 -1
  45. data/lib/kward/prompt_interface/editor/auto_indent.rb +3 -0
  46. data/lib/kward/prompt_interface/editor/controller.rb +75 -6
  47. data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
  48. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  49. data/lib/kward/prompt_interface/editor/modes/vibe.rb +12 -6
  50. data/lib/kward/prompt_interface/editor/renderer.rb +1 -0
  51. data/lib/kward/prompt_interface/editor/state.rb +11 -0
  52. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  53. data/lib/kward/prompt_interface/file_overlay.rb +21 -7
  54. data/lib/kward/prompt_interface/git_prompt.rb +1 -1
  55. data/lib/kward/prompt_interface/key_handler.rb +30 -0
  56. data/lib/kward/prompt_interface/overlay_renderer.rb +1 -0
  57. data/lib/kward/prompt_interface/project_browser.rb +192 -8
  58. data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
  59. data/lib/kward/prompt_interface/question_prompt.rb +1 -1
  60. data/lib/kward/prompt_interface/runtime_state.rb +5 -1
  61. data/lib/kward/prompt_interface/screen.rb +40 -20
  62. data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
  63. data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
  64. data/lib/kward/prompt_interface.rb +159 -32
  65. data/lib/kward/prompts/commands.rb +1 -0
  66. data/lib/kward/pty_output_sink.rb +45 -0
  67. data/lib/kward/pty_transcript_normalizer.rb +93 -0
  68. data/lib/kward/rpc/auth_manager.rb +1 -1
  69. data/lib/kward/rpc/server.rb +2 -1
  70. data/lib/kward/session_catalog.rb +87 -0
  71. data/lib/kward/session_store.rb +94 -5
  72. data/lib/kward/shell_prompt.rb +50 -0
  73. data/lib/kward/shell_prompt_session.rb +58 -0
  74. data/lib/kward/terminal_image_support.rb +116 -0
  75. data/lib/kward/terminal_keys.rb +1 -0
  76. data/lib/kward/terminal_sequences.rb +43 -0
  77. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  78. data/lib/kward/tools/registry.rb +63 -5
  79. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  80. data/lib/kward/tools/run_shell_command.rb +24 -6
  81. data/lib/kward/version.rb +1 -1
  82. data/templates/default/layout/html/layout.erb +1 -1
  83. metadata +15 -4
  84. data/.github/workflows/ci.yml +0 -48
  85. data/.github/workflows/pages.yml +0 -48
@@ -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
@@ -44,14 +48,17 @@ module Kward
44
48
  end
45
49
 
46
50
  def open_project_browser_locked
47
- paths = project_file_paths
48
51
  saved_state = saved_project_browser_state
52
+ show_ignored = saved_state["show_ignored"] == true
53
+ paths = project_file_paths(include_ignored: show_ignored)
49
54
  @project_browser_state = {
50
55
  paths: paths,
56
+ show_ignored: show_ignored,
51
57
  expanded: restored_project_browser_expanded_paths(paths, saved_state),
52
58
  selection_index: 0,
53
59
  search_active: false,
54
- query: ""
60
+ query: "",
61
+ status: nil
55
62
  }
56
63
  restore_project_browser_selection(saved_state["selected_path"])
57
64
  self.composer_input = ""
@@ -63,7 +70,11 @@ module Kward
63
70
  private
64
71
 
65
72
  def project_browser_visible?
66
- !@project_browser_state.nil? && !editor_active?
73
+ !@project_browser_state.nil? && !editor_active? && !image_viewer_active?
74
+ end
75
+
76
+ def image_viewer_active?
77
+ !@image_viewer_state.nil?
67
78
  end
68
79
 
69
80
  def dismiss_project_browser
@@ -133,6 +144,8 @@ module Kward
133
144
  collapse_selected_project_browser_row
134
145
  elsif text == "l" && !project_browser_search_active?
135
146
  expand_selected_project_browser_row
147
+ elsif text == "i" && !project_browser_search_active?
148
+ toggle_project_browser_ignored_files
136
149
  elsif project_browser_search_active?
137
150
  project_browser_append_search(text)
138
151
  else
@@ -164,6 +177,8 @@ module Kward
164
177
  project_browser_search_active? ? project_browser_append_search(key) : collapse_selected_project_browser_row
165
178
  when "l"
166
179
  project_browser_search_active? ? project_browser_append_search(key) : expand_selected_project_browser_row
180
+ when "i"
181
+ project_browser_search_active? ? project_browser_append_search(key) : toggle_project_browser_ignored_files
167
182
  else
168
183
  project_browser_append_search(key) if project_browser_search_active? && printable_key?(key)
169
184
  end
@@ -181,6 +196,21 @@ module Kward
181
196
  project_browser_search_active? ? deactivate_project_browser_search : activate_project_browser_search
182
197
  end
183
198
 
199
+ def toggle_project_browser_ignored_files
200
+ selected_path = selected_project_browser_row&.[](:path)
201
+ show_ignored = !project_browser_ignored_files_visible?
202
+ @project_browser_state[:paths] = project_file_paths(include_ignored: show_ignored)
203
+ @project_browser_state[:show_ignored] = show_ignored
204
+ @project_browser_tree_paths = nil
205
+ @project_browser_tree = nil
206
+ restore_project_browser_selection(selected_path)
207
+ persist_project_browser_state
208
+ end
209
+
210
+ def project_browser_ignored_files_visible?
211
+ @project_browser_state[:show_ignored] == true
212
+ end
213
+
184
214
  def activate_project_browser_search
185
215
  @project_browser_state[:search_active] = true
186
216
  @project_browser_state[:query] = ""
@@ -243,6 +273,8 @@ module Kward
243
273
  if row[:directory]
244
274
  toggle_project_browser_directory(row[:path])
245
275
  true
276
+ elsif Kward::ImageAttachments.image_extension?(row[:path])
277
+ open_project_browser_image(row[:path])
246
278
  else
247
279
  persist_project_browser_state unless project_browser_search_active?
248
280
  @project_browser_restore_after_editor = true if open_editor(row[:path])
@@ -250,6 +282,152 @@ module Kward
250
282
  end
251
283
  end
252
284
 
285
+ def open_project_browser_image(path)
286
+ full_path = File.expand_path(path.to_s, prompt_workspace_root)
287
+ part = Kward::ImageAttachments.image_part_from_path(full_path)
288
+ unless part
289
+ @project_browser_state[:status] = "Cannot preview #{path}: unreadable or larger than 20 MB"
290
+ return false
291
+ end
292
+
293
+ protocol = terminal_image_protocol
294
+ unless protocol
295
+ @project_browser_state[:status] = "Cannot preview #{path}: terminal does not support inline images"
296
+ return false
297
+ end
298
+
299
+ part = Kward::ImageAttachments.terminal_image_part(part, protocol)
300
+ unless part
301
+ @project_browser_state[:status] = "Cannot preview #{path}: terminal cannot render this image format"
302
+ return false
303
+ end
304
+
305
+ image_id = next_terminal_image_id
306
+ columns = image_viewer_columns(screen_width)
307
+ rows = image_viewer_preview_rows(screen_height, width: screen_width)
308
+ terminal_columns, terminal_rows = image_viewer_terminal_size(part, protocol, columns, rows)
309
+ sequence = Kward::ImageAttachments.terminal_image_sequence(
310
+ part,
311
+ width: terminal_columns,
312
+ height: terminal_rows,
313
+ protocol: protocol,
314
+ image_id: image_id,
315
+ move_cursor: false,
316
+ env: ENV
317
+ )
318
+ unless sequence
319
+ @project_browser_state[:status] = "Cannot preview #{path}: terminal cannot render this image format"
320
+ return false
321
+ end
322
+
323
+ @image_viewer_state = {
324
+ path: full_path,
325
+ display_path: path.to_s,
326
+ protocol: protocol,
327
+ image_id: image_id,
328
+ part: part,
329
+ zoom: 1.0,
330
+ columns: columns,
331
+ rows: rows,
332
+ sequence: sequence
333
+ }
334
+ @pending_keys.clear
335
+ @asking = true
336
+ true
337
+ end
338
+
339
+ def close_image_viewer
340
+ return false unless image_viewer_active?
341
+
342
+ clear_image_viewer_output_locked
343
+ @image_viewer_state = nil
344
+ sync_project_browser_query_input
345
+ @asking = true
346
+ true
347
+ end
348
+
349
+ def terminal_image_protocol
350
+ return @terminal_image_protocol if @terminal_image_protocol
351
+
352
+ detected = TerminalImageSupport.detect(input: @input_io, output: @output_io)
353
+ @terminal_image_protocol = detected if detected
354
+ detected
355
+ end
356
+
357
+ def next_terminal_image_id
358
+ @next_terminal_image_id ||= 0
359
+ @next_terminal_image_id += 1
360
+ @next_terminal_image_id = 1 if @next_terminal_image_id > 4_294_967_295
361
+ @next_terminal_image_id
362
+ end
363
+
364
+ def image_viewer_zoom
365
+ @image_viewer_state&.fetch(:zoom, 1.0) || 1.0
366
+ end
367
+
368
+ def zoom_image_viewer(direction)
369
+ return false unless image_viewer_active?
370
+
371
+ delta = direction == :in ? IMAGE_VIEWER_ZOOM_STEP : -IMAGE_VIEWER_ZOOM_STEP
372
+ zoom = [[image_viewer_zoom + delta, IMAGE_VIEWER_MIN_ZOOM].max, IMAGE_VIEWER_MAX_ZOOM].min
373
+ @image_viewer_state[:zoom] = zoom.round(2)
374
+ @asking = true
375
+ true
376
+ end
377
+
378
+ def image_viewer_columns(width = screen_width)
379
+ available_columns = [width - 4, 1].max
380
+ desired_columns = [(ImageAttachments::DEFAULT_TERMINAL_IMAGE_WIDTH.to_i * image_viewer_zoom).round, 1].max
381
+ [desired_columns, available_columns].min
382
+ end
383
+
384
+ def image_viewer_sequence(width, height)
385
+ columns = image_viewer_columns(width)
386
+ rows = image_viewer_preview_rows(height, width: width)
387
+ state = @image_viewer_state
388
+ return state[:sequence] if state[:columns] == columns && state[:rows] == rows
389
+
390
+ terminal_columns, terminal_rows = image_viewer_terminal_size(state[:part], state[:protocol], columns, rows)
391
+ state[:columns] = columns
392
+ state[:rows] = rows
393
+ sequence = Kward::ImageAttachments.terminal_image_sequence(
394
+ state[:part],
395
+ width: terminal_columns,
396
+ height: terminal_rows,
397
+ protocol: state[:protocol],
398
+ image_id: state[:image_id],
399
+ move_cursor: false,
400
+ env: ENV
401
+ )
402
+ delete_sequence = image_viewer_delete_sequence(state[:image_id]) if state[:protocol] == TerminalImageSupport::KITTY
403
+ state[:sequence] = "#{delete_sequence}#{sequence}"
404
+ end
405
+
406
+ def image_viewer_terminal_size(part, protocol, columns, rows)
407
+ return [columns, rows] unless protocol == TerminalImageSupport::KITTY
408
+
409
+ dimensions = Kward::ImageAttachments.png_dimensions(part)
410
+ return [nil, rows] unless dimensions
411
+
412
+ image_width, image_height = dimensions
413
+ image_aspect_ratio = image_width.to_f / image_height
414
+ available_aspect_ratio = columns.to_f / (rows * IMAGE_VIEWER_CELL_HEIGHT_TO_WIDTH)
415
+ image_aspect_ratio > available_aspect_ratio ? [columns, nil] : [nil, rows]
416
+ end
417
+
418
+ def image_viewer_delete_sequence(image_id)
419
+ sequence = TerminalSequences.kitty_delete(image_id)
420
+ ENV["TMUX"].to_s.empty? ? sequence : TerminalSequences.tmux_passthrough(sequence)
421
+ end
422
+
423
+ def clear_image_viewer_output_locked
424
+ return unless image_viewer_active?
425
+ return unless @image_viewer_state[:protocol] == TerminalImageSupport::KITTY
426
+ return unless @started
427
+
428
+ print_output_locked(image_viewer_delete_sequence(@image_viewer_state[:image_id]))
429
+ end
430
+
253
431
  def expand_selected_project_browser_row
254
432
  row = selected_project_browser_row
255
433
  return false unless row&.fetch(:directory, false)
@@ -311,14 +489,17 @@ module Kward
311
489
 
312
490
  @project_browser_restore_after_editor = false
313
491
  unless @project_browser_state
314
- paths = project_file_paths
315
492
  saved_state = saved_project_browser_state
493
+ show_ignored = saved_state["show_ignored"] == true
494
+ paths = project_file_paths(include_ignored: show_ignored)
316
495
  @project_browser_state = {
317
496
  paths: paths,
497
+ show_ignored: show_ignored,
318
498
  expanded: restored_project_browser_expanded_paths(paths, saved_state),
319
499
  selection_index: 0,
320
500
  search_active: false,
321
- query: ""
501
+ query: "",
502
+ status: nil
322
503
  }
323
504
  restore_project_browser_selection(saved_state["selected_path"])
324
505
  end
@@ -341,6 +522,7 @@ module Kward
341
522
  lines << overlay_choice_line(project_browser_row_text(row), selected: index == @project_browser_state[:selection_index])
342
523
  end
343
524
  end
525
+ lines << overlay_text_line(@project_browser_state[:status], :muted) if @project_browser_state[:status]
344
526
  lines << overlay_blank_line
345
527
  lines << overlay_text_line(project_browser_help_text, :muted)
346
528
  overlay_card_rows(title, lines, width)
@@ -348,7 +530,8 @@ module Kward
348
530
 
349
531
  def project_browser_title
350
532
  query = @project_browser_state[:query].to_s
351
- project_browser_search_active? ? "Project files — Search: #{query}" : "Project files"
533
+ title = project_browser_search_active? ? "Project files — Search: #{query}" : "Project files"
534
+ project_browser_ignored_files_visible? ? "#{title} (including ignored)" : title
352
535
  end
353
536
 
354
537
  def project_browser_empty_message
@@ -359,7 +542,7 @@ module Kward
359
542
  if project_browser_search_active?
360
543
  "Type search • Esc tree • Enter open • @ mention"
361
544
  else
362
- "Enter open/toggle • ←/→ collapse/expand • Tab or / search • @ mention • Esc close"
545
+ "Enter open/toggle • i show/hide ignored • ←/→ collapse/expand • Tab or / search • @ mention • Esc close"
363
546
  end
364
547
  end
365
548
 
@@ -472,7 +655,8 @@ module Kward
472
655
  row = selected_project_browser_row
473
656
  workspaces[project_browser_workspace_root] = {
474
657
  "expanded" => @project_browser_state[:expanded].to_a.sort,
475
- "selected_path" => row && row[:path]
658
+ "selected_path" => row && row[:path],
659
+ "show_ignored" => project_browser_ignored_files_visible?
476
660
  }
477
661
  data["version"] = PROJECT_BROWSER_STATE_VERSION
478
662
  data["workspaces"] = workspaces
@@ -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
 
@@ -118,8 +118,12 @@ module Kward
118
118
  result.is_a?(Hash) && result[:reasoning_action]
119
119
  end
120
120
 
121
+ def editor_prompt_action?(result)
122
+ result.is_a?(Hash) && result[:editor_prompt]
123
+ end
124
+
121
125
  def prompt_action_result?(result)
122
- tab_action_result?(result) || reasoning_action_result?(result)
126
+ tab_action_result?(result) || reasoning_action_result?(result) || editor_prompt_action?(result)
123
127
  end
124
128
 
125
129
  end
@@ -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