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
@@ -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
 
@@ -95,13 +118,15 @@ module Kward
95
118
  old_top = [height - old_reserved_rows + 1, 1].max
96
119
  @reserved_rows = new_reserved_rows
97
120
  new_top = composer_top_row(height)
98
- @output_io.print(TerminalSequences.scroll_region(1, transcript_bottom_row(height)))
121
+ print_output_locked(TerminalSequences.scroll_region(1, transcript_bottom_row(height)))
99
122
  clear_screen_rows_locked(old_top, new_top - 1) if new_top > old_top
100
123
  @last_composer_rows = []
101
124
  redraw_transcript_locked(width: width, height: height) if redraw_transcript && new_reserved_rows < old_reserved_rows
102
125
  end
103
126
 
104
127
  def handle_resize_locked
128
+ return false if terminal_owned_by_child_locked?
129
+
105
130
  current_width, current_height = screen_size
106
131
  return false if current_width == @last_width && current_height == @last_height
107
132
 
@@ -122,7 +147,7 @@ module Kward
122
147
  end
123
148
 
124
149
  def restore_scroll_region_locked
125
- @output_io.print(TerminalSequences.restore_scroll_region)
150
+ print_output_locked(TerminalSequences.restore_scroll_region)
126
151
  @reserved_rows = 0
127
152
  end
128
153
 
@@ -138,15 +163,15 @@ module Kward
138
163
 
139
164
  move_to_screen(top + index, 1)
140
165
  if row
141
- @output_io.print(row)
166
+ print_output_locked(row)
142
167
  else
143
- @output_io.print(TTY::Cursor.clear_line)
168
+ print_output_locked(TTY::Cursor.clear_line)
144
169
  end
145
170
  end
146
171
 
147
172
  rows.length.upto(rows.length + rows_to_clear - 1) do |index|
148
173
  move_to_screen(top + index, 1)
149
- @output_io.print(TTY::Cursor.clear_line)
174
+ print_output_locked(TTY::Cursor.clear_line)
150
175
  end
151
176
 
152
177
  @last_composer_rows = rows.dup
@@ -186,12 +211,12 @@ module Kward
186
211
  def clear_screen_rows_locked(top, bottom)
187
212
  top.upto(bottom) do |row|
188
213
  move_to_screen(row, 1)
189
- @output_io.print(TTY::Cursor.clear_line)
214
+ print_output_locked(TTY::Cursor.clear_line)
190
215
  end
191
216
  end
192
217
 
193
218
  def move_to_screen(row, col)
194
- @output_io.print(TerminalSequences.move_to(row, col))
219
+ print_output_locked(TerminalSequences.move_to(row, col))
195
220
  end
196
221
 
197
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
 
@@ -4,6 +4,8 @@ require "pathname"
4
4
  require "rbconfig"
5
5
  require "thread"
6
6
  require_relative "project_files"
7
+ require_relative "image_attachments"
8
+ require_relative "terminal_image_support"
7
9
  require_relative "prompt_history"
8
10
  require "tty-cursor"
9
11
  require "tty-reader"
@@ -68,6 +70,7 @@ module Kward
68
70
  FOOTER_REFRESH_INTERVAL = 1.0
69
71
  COMPOSER_STATUS_REFRESH_INTERVAL = 1.0
70
72
  COMPOSER_MAX_INPUT_ROWS = 6
73
+ IMAGE_VIEWER_MAX_ROWS = 8
71
74
  TRANSCRIPT_BUFFER_LIMIT = 200_000
72
75
  BANNER_MESSAGE = Banner::MESSAGE
73
76
 
@@ -128,9 +131,12 @@ module Kward
128
131
  end
129
132
  end
130
133
 
131
- def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, project_browser_icon_theme: "off", footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, workspace_root: Dir.pwd, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil)
134
+ def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, project_browser_icon_theme: "off", footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, workspace_root: Dir.pwd, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil, redraw_handler: nil)
132
135
  @input_io = input
133
136
  @output_io = output
