kward 0.82.0 → 0.84.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/agent-tools.md +1 -0
  9. data/doc/api.md +4 -0
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +82 -20
  12. data/doc/editor.md +47 -22
  13. data/doc/files.md +12 -7
  14. data/doc/getting-started.md +3 -0
  15. data/doc/pan.md +19 -15
  16. data/doc/permissions.md +1 -0
  17. data/doc/platform-support.md +48 -0
  18. data/doc/releasing.md +12 -4
  19. data/doc/rpc.md +2 -2
  20. data/doc/sandboxing.md +5 -1
  21. data/doc/security.md +4 -3
  22. data/doc/shell.md +101 -60
  23. data/doc/tabs.md +3 -0
  24. data/doc/troubleshooting.md +12 -2
  25. data/doc/usage.md +7 -6
  26. data/kward.gemspec +5 -4
  27. data/lib/kward/agent.rb +7 -3
  28. data/lib/kward/ansi.rb +110 -10
  29. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  30. data/lib/kward/cli/auth_commands.rb +34 -13
  31. data/lib/kward/cli/commands.rb +83 -62
  32. data/lib/kward/cli/doctor.rb +39 -17
  33. data/lib/kward/cli/hook_commands.rb +22 -12
  34. data/lib/kward/cli/interactive_turn.rb +144 -21
  35. data/lib/kward/cli/project_skills_commands.rb +8 -4
  36. data/lib/kward/cli/prompt_interface.rb +27 -0
  37. data/lib/kward/cli/rendering.rb +15 -9
  38. data/lib/kward/cli/runtime_helpers.rb +260 -55
  39. data/lib/kward/cli/slash_commands.rb +21 -12
  40. data/lib/kward/cli/tabs.rb +91 -13
  41. data/lib/kward/cli/tool_summaries.rb +14 -0
  42. data/lib/kward/cli.rb +55 -7
  43. data/lib/kward/cli_transcript_formatter.rb +11 -4
  44. data/lib/kward/compaction/token_estimator.rb +12 -6
  45. data/lib/kward/config_files.rb +94 -63
  46. data/lib/kward/detached_run.rb +44 -0
  47. data/lib/kward/editor_prompt.rb +46 -0
  48. data/lib/kward/editor_prompt_session.rb +28 -0
  49. data/lib/kward/interactive_pty_runner.rb +102 -28
  50. data/lib/kward/{ekwsh.rb → kwsh.rb} +103 -24
  51. data/lib/kward/kwshrc.rb +233 -0
  52. data/lib/kward/markdown_code_block.rb +136 -0
  53. data/lib/kward/model/client.rb +35 -39
  54. data/lib/kward/model/model_info.rb +3 -2
  55. data/lib/kward/model/payloads.rb +0 -2
  56. data/lib/kward/model/provider_catalog.rb +5 -0
  57. data/lib/kward/model/stream_parser.rb +20 -4
  58. data/lib/kward/pan/index.html.erb +3 -3
  59. data/lib/kward/pan/server.rb +23 -3
  60. data/lib/kward/persistent_shell_session.rb +843 -0
  61. data/lib/kward/project_files.rb +19 -6
  62. data/lib/kward/prompt_interface/composer_renderer.rb +45 -41
  63. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  64. data/lib/kward/prompt_interface/editor/auto_indent.rb +27 -22
  65. data/lib/kward/prompt_interface/editor/controller.rb +99 -33
  66. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  67. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  68. data/lib/kward/prompt_interface/editor/modes/vibe.rb +298 -47
  69. data/lib/kward/prompt_interface/editor/renderer.rb +109 -6
  70. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  71. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/state.rb +21 -10
  73. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  74. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  75. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  76. data/lib/kward/prompt_interface/file_overlay.rb +92 -22
  77. data/lib/kward/prompt_interface/key_handler.rb +75 -0
  78. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  79. data/lib/kward/prompt_interface/project_browser.rb +443 -17
  80. data/lib/kward/prompt_interface/runtime_state.rb +55 -2
  81. data/lib/kward/prompt_interface/screen.rb +2 -2
  82. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  83. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  84. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  85. data/lib/kward/prompt_interface.rb +169 -18
  86. data/lib/kward/prompts/commands.rb +2 -1
  87. data/lib/kward/prompts.rb +1 -1
  88. data/lib/kward/pty_output_sink.rb +47 -0
  89. data/lib/kward/rpc/auth_manager.rb +1 -1
  90. data/lib/kward/rpc/server.rb +2 -1
  91. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  92. data/lib/kward/scratchpad_languages.rb +74 -0
  93. data/lib/kward/scratchpad_runner.rb +155 -29
  94. data/lib/kward/shell_prompt.rb +52 -0
  95. data/lib/kward/shell_prompt_session.rb +58 -0
  96. data/lib/kward/terminal_keys.rb +13 -0
  97. data/lib/kward/terminal_text.rb +121 -0
  98. data/lib/kward/text_matcher.rb +18 -0
  99. data/lib/kward/tools/open_editor.rb +41 -0
  100. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  101. data/lib/kward/tools/registry.rb +75 -8
  102. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  103. data/lib/kward/tools/run_shell_command.rb +24 -6
  104. data/lib/kward/tools/tool_call.rb +2 -1
  105. data/lib/kward/version.rb +1 -1
  106. data/templates/default/fulldoc/html/css/kward.css +0 -125
  107. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  108. data/templates/default/fulldoc/html/setup.rb +1 -1
  109. data/templates/default/kward_navigation.rb +1 -0
  110. data/templates/default/layout/html/footer.erb +10 -0
  111. data/templates/default/layout/html/headers.erb +23 -0
  112. data/templates/default/layout/html/layout.erb +6 -18
  113. data/templates/default/layout/html/setup.rb +41 -2
  114. metadata +44 -8
  115. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