137
+ # Logical state may change during a child handoff, but only the owner may render.
138
+ @terminal_owner = :kward
139
+ @terminal_handoff_mode = nil
134
140
  @reader = TTY::Reader.new(input: input, output: output, interrupt: :error)
135
141
  @mutex = Mutex.new
136
142
  @prompt_history = prompt_history
@@ -162,6 +168,10 @@ module Kward
162
168
  @restoring_transcript = false
163
169
  @pending_keys = []
164
170
  @completion_provider = nil
171
+ @completion_cycle = nil
172
+ @completion_overlay = nil
173
+ @redraw_handler = redraw_handler
174
+ @transcript_redraw_requested = false
165
175
  @original_console_mode = nil
166
176
  @raw_mode_active = false
167
177
  @slash_commands = normalize_slash_commands(slash_commands)
@@ -174,6 +184,8 @@ module Kward
174
184
  @file_editor_open_status = nil
175
185
  @file_mention_paths = nil
176
186
  @project_browser_state = nil
187
+ @image_viewer_state = nil
188
+ @terminal_image_protocol = nil
177
189
  @project_browser_restore_after_editor = false
178
190
  @editor_state = nil
179
191
  @interactive_state = nil
@@ -233,8 +245,8 @@ module Kward
233
245
  @started = true
234
246
  @asking = true
235
247
  disable_editor_mouse_reporting(force: true) unless editor_active?
236
- @output_io.print(KEYBOARD_PROTOCOL_ENABLE)
237
- @output_io.print(BRACKETED_PASTE_ENABLE)
248
+ print_output_locked(KEYBOARD_PROTOCOL_ENABLE)
249
+ print_output_locked(BRACKETED_PASTE_ENABLE)
238
250
  render_prompt_locked if render
239
251
  end
240
252
  end
@@ -243,39 +255,27 @@ module Kward
243
255
  @mutex.synchronize do
244
256
  return unless @started
245
257
 
258
+ clear_image_viewer_output_locked if image_viewer_active?
246
259
  clear_prompt_for_output_locked
247
260
  restore_scroll_region_locked
248
261
  disable_editor_mouse_reporting(force: true)
249
- @output_io.print(BRACKETED_PASTE_RESTORE)
250
- @output_io.print(KEYBOARD_PROTOCOL_RESTORE)
262
+ print_output_locked(BRACKETED_PASTE_RESTORE)
263
+ print_output_locked(KEYBOARD_PROTOCOL_RESTORE)
251
264
  restore_editor_cursor_shape_locked
252
265
  set_cursor_visible_locked(true, force: true)
253
- @output_io.puts
254
- @output_io.flush
266
+ puts_output_locked
267
+ flush_output_locked
255
268
  @started = false
256
269
  restore_console_mode_locked
257
270
  end
258
271
  end
259
272
 
260
273
  def say(message)
261
- @mutex.synchronize do
262
- text = message.to_s
263
- if @restoring_transcript
264
- write_transcript_text_locked(text)
265
- write_transcript_text_locked("\n") unless text.end_with?("\n")
266
- @stream_state.finish_block
267
- next
268
- end
274
+ write_transcript_message(message, preserve_composer: false)
275
+ end
269
276
 
270
- with_synchronized_output_locked do
271
- clear_prompt_for_output_locked
272
- write_transcript_text_locked(text)
273
- write_transcript_text_locked("\n") unless text.end_with?("\n")
274
- @stream_state.finish_block
275
- render_prompt_after_output_locked
276
- end
277
- @output_io.flush
278
- end
277
+ def write_transcript(message)
278
+ write_transcript_message(message, preserve_composer: true)
279
279
  end
280
280
 
281
281
  def say_visual(message)
@@ -290,14 +290,14 @@ module Kward
290
290
  @stream_state.finish_block
291
291
  render_prompt_after_output_locked
292
292
  end
293
- @output_io.flush
293
+ flush_output_locked
294
294
  end
295
295
  end
296
296
 
297
297
  def restore_transcript
298
298
  start(render: false) unless @started
299
299
  @mutex.synchronize do