@@ -338,6 +338,7 @@ module Kward
338
338
 
339
339
  @mutex.synchronize do
340
340
  @busy = true
341
+ @busy_started_at = monotonic_now
341
342
  @busy_activity = normalize_busy_activity(activity)
342
343
  @asking = true
343
344
  reset_spinner_locked
@@ -349,7 +350,8 @@ module Kward
349
350
  def finish_select_action
350
351
  @mutex.synchronize do
351
352
  @busy = false
352
- @busy_activity = "streaming"
353
+ @busy_started_at = nil
354
+ @busy_activity = "thinking"
353
355
  @select_state&.delete(:busy_activity)
354
356
  render_prompt_locked if @asking
355
357
  flush_output_locked
@@ -19,13 +19,17 @@ module Kward
19
19
  end
20
20
 
21
21
  def normalize_slash_commands(commands)
22
- commands.map do |command|
22
+ commands.filter_map do |command|
23
+ name = slash_command_value(command, :name).to_s
24
+ next if name.empty?
25
+
23
26
  {
24
- name: slash_command_value(command, :name).to_s,
27
+ name: name,
28
+ normalized_name: name.downcase,
25
29
  description: slash_command_value(command, :description).to_s,
26
30
  argument_hint: slash_command_value(command, :argument_hint).to_s
27
31
  }
28
- end.reject { |command| command[:name].empty? }.sort_by { |command| command[:name] }
32
+ end.sort_by { |command| command[:name] }
29
33
  end
30
34
 
31
35
  def slash_command_value(command, key)
@@ -44,7 +48,18 @@ module Kward
44
48
 
45
49
  def slash_overlay_matches
46
50
  prefix = composer_input.delete_prefix("/").downcase
47
- @slash_commands.select { |command| command[:name].downcase.start_with?(prefix) }.first(8)
51
+ cache = @slash_overlay_match_cache
52
+ return cache[:matches] if cache && cache[:prefix] == prefix && cache[:commands].equal?(@slash_commands)
53
+
54
+ matches = []
55
+ @slash_commands.each do |command|
56
+ next unless command[:normalized_name].start_with?(prefix)
57
+
58
+ matches << command
59
+ break if matches.length == 8
60
+ end
61
+ @slash_overlay_match_cache = { prefix: prefix, commands: @slash_commands, matches: matches }
62
+ matches
48
63
  end
49
64
 
50
65
  def selected_slash_command
@@ -11,7 +11,7 @@ module Kward
11
11
  prepare_transcript_output_locked unless @restoring_transcript
12
12
  if label && @stream_state.block != label
13
13
  ensure_transcript_block_separator_locked
14
- write_transcript_text_locked("#{colored("#{transcript_label(label)}>", *label_styles(label))} ")
14
+ write_transcript_text_locked("#{colored(transcript_marker(label), *label_styles(label))} ")
15
15
  @stream_state.start_block(label)
16
16
  end
17
17
  write_transcript_text_locked(delta) unless delta.empty?
@@ -117,20 +117,25 @@ module Kward
117
117
  end
118
118
  end
119
119
 
120
+ def transcript_marker(label)
121
+ marker = "#{transcript_label(label)}>"
122
+ label == "Assistant" ? "✦ #{marker}" : marker
123
+ end
124
+
120
125
  def label_styles(label)
121
126
  case label
122
127
  when "Reasoning", "Compaction summary"
123
- [:gray, :bold]
128
+ [:metadata, :bold]
124
129
  when "Assistant", "Kward"
125
- [:green, :bold]
130
+ [:activity, :bold]
126
131
  when "Tool", "Tool output"
127
- [:cyan, :bold]
132
+ [:tool, :bold]
128
133
  when "Tool failed"
129
- [:red, :bold]
134
+ [:failure, :bold]
130
135
  when "Retry"
131
- [:yellow, :bold]
136
+ [:caution, :bold]
132
137
  else
133
- [:gray, :bold]
138
+ [:metadata, :bold]
134
139
  end
135
140
  end
136
141
 
@@ -15,6 +15,8 @@ require_relative "terminal_sequences"
15
15
  require_relative "terminal_keys"
16
16
  require_relative "editor_mode"
17
17
  require_relative "diff_view_mode"
18
+ require_relative "scratchpad_languages"
19
+ require_relative "markdown_code_block"
18
20
  require_relative "prompt_interface/banner"
19
21
  require_relative "prompt_interface/composer_state"
20
22
  require_relative "prompt_interface/editor/state"
@@ -32,8 +34,10 @@ require_relative "prompt_interface/git_prompt"
32
34
  require_relative "prompt_interface/overlay_renderer"
33
35
  require_relative "prompt_interface/editor/renderer"
34
36
  require_relative "prompt_interface/editor/syntax_highlighter"
37
+ require_relative "prompt_interface/editor/runner"
35
38
  require_relative "prompt_interface/editor/auto_close_pairs"
36
39
  require_relative "prompt_interface/editor/endwise"
40
+ require_relative "prompt_interface/editor/word_completion"
37
41
  require_relative "prompt_interface/editor/auto_indent"
38
42
  require_relative "prompt_interface/composer_renderer"
39
43
  require_relative "prompt_interface/composer_controller"
@@ -63,12 +67,15 @@ module Kward
63
67
  # `prompt_interface/` to keep rendering, layout, keyboard handling, and runtime
64
68
  # state readable while sharing one terminal state object.
65
69
  class PromptInterface
66
- HELP_TEXT = "Enter sends • Shift+Enter inserts newline • ↑/↓ history • Ctrl+D exits empty prompt".freeze
70
+ HELP_TEXT = "Enter sends • Shift+Enter newline • / commands • @ files • ↑/↓ history • Ctrl+D exits".freeze
67
71
  BUSY_HELP_TEXT = "Ctrl+C cancels".freeze
68
- SPINNER_FRAMES = %w[ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
69
- SPINNER_INTERVAL = 0.1
72
+ SPINNER_FRAMES = %w[· ].freeze
73
+ SPINNER_INTERVAL = 0.16
70
74
  FOOTER_REFRESH_INTERVAL = 1.0
71
75
  COMPOSER_STATUS_REFRESH_INTERVAL = 1.0
76
+ COMPLETION_DISPLAY_SECONDS = 0.8
77
+ RESPONSE_ARRIVAL_SECONDS = 0.4
78
+ BUSY_ELAPSED_DELAY_SECONDS = 2.0
72
79
  COMPOSER_MAX_INPUT_ROWS = 6
73
80
  IMAGE_VIEWER_MAX_ROWS = 8
74
81
  TRANSCRIPT_BUFFER_LIMIT = 200_000
@@ -86,7 +93,9 @@ module Kward
86
93
  include EditorSyntaxHighlighter
87
94
  include EditorAutoClosePairs
88
95
  include EditorEndwise
96
+ include EditorWordCompletion
89
97
  include EditorAutoIndent
98
+ include EditorRunner
90
99
  include ComposerRenderer
91
100
  include ComposerController
92
101
  include ModernEditorMode
@@ -131,7 +140,7 @@ module Kward
131
140
  end
132
141
  end
133
142
 
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)
143
+ 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, editor_runners_source: nil, redraw_handler: nil)
135
144
  @input_io = input
136
145
  @output_io = output
137
146
  # Logical state may change during a child handoff, but only the owner may render.
@@ -143,12 +152,14 @@ module Kward
143
152
  @workspace_root = File.expand_path(workspace_root.to_s.empty? ? Dir.pwd : workspace_root)
144
153
  @composer = ComposerState.new
145
154
  load_history(@prompt_history.values) if @prompt_history
146
- self.composer_input = @composer.input
147
- self.composer_cursor = @composer.cursor
148
155
  @started = false
149
156
  @asking = false
150
157
  @busy = false
151
- @busy_activity = "streaming"
158
+ @busy_activity = "thinking"
159
+ @busy_started_at = nil
160
+ @last_busy_elapsed = nil
161
+ @completion_status = nil
162
+ @response_arrival_until = nil
152
163
  @queued_count = 0
153
164
  @steered_count = 0
154
165
  @spinner_frame_index = 0