300
- @output_io.print(SYNCHRONIZED_OUTPUT_ENABLE)
300
+ print_output_locked(SYNCHRONIZED_OUTPUT_ENABLE)
301
301
  clear_prompt_for_output_locked unless @rendered_rows.zero? && @last_composer_rows.empty?
302
302
  @transcript_buffer.clear
303
303
  @transcript_viewport_rows = 0
@@ -312,8 +312,8 @@ module Kward
312
312
  @restoring_transcript = false
313
313
  width, height = screen_size
314
314
  redraw_screen_locked(width: width, height: height)
315
- @output_io.print(SYNCHRONIZED_OUTPUT_DISABLE)
316
- @output_io.flush
315
+ print_output_locked(SYNCHRONIZED_OUTPUT_DISABLE)
316
+ flush_output_locked
317
317
  end
318
318
  end
319
319
 
@@ -328,6 +328,16 @@ module Kward
328
328
  @slash_overlay_disabled = previous_slash_overlay_disabled
329
329
  end
330
330
 
331
+ def update_completion_provider(provider, slash_overlay: true)
332
+ @mutex.synchronize do
333
+ @completion_provider = provider
334
+ @completion_cycle = nil
335
+ @completion_overlay = nil
336
+ @slash_overlay_disabled = !slash_overlay
337
+ render_prompt_locked if @started
338
+ end
339
+ end
340
+
331
341
  def with_prompt_history(history)
332
342
  previous_history = @prompt_history
333
343
  @prompt_history = history
@@ -342,6 +352,10 @@ module Kward
342
352
  @mutex.synchronize { editor_active? }
343
353
  end
344
354
 
355
+ def inline_image_protocol
356
+ @mutex.synchronize { terminal_image_protocol }
357
+ end
358
+
345
359
  def update_workspace_root(root, prompt_history: nil)
346
360
  @mutex.synchronize do
347
361
  expanded_root = File.expand_path(root.to_s.empty? ? Dir.pwd : root)
@@ -435,6 +449,8 @@ module Kward
435
449
  reset_history_navigation
436
450
  end
437
451
  @pending_keys.clear
452
+ @completion_cycle = nil
453
+ @completion_overlay = nil
438
454
  @asking = true
439
455
  @busy = false
440
456
  @queued_count = 0
@@ -454,11 +470,14 @@ module Kward
454
470
  render_prompt_locked unless result.is_a?(String) || result == EXIT_INPUT || prompt_action_result?(result)
455
471
  end
456
472
  end
473
+ perform_pending_transcript_redraw
457
474
  return result if result.is_a?(String) || prompt_action_result?(result)
458
475
  return nil if result == EXIT_INPUT
459
476
 
460
477
  sleep 0.02 if key.nil?
461
478
  end
479
+ ensure
480
+ perform_pending_transcript_redraw
462
481
  end
463
482
 
464
483
  def yes?(message, default: false)
@@ -569,37 +588,59 @@ module Kward
569
588
  @interactive_state = nil
570
589
  restore_composer_snapshot_locked(snapshot)
571
590
  redraw_screen_locked if @started
572
- @output_io.flush
591
+ flush_output_locked
573
592
  end
574
593
  end
575
594
 
576
- def with_terminal_handoff
595
+ def with_inline_terminal_handoff(&block)
596
+ with_terminal_handoff(inline: true, &block)
597
+ end
598
+
599
+ def with_terminal_handoff(inline: false)
577
600
  start
578
601
  input = nil
579
602
  output = nil
603
+ transition = nil
580
604
  @mutex.synchronize do
581
- clear_prompt_for_output_locked
582
- restore_scroll_region_locked
605
+ if inline
606
+ handle_resize_locked
607
+ reserve_composer_region_locked
608
+ move_to_transcript_cursor_locked
609
+ else
610
+ clear_prompt_for_output_locked
611
+ restore_scroll_region_locked
612
+ end
583
613
  disable_editor_mouse_reporting(force: true)