@@ -167,6 +178,7 @@ module Kward
167
178
  @transcript_viewport_rows = 0
168
179
  @restoring_transcript = false
169
180
  @pending_keys = []
181
+ @handoff_input_buffer = +"".b
170
182
  @completion_provider = nil
171
183
  @completion_cycle = nil
172
184
  @completion_overlay = nil
@@ -183,11 +195,25 @@ module Kward
183
195
  @file_open_dismissed_token = nil
184
196
  @file_editor_open_status = nil
185
197
  @file_mention_paths = nil
198
+ @file_mention_discovery_thread = nil
199
+ @file_mention_discovery_root = nil
200
+ @file_mention_discovery_token = nil
201
+ @project_browser_file_paths = nil
186
202
  @project_browser_state = nil
187
203
  @image_viewer_state = nil
188
204
  @terminal_image_protocol = nil
189
205
  @project_browser_restore_after_editor = false
190
206
  @editor_state = nil
207
+ @editor_runner_state = nil
208
+ @editor_runner_completion = nil
209
+ @editor_runner_output_visible = false
210
+ @editor_runner_selection_anchor = nil
211
+ @editor_runner_selection_cursor = nil
212
+ @editor_runner_mouse_dragging = false
213
+ @editor_runner_last_click = nil
214
+ @editor_agent_suspended = false
215
+ @editor_prompt_active = false
216
+ @editor_prompt_input = ""
191
217
  @interactive_state = nil
192
218
  @last_interactive_tick = monotonic_now
193
219
  @select_state = nil
@@ -226,6 +252,7 @@ module Kward
226
252
  @editor_line_numbers_source = editor_line_numbers_source
227
253
  @diff_view = DiffViewMode.normalize(diff_view)
228
254
  @diff_view_source = diff_view_source
255
+ @editor_runners_source = editor_runners_source
229
256
  end
230
257
 
231
258
  def update_slash_commands(commands)
@@ -352,6 +379,60 @@ module Kward
352
379
  @mutex.synchronize { editor_active? }
353
380
  end
354
381
 
382
+ # Returns the active Vibe or Modern editor buffer for an agent prompt turn.
383
+ def editor_prompt_context
384
+ @mutex.synchronize do
385
+ next nil unless editor_active? && (@editor_state.vibe? || @editor_state.modern?)
386
+
387
+ {
388
+ path: @editor_state.path,
389
+ display_path: @editor_state.display_path,
390
+ workspace_root: @workspace_root,
391
+ language: @editor_state.language,
392
+ content: @editor_state.buffer.dup,
393
+ cursor: @editor_state.cursor_line_and_column,
394
+ selection: @editor_state.selected_text
395
+ }
396
+ end
397
+ end
398
+
399
+ # Suspends the live editor while an agent prompt is running.
400
+ def suspend_editor_for_agent
401
+ @mutex.synchronize do
402
+ return false unless editor_active? && !@editor_agent_suspended
403
+
404
+ @editor_agent_suspended = true
405
+ @editor_state.status = "Agent is working · Ctrl+C cancels"
406
+ render_prompt_locked if @started
407
+ true
408
+ end
409
+ end
410
+
411
+ # Commits an agent draft as one undoable editor operation.
412
+ def commit_editor_prompt(session)
413
+ @mutex.synchronize do
414
+ return false unless editor_active? && @editor_agent_suspended
415
+ return false unless @editor_state.buffer == session.initial_content
416
+
417
+ changed = @editor_state.replace_buffer(session.content)
418
+ @editor_state.status = changed ? "Agent updated buffer · :w save" : "Agent made no buffer changes"
419
+ render_prompt_locked if @started
420
+ changed
421
+ end
422
+ end
423
+
424
+ # Resumes the editor after an agent prompt turn.
425
+ def resume_editor_for_agent(status: nil)
426
+ @mutex.synchronize do
427
+ return false unless editor_active?
428
+
429
+ @editor_agent_suspended = false
430
+ @editor_state.status = status.to_s unless status.to_s.empty?
431
+ render_prompt_locked if @started
432
+ true
433
+ end
434
+ end
435
+
355
436
  def inline_image_protocol
356
437
  @mutex.synchronize { terminal_image_protocol }
357
438
  end
@@ -379,6 +460,12 @@ module Kward
379
460
  return false unless opened
380
461
 
381
462
  run_editor
463
+ rescue StandardError
464
+ @mutex.synchronize do
465
+ close_editor if editor_active?
466
+ render_prompt_locked if @started
467
+ end
468
+ raise
382
469
  end
383
470
 
384
471
  def scratchpad(language = :text)
@@ -464,7 +551,8 @@ module Kward
464
551
  if key.nil?
465
552
  resized = handle_resize_locked
466
553
  footer_refreshed = tick_footer_locked
467
- render_prompt_locked if resized || footer_refreshed
554
+ completion_changed = tick_completion_locked
555
+ render_prompt_locked if resized || footer_refreshed || completion_changed
468
556
  else
469
557
  result = handle_key(key)
470
558
  render_prompt_locked unless result.is_a?(String) || result == EXIT_INPUT || prompt_action_result?(result)
@@ -480,6 +568,21 @@ module Kward
480
568
  perform_pending_transcript_redraw
481
569
  end
482
570
 
571
+ # Prefills the next composer input without submitting it.
572
+ def prefill_input(value)
573
+ @mutex.synchronize do
574
+ @composer.prefill_input = value.to_s
575
+ end
576
+ end
577
+
578
+ # Updates the visible prompt label while the composer is active.
579
+ def update_prompt_label(label)
580
+ @mutex.synchronize do
581
+ @prompt_label = label.to_s
582
+ render_prompt_locked if @started && @asking
583
+ end
584
+ end
585
+
483
586
  def yes?(message, default: false)
484
587
  answer = ask("#{message} #{default ? "[Y/n]" : "[y/N]"}")
485
588
  return default if answer.nil?
@@ -592,11 +695,11 @@ module Kward
592
695
  end
593
696
  end
594
697
 
595
- def with_inline_terminal_handoff(&block)
596
- with_terminal_handoff(inline: true, &block)
698
+ def with_inline_terminal_handoff(preserve_tab_keybindings: false, &block)
699
+ with_terminal_handoff(inline: true, preserve_tab_keybindings: preserve_tab_keybindings, &block)
597
700
  end
598
701
 
599
- def with_terminal_handoff(inline: false)
702
+ def with_terminal_handoff(inline: false, preserve_tab_keybindings: false)
600
703
  start
601
704
  input = nil
602
705
  output = nil
@@ -612,7 +715,8 @@ module Kward
612
715
  end
613
716
  disable_editor_mouse_reporting(force: true)
614
717
  print_output_locked(BRACKETED_PASTE_RESTORE)
615
- print_output_locked(KEYBOARD_PROTOCOL_RESTORE)
718
+ print_output_locked(KEYBOARD_PROTOCOL_RESTORE) unless preserve_tab_keybindings
719
+ @handoff_input_buffer.clear
616
720
  restore_editor_cursor_shape_locked
617
721
  set_cursor_visible_locked(true, force: true)
618
722
  flush_output_locked
@@ -634,6 +738,7 @@ module Kward
634
738
  enter_raw_mode_locked
635
739
  print_output_locked(KEYBOARD_PROTOCOL_ENABLE)
636
740
  print_output_locked(BRACKETED_PASTE_ENABLE)
741
+ @handoff_input_buffer.clear
637
742
  disable_editor_mouse_reporting(force: true)
638
743
  restore_editor_cursor_shape_locked(force: true)
639
744
  @cursor_visible = nil
@@ -727,8 +832,6 @@ module Kward
727
832
  @completion_cycle = nil
728
833
  @completion_overlay = nil
729
834
  @prompt_label = snapshot[:prompt_label].to_s.empty? ? "You>" : snapshot[:prompt_label].to_s
730
- self.composer_input = @composer.input
731
- self.composer_cursor = @composer.cursor
732
835
  @last_composer_rows = []
733
836
  end
734
837
 
@@ -740,6 +843,11 @@ module Kward
740
843
 
741
844
  def restore_editor_snapshot_locked(snapshot)
742
845
  editor_was_active = editor_active?
846
+ cancel_editor_runner
847
+ @editor_runner_state = nil
848
+ @editor_runner_completion = nil
849
+ @editor_runner_output_visible = false
850
+ clear_editor_runner_selection
743
851
  @editor_state = snapshot[:editor_state]&.dup
744
852
  editor_is_active = editor_active?
745
853
 
@@ -764,17 +872,21 @@ module Kward
764
872
  end
765
873
  end
766
874
 
767
- def begin_busy_input(message = "You>", activity: "streaming")
875
+ def begin_busy_input(message = "You>", activity: "thinking")
768
876
  start
769
877
  @mutex.synchronize do
770
878
  @prompt_label = message.to_s
771
879
  @busy_activity = normalize_busy_activity(activity)
880
+ @completion_status = nil
881
+ @response_arrival_until = nil
772
882
  self.composer_input = ""
773
883
  self.composer_cursor = 0
774
884
  @composer.clear_attachments
775
885
  @pending_keys.clear
776
886
  @asking = true
777
887
  @busy = true
888
+ @busy_started_at = monotonic_now
889
+ @last_busy_elapsed = nil
778
890
  @queued_count = 0