584
- @output_io.print(BRACKETED_PASTE_RESTORE)
585
- @output_io.print(KEYBOARD_PROTOCOL_RESTORE)
614
+ print_output_locked(BRACKETED_PASTE_RESTORE)
615
+ print_output_locked(KEYBOARD_PROTOCOL_RESTORE)
586
616
  restore_editor_cursor_shape_locked
587
617
  set_cursor_visible_locked(true, force: true)
588
- @output_io.flush
618
+ flush_output_locked
589
619
  restore_console_mode_locked
590
620
  input = @input_io
591
621
  output = @output_io
622
+ transition = method(:switch_terminal_handoff_to_exclusive)
623
+ @terminal_handoff_mode = inline ? :inline : :exclusive
624
+ @terminal_owner = :child
592
625
  end
593
626
 
594
- yield(input, output)
627
+ yield(input, output, transition)
595
628
  ensure
596
629
  @mutex.synchronize do
630
+ @terminal_owner = :kward
631
+ @terminal_handoff_mode = nil
632
+ restore_scroll_region_locked
633
+ print_output_locked(TerminalSequences::SGR_RESET)
597
634
  enter_raw_mode_locked
598
- @output_io.print(KEYBOARD_PROTOCOL_ENABLE)
599
- @output_io.print(BRACKETED_PASTE_ENABLE)
635
+ print_output_locked(KEYBOARD_PROTOCOL_ENABLE)
636
+ print_output_locked(BRACKETED_PASTE_ENABLE)
637
+ disable_editor_mouse_reporting(force: true)
638
+ restore_editor_cursor_shape_locked(force: true)
639
+ @cursor_visible = nil
600
640
  @last_composer_rows = []
601
- render_prompt_locked if @started && @asking
602
- @output_io.flush
641
+ width, height = screen_size
642
+ with_synchronized_output_locked { redraw_screen_locked(width: width, height: height) }
643
+ flush_output_locked
603
644
  end
604
645
  end
605
646
 
@@ -683,6 +724,8 @@ module Kward
683
724
 
684
725
  def restore_composer_snapshot_locked(snapshot)
685
726
  @composer = snapshot[:composer] || new_composer_state_with_history
727
+ @completion_cycle = nil
728
+ @completion_overlay = nil
686
729
  @prompt_label = snapshot[:prompt_label].to_s.empty? ? "You>" : snapshot[:prompt_label].to_s
687
730
  self.composer_input = @composer.input
688
731
  self.composer_cursor = @composer.cursor
@@ -811,6 +854,8 @@ module Kward
811
854
  render_prompt_locked unless [EXIT_INPUT, CANCEL_INPUT].include?(result) || prompt_action_result?(result)
812
855
  [EXIT_INPUT, CANCEL_INPUT].include?(result) ? result : result
813
856
  end
857
+ ensure
858
+ perform_pending_transcript_redraw
814
859
  end
815
860
 
816
861
  def update_assistant_label(label)
@@ -833,7 +878,7 @@ module Kward
833
878
  @stream_state.finish_block
834
879
  restore_composer_cursor_locked
835
880
  end
836
- @output_io.flush
881
+ flush_output_locked
837
882
  end
838
883
  end
839
884
 
@@ -855,6 +900,23 @@ module Kward
855
900
  end
856
901
  end
857
902
 
903
+ def record_transient_terminal_output(text, render: true)
904
+ value = text.to_s
905
+ return if value.empty?
906
+
907
+ @mutex.synchronize do
908
+ append_transcript_buffer(value)
909
+ append_transcript_buffer("\n") unless value.end_with?("\n")
910
+ @stream_state.finish_block
911
+ width, height = screen_size
912
+ remember_transcript_viewport_locked(height)
913
+ next unless render
914
+
915
+ with_synchronized_output_locked { redraw_screen_locked(width: width, height: height) }
916
+ flush_output_locked
917
+ end
918
+ end
919
+
858
920
  def write_transcript_delta(delta)
859
921
  @mutex.synchronize do
860
922
  with_synchronized_output_locked do