779
891
  @steered_count = 0
780
892
  reset_spinner_locked
@@ -783,6 +895,17 @@ module Kward
783
895
  end
784
896
  end
785
897
 
898
+ def update_busy_activity(activity)
899
+ @mutex.synchronize do
900
+ value = normalize_busy_activity(activity)
901
+ return false if value == @busy_activity
902
+
903
+ @busy_activity = value
904
+ render_prompt_locked if @started && @asking && @busy
905
+ true
906
+ end
907
+ end
908
+
786
909
  def set_queued_count(count)
787
910
  @mutex.synchronize do
788
911
  @queued_count = count.to_i
@@ -802,15 +925,18 @@ module Kward
802
925
  def clear_steered_count
803
926
  @mutex.synchronize do
804
927
  @steered_count = 0
805
- @busy_activity = "streaming"
928
+ @busy_activity = "thinking"
806
929
  render_prompt_locked if @asking
807
930
  end
808
931
  end
809
932
 
810
933
  def finish_busy_input
811
934
  @mutex.synchronize do
935
+ @last_busy_elapsed = monotonic_now - @busy_started_at if @busy_started_at
936
+ @busy_started_at = nil
812
937
  @busy = false
813
- @busy_activity = "streaming"
938
+ @busy_activity = "thinking"
939
+ @response_arrival_until = nil
814
940
  @queued_count = 0
815
941
  @steered_count = 0
816
942
  @asking = true
@@ -818,6 +944,24 @@ module Kward
818
944
  end
819
945
  end
820
946
 
947
+ def show_response_arrival
948
+ @mutex.synchronize do
949
+ @response_arrival_until = monotonic_now + RESPONSE_ARRIVAL_SECONDS
950
+ render_prompt_locked if @started && @asking && @busy
951
+ end
952
+ end
953
+
954
+ def show_completion(status)
955
+ @mutex.synchronize do
956
+ @completion_status = completion_status(status)
957
+ render_prompt_locked if @started && @asking
958
+ end
959
+ end
960
+
961
+ def filter_terminal_handoff_input(chunk)
962
+ @mutex.synchronize { filter_terminal_handoff_input_locked(chunk) }
963
+ end
964
+
821
965
  def poll_input
822
966
  key = read_key(nonblock: true)
823
967
  @mutex.synchronize do
@@ -841,7 +985,8 @@ module Kward
841
985
  resized = handle_resize_locked
842
986
  spun = tick_spinner_locked
843
987
  footer_refreshed = tick_footer_locked
844
- render_prompt_locked if resized || spun || footer_refreshed
988
+ completion_changed = tick_completion_locked
989
+ render_prompt_locked if resized || spun || footer_refreshed || completion_changed
845
990
  return nil
846
991
  end
847
992
 
@@ -1031,6 +1176,12 @@ module Kward
1031
1176
 
1032
1177
  def reset_workspace_file_state_locked
1033
1178
  @file_mention_paths = nil
1179
+ @file_mention_discovery_thread = nil
1180
+ @file_mention_discovery_root = nil
1181
+ @file_mention_discovery_token = nil
1182
+ @file_overlay_match_cache = nil
1183
+ @project_browser_search_cache = nil
1184
+ @project_browser_file_paths = nil
1034
1185
  @file_mention_path_entries_paths = nil
1035
1186
  @file_mention_path_entries = nil
1036
1187
  @project_browser_state = nil
@@ -29,9 +29,10 @@ module Kward
29
29
  { name: "files", description: "Browse project files.", argument_hint: "" },
30
30
  { name: "shell", description: "Open the embedded Kward shell.", argument_hint: "" },
31
31
  { name: "capture", description: "Run a command with bounded transcript capture.", argument_hint: "<command>" },
32
- { name: "scratchpad", description: "Open an unsaved editor buffer.", argument_hint: "[text|markdown|ruby]" },
32
+ { name: "scratchpad", description: "Open an unsaved editor buffer.", argument_hint: "[language|help]" },
33
33
  { name: "pty", description: "Run a command in an interactive PTY passthrough session.", argument_hint: "<command>" },
34
34
  { name: "tab", description: "Manage tabs.", argument_hint: "[1-n|move|close|new|name|worktree]" },
35
+ { name: "worktree", description: "Manage the active tab's linked Git worktree.", argument_hint: "[activate|detach|status|merge|merge abort|remove]" },
35
36
  { name: "status", description: "Show the current status message.", argument_hint: "" },
36
37
  { name: "stats", description: "Show telemetry logging stats.", argument_hint: "[range]" },
37
38
  { name: "sandbox", description: "Inspect or configure model command sandboxing.", argument_hint: "[status|off|read_only|workspace_write|network allow|network deny]" },
data/lib/kward/prompts.rb CHANGED
@@ -60,7 +60,7 @@ module Kward
60
60
 
61
61
  When web tools are available, use web_search to discover sources, fetch_content for important human-readable pages, and fetch_raw for machine-readable resources such as JSON, YAML, XML, RSS, OpenAPI specs, and plain text. Prefer official or primary sources and cite or mention the URLs you relied on. Use code_search for package, GitHub repository, and source-code research.
62
62
 
63
- Manage code context deliberately. Prefer context_for_task, summarize_file_structure, and read_file mode="outline"/"preview" before broad reads. Escalate to read_file mode="range" for exact lines, and use mode="full" only when focused context is insufficient. If output is compacted or replaced with a duplicate reference, use retrieve_tool_output to inspect the needed original detail. Use context_budget_stats when asked about context savings. When a material requirement is ambiguous, use ask_user_question when it is available; otherwise state the assumption.
63
+ Manage code context deliberately. Prefer context_for_task, summarize_file_structure, and read_file mode="outline"/"preview" before broad reads. Escalate to read_file mode="range" for exact lines, and use mode="full" only when focused context is insufficient. If output is compacted or replaced with a duplicate reference, use retrieve_tool_output to inspect the needed original detail. Use context_budget_stats when asked about context savings. When the user asks to open a file in the built-in editor and open_editor is advertised, use open_editor rather than read_file; use read_file when they ask you to inspect or display contents. Opening the editor is a user-facing action and does not itself modify or save the file. When a material requirement is ambiguous, use ask_user_question when it is available; otherwise state the assumption.
64
64
  PROMPT
65
65
  end
66
66
 
@@ -1,5 +1,52 @@
1
+ require "thread"
2
+
1
3
  # Namespace for the Kward CLI agent runtime.
2
4
  module Kward
5
+ # Retains output while a detached PTY command runs without terminal ownership.
6
+ class BufferedPtyOutputSink
7
+ attr_reader :captured_output
8
+
9
+ def initialize(max_capture_bytes:)
10
+ @max_capture_bytes = max_capture_bytes
11
+ @captured_output = +"".b
12
+ @truncated = false
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def write(chunk)
17
+ value = chunk.to_s.b
18
+ @mutex.synchronize do
19
+ remaining = @max_capture_bytes - @captured_output.bytesize
20
+ if value.bytesize > remaining
21
+ @captured_output << value.byteslice(0, remaining) if remaining.positive?
22
+ @truncated = true
23
+ else
24
+ @captured_output << value
25
+ end
26
+ end
27
+ end
28
+
29
+ def flush
30
+ nil
31
+ end
32
+
33
+ def finish
34
+ nil
35
+ end
36
+
37
+ def transcript_safe?
38
+ true
39
+ end
40
+
41
+ def pre_input_capture_only?
42
+ true
43
+ end
44
+
45
+ def truncated?
46
+ @mutex.synchronize { @truncated }
47
+ end
48
+ end
49
+
3
50
  # Forwards PTY output immediately and optionally keeps a bounded byte copy.
4
51
  #
5
52
  # The runner only depends on the sink's `write` method and optional `flush`
@@ -235,7 +235,7 @@ module Kward
235
235
  def complete_login(login, code)
236
236
  raise "Missing authorization code" if code.to_s.empty?
237
237
 
238
- login.oauth.complete_login_flow(code: code, redirect_uri: login.redirect_uri, code_verifier: login.pkce[:verifier])
238
+ login.oauth.complete_login_flow(code: code, redirect_uri: login.redirect_uri, code_verifier: login.pkce[:verifier], state: login.state)
239
239
  login.status = "completed"
240
240
  @server.notify("auth/loginFinished", login_payload(login))
241
241
  rescue StandardError => e
@@ -610,7 +610,8 @@ module Kward
610
610
  },
611
611
  composer: {
612
612
  sessionDiff: { supported: false, reason: "interactiveComposerOnly" },
613
- copy: { supported: false, reason: "clientClipboardOwnedByUi" }
613
+ copy: { supported: false, reason: "clientClipboardOwnedByUi" },
614
+ editorPrompt: { supported: false, reason: "interactiveVibeEditorOnly" }
614
615
  },