@@ -862,7 +924,7 @@ module Kward
862
924
  write_transcript_text_locked(delta.to_s)
863
925
  restore_composer_cursor_locked unless @restoring_transcript
864
926
  end
865
- @output_io.flush unless @restoring_transcript
927
+ flush_output_locked unless @restoring_transcript
866
928
  end
867
929
  end
868
930
 
@@ -876,7 +938,7 @@ module Kward
876
938
  @mutex.synchronize do
877
939
  width, height = screen_size
878
940
  with_synchronized_output_locked { redraw_screen_locked(width: width, height: height) }
879
- @output_io.flush
941
+ flush_output_locked
880
942
  end
881
943
  end
882
944
 
@@ -898,12 +960,71 @@ module Kward
898
960
  @stream_state.reset
899
961
  width, height = screen_size
900
962
  with_synchronized_output_locked { redraw_screen_locked(width: width, height: height) }
901
- @output_io.flush
963
+ flush_output_locked
902
964
  end
903
965
  end
904
966
 
905
967
  private
906
968
 
969
+ def switch_terminal_handoff_to_exclusive
970
+ @mutex.synchronize do
971
+ return unless @terminal_owner == :child && @terminal_handoff_mode == :inline
972
+
973
+ @terminal_owner = :kward
974
+ with_synchronized_output_locked do
975
+ clear_prompt_for_output_locked
976
+ restore_scroll_region_locked
977
+ end
978
+ flush_output_locked
979
+ @terminal_handoff_mode = :exclusive
980
+ @terminal_owner = :child
981
+ end
982
+ end
983
+
984
+ def write_transcript_message(message, preserve_composer:)
985
+ @mutex.synchronize do
986
+ text = message.to_s
987
+ if @restoring_transcript
988
+ write_transcript_text_locked(text)
989
+ write_transcript_text_locked("\n") unless text.end_with?("\n")
990
+ @stream_state.finish_block
991
+ next
992
+ end
993
+
994
+ with_synchronized_output_locked do
995
+ if preserve_composer
996
+ prepare_transcript_output_locked
997
+ else
998
+ clear_prompt_for_output_locked
999
+ end
1000
+ write_transcript_text_locked(text)
1001
+ write_transcript_text_locked("\n") unless text.end_with?("\n")
1002
+ @stream_state.finish_block
1003
+ preserve_composer ? restore_composer_cursor_locked : render_prompt_after_output_locked
1004
+ end
1005
+ flush_output_locked
1006
+ end
1007
+ end
1008
+
1009
+ def request_transcript_redraw
1010
+ @transcript_redraw_requested = true
1011
+ end
1012
+
1013
+ def perform_pending_transcript_redraw
1014
+ requested = @mutex.synchronize do
1015
+ pending = @transcript_redraw_requested
1016
+ @transcript_redraw_requested = false
1017
+ pending
1018
+ end
1019
+ return unless requested
1020
+
1021
+ if @redraw_handler
1022
+ @redraw_handler.call
1023
+ else
1024
+ redraw
1025
+ end
1026
+ end
1027
+
907
1028
  def prompt_workspace_root
908
1029
  @workspace_root
909
1030
  end
@@ -913,6 +1034,8 @@ module Kward
913
1034
  @file_mention_path_entries_paths = nil
914
1035
  @file_mention_path_entries = nil
915
1036
  @project_browser_state = nil
1037
+ @image_viewer_state = nil
1038
+ @terminal_image_protocol = nil
916
1039
  @project_browser_tree_paths = nil
917
1040
  @project_browser_tree = nil
918
1041
  @file_overlay_dismissed_token = nil
@@ -28,6 +28,7 @@ module Kward
28
28
  { name: "diff", description: "Open the file changes recorded in this session.", argument_hint: "" },
29
29
  { name: "files", description: "Browse project files.", argument_hint: "" },
30
30
  { name: "shell", description: "Open the embedded Kward shell.", argument_hint: "" },
31
+ { name: "capture", description: "Run a command with bounded transcript capture.", argument_hint: "<command>" },
31
32
  { name: "scratchpad", description: "Open an unsaved editor buffer.", argument_hint: "[text|markdown|ruby]" },