615
616
  security: {
616
617
  workspaceMutationGuard: "none",
@@ -173,10 +173,14 @@ module Kward
173
173
  when "reasoning"
174
174
  reasoning_item_blocks(item).map { |thinking| { type: "thinking", thinking: thinking } }
175
175
  when "message"
176
- next [] if ToolCall.value(item, :phase).to_s == "commentary"
177
-
178
176
  text = response_message_item_text(item)
179
- text.empty? ? [] : [{ type: "text", text: text }]
177
+ next [] if text.empty?
178
+
179
+ if ToolCall.value(item, :phase).to_s == "commentary"
180
+ [{ type: "thinking", thinking: text }]
181
+ else
182
+ [{ type: "text", text: text }]
183
+ end
180
184
  else
181
185
  []
182
186
  end
@@ -0,0 +1,74 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Names, aliases, and display paths accepted by the scratchpad command.
4
+ module ScratchpadLanguages
5
+ LANGUAGES = {
6
+ text: { aliases: %w[text txt], display_path: "scratchpad.txt" },
7
+ ruby: { aliases: %w[ruby rb], display_path: "scratchpad.rb", runner: :ruby },
8
+ erb: { aliases: %w[erb], display_path: "scratchpad.erb" },
9
+ crystal: { aliases: %w[crystal cr], display_path: "scratchpad.cr", runner: :crystal },
10
+ elixir: { aliases: %w[elixir ex exs], display_path: "scratchpad.ex", runner: :elixir },
11
+ julia: { aliases: %w[julia jl], display_path: "scratchpad.jl", runner: :julia },
12
+ javascript: { aliases: %w[javascript js jsx mjs cjs], display_path: "scratchpad.js", runner: :node },
13
+ typescript: { aliases: %w[typescript ts tsx], display_path: "scratchpad.ts", runner: :node },
14
+ json: { aliases: %w[json], display_path: "scratchpad.json" },
15
+ markdown: { aliases: %w[markdown md], display_path: "scratchpad.md" },
16
+ yaml: { aliases: %w[yaml yml], display_path: "scratchpad.yml" },
17
+ shell: { aliases: %w[shell sh bash zsh fish], display_path: "scratchpad.sh", runner: :shell },
18
+ makefile: { aliases: %w[makefile make mk], display_path: "scratchpad.mk" },
19
+ html: { aliases: %w[html htm], display_path: "scratchpad.html" },
20
+ css: { aliases: %w[css], display_path: "scratchpad.css" },
21
+ scss: { aliases: %w[scss], display_path: "scratchpad.scss" },
22
+ python: { aliases: %w[python py pyw], display_path: "scratchpad.py", runner: :python },
23
+ go: { aliases: %w[go], display_path: "scratchpad.go", runner: :go },
24
+ rust: { aliases: %w[rust rs], display_path: "scratchpad.rs" },
25
+ java: { aliases: %w[java], display_path: "scratchpad.java" },
26
+ csharp: { aliases: %w[csharp cs c#], display_path: "scratchpad.cs" },
27
+ c: { aliases: %w[c], display_path: "scratchpad.c" },
28
+ cpp: { aliases: %w[cpp cc cxx hpp hh hxx c++], display_path: "scratchpad.cpp" },
29
+ swift: { aliases: %w[swift], display_path: "scratchpad.swift", runner: :swift },
30
+ kotlin: { aliases: %w[kotlin kt kts], display_path: "scratchpad.kt" },
31
+ lua: { aliases: %w[lua], display_path: "scratchpad.lua", runner: :lua },
32
+ sql: { aliases: %w[sql], display_path: "scratchpad.sql" }
33
+ }.freeze
34
+
35
+ ALIASES = LANGUAGES.each_with_object({}) do |(language, definition), aliases|
36
+ ([language.to_s] + definition.fetch(:aliases)).each do |name|
37
+ aliases[name] = language
38
+ end
39
+ end.freeze
40
+
41
+ module_function
42
+
43
+ def normalize(language)
44
+ value = language.to_s.strip.downcase
45
+ return :text if value.empty?
46
+
47
+ ALIASES.fetch(value) do
48
+ raise ArgumentError, "Unknown scratchpad language #{language.inspect}. Use /scratchpad help for supported languages."
49
+ end
50
+ end
51
+
52
+ def display_path(language)
53
+ LANGUAGES.fetch(normalize(language)).fetch(:display_path)
54
+ end
55
+
56
+ def runner(language)
57
+ LANGUAGES.fetch(normalize(language))[:runner]
58
+ end
59
+
60
+ def runnable?(language)
61
+ !runner(language).nil?
62
+ end
63
+
64
+ def help_text
65
+ lines = ["Supported scratchpad languages:"]
66
+ LANGUAGES.each do |language, definition|
67
+ aliases = definition.fetch(:aliases).reject { |name| name == language.to_s }
68
+ suffix = aliases.empty? ? "" : " (#{aliases.join(", ")})"
69
+ lines << " #{language}#{suffix}"
70
+ end
71
+ lines.join("\n")
72
+ end
73
+ end
74
+ end