32
33
  { name: "pty", description: "Run a command in an interactive PTY passthrough session.", argument_hint: "<command>" },
33
34
  { name: "tab", description: "Manage tabs.", argument_hint: "[1-n|move|close|new|name|worktree]" },
@@ -36,6 +37,7 @@ module Kward
36
37
  { name: "sandbox", description: "Inspect or configure model command sandboxing.", argument_hint: "[status|off|read_only|workspace_write|network allow|network deny]" },
37
38
  { name: "hooks", description: "Inspect lifecycle hooks.", argument_hint: "[list|events|logs|doctor|trust|untrust]" },
38
39
  { name: "skill", description: "Activate a skill or capture one from a saved session.", argument_hint: "<name>|capture" },
40
+ { name: "skills", description: "Review project skill trust.", argument_hint: "[status|trust|untrust]" },
39
41
  { name: "memory", description: "Inspect and manage Kward memory.", argument_hint: "[enable|disable|auto-summary|core|add|list|forget|promote|relax|inspect|why|summarize]" }
40
42
  ].freeze
41
43
  BUILTIN_RESERVED_COMMAND_NAMES = BUILTIN_COMMANDS.map { |command| command[:name] }.freeze
data/lib/kward/prompts.rb CHANGED
@@ -22,22 +22,22 @@ module Kward
22
22
  # @param plugin_context [String, nil] trusted plugin-provided instructions
23
23
  # @return [Hash] role/content system message
24
24
  # @api public
25
- def system_message(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil, execution_profile_context: nil)
25
+ def system_message(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil, execution_profile_context: nil, project_skill_paths: nil)
26
26
  {
27
27
  role: "system",
28
- content: prompt_parts(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context, execution_profile_context: execution_profile_context).compact.join("\n\n")
28
+ content: prompt_parts(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context, execution_profile_context: execution_profile_context, project_skill_paths: project_skill_paths).compact.join("\n\n")
29
29
  }
30
30
  end
31
31
 
32
- def prompt_parts(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil, execution_profile_context: nil)
33
- prompt_sections(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context, execution_profile_context: execution_profile_context).map { |section| section[:content] }
32
+ def prompt_parts(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil, execution_profile_context: nil, project_skill_paths: nil)
33
+ prompt_sections(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context, execution_profile_context: execution_profile_context, project_skill_paths: project_skill_paths).map { |section| section[:content] }
34
34
  end
35
35
 
36
36
  # Returns labeled prompt sections for inspection by CLI and RPC frontends.
37
37
  #
38
38
  # @return [Array<Hash>] section hashes with label, content, and optional source
39
39
  # @api public
40
- def prompt_sections(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil, execution_profile_context: nil)
40
+ def prompt_sections(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil, execution_profile_context: nil, project_skill_paths: nil)
41
41
  return replacement_prompt_sections if ConfigFiles.replacement_system_prompt?
42
42
 
43
43
  sections = [prompt_section("Built-in system prompt", base_prompt)]
@@ -48,7 +48,7 @@ module Kward
48
48
  sections << prompt_section("Persona", persona_prompt(workspace_root, model: model, reasoning_effort: reasoning_effort, now: now))
49
49
  sections << prompt_section("Plugin context", plugin_context) unless plugin_context.to_s.empty?
50
50
  end
51
- skills = ConfigFiles.skills(workspace_root: workspace_root)
51
+ skills = ConfigFiles.skills(workspace_root: workspace_root, project_skill_paths: project_skill_paths)
52
52
  sections << prompt_section("Configured skills", skills_prompt(skills), source: skills.empty? ? nil : File.join(ConfigFiles.config_dir, "skills"))
53
53
  sections << prompt_section(workspace_agents_context_label(workspace_root), workspace_agents_context(workspace_root), source: ConfigFiles.workspace_agents_file?(workspace_root) ? ConfigFiles.workspace_agents_path(workspace_root) : nil)
54
54
  sections.compact