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
@@ -102,19 +102,19 @@ module Kward
102
102
  if editor_result
103
103
  record_tab_transient_shell_output(editor_result.output, render: false)
104
104
  @prompt.say(editor_result.output) unless editor_result.output.to_s.empty?
105
- open_ekwsh_editor(editor_result.open_editor_path, shell) if editor_result.open_editor_path
105
+ open_kwsh_editor(editor_result.open_editor_path, shell) if editor_result.open_editor_path
106
106
  return true
107
107
  end
108
108
 
109
109
  expanded_command = shell.expand_alias(command, interactive: true)
110
- run_user_interactive_pty_command(
110
+ result = run_user_interactive_pty_command(
111
111
  expanded_command,
112
- shell: Ekwsh::DEFAULT_SHELL,
113
- env: interactive_pty_environment({}, preserve_git_pager: true),
112
+ shell: Kwsh::DEFAULT_SHELL,
113
+ env: interactive_pty_environment(shell.child_env, preserve_git_pager: true),
114
114
  cwd: interactive_workspace_root(agent),
115
115
  intro: "$ #{command}\n"
116
116
  )
117
- true
117
+ result&.tab_action ? :tab_action : true
118
118
  end
119
119
 
120
120
  def run_captured_shell_command(command, agent)
@@ -142,7 +142,7 @@ module Kward
142
142
  completion = bang_shell(agent).complete(value[1..], cursor.to_i - 1)
143
143
  return nil unless completion
144
144
 
145
- Ekwsh::Completion.new(
145
+ Kwsh::Completion.new(
146
146
  range: (completion.range.begin + 1)...(completion.range.end + 1),
147
147
  replacement: completion.replacement,
148
148
  candidates: completion.candidates
@@ -151,12 +151,14 @@ module Kward
151
151
 
152
152
  def bang_shell(agent)
153
153
  root = File.expand_path(interactive_workspace_root(agent).to_s)
154
- aliases = ConfigFiles.read_ekwsh_config[:aliases]
155
- cache_key = [root, ENV.fetch("PATH", ""), aliases.sort]
154
+ config = ConfigFiles.read_kwsh_config
155
+ aliases = config[:aliases]
156
+ configured_env = config[:env]
157
+ cache_key = [root, ENV.fetch("PATH", ""), aliases.sort, configured_env.sort]
156
158
  return @bang_shell if @bang_shell_key == cache_key
157
159
 
158
160
  @bang_shell_key = cache_key
159
- @bang_shell = Ekwsh.new(cwd: root, env: ENV.to_h, aliases: aliases)
161
+ @bang_shell = Kwsh.new(cwd: root, env: ENV.to_h, configured_env: configured_env, aliases: aliases)
160
162
  end
161
163
 
162
164
  def install_bang_completion_provider(agent)
@@ -182,7 +184,7 @@ module Kward
182
184
  @bang_completion_provider_installed = false
183
185
  end
184
186
 
185
- def run_ekwsh(agent)
187
+ def run_kwsh(agent)
186
188
  unless @prompt.respond_to?(:ask)
187
189
  runtime_output("The embedded shell is only available in interactive mode.")
188
190
  return
@@ -190,15 +192,91 @@ module Kward
190
192
 
191
193
  tab = active_tab if respond_to?(:active_tab, true)
192
194
  entering = tab.nil? || tab.shell.nil?
193
- shell = tab&.shell || build_ekwsh(agent)
195
+ shell = tab&.shell || build_kwsh(agent)
194
196
  tab.shell = shell if tab
195
- runtime_output("Entering ekwsh. Type exit or press Ctrl+D on an empty prompt to return.") if entering
196
- run_ekwsh_loop(shell, tab: tab, history: build_ekwsh_history(agent))
197
+ shell_agent = shell_prompt_agent_for(agent, tab: tab)
198
+ runtime_output("Entering kwsh. Type exit or press Ctrl+D on an empty prompt to return.") if entering
199
+ run_kwsh_loop(shell, tab: tab, history: build_kwsh_history(agent), shell_agent: shell_agent)
200
+ end
201
+
202
+ def shell_prompt_agent_for(agent, tab: nil)
203
+ return tab.shell_agent if tab&.shell_agent
204
+ return nil unless agent.respond_to?(:conversation) && agent.respond_to?(:tool_registry)
205
+
206
+ shell_agent = build_shell_prompt_agent(agent)
207
+ tab.shell_agent = shell_agent if tab
208
+ shell_agent
209
+ end
210
+
211
+ def build_shell_prompt_agent(active_agent)
212
+ active_conversation = active_agent.conversation
213
+ config = ConfigFiles.read_config
214
+ configured_provider = ConfigFiles.shell_agent_provider(config)
215
+ conversation = Conversation.new(
216
+ system_message: ShellPrompt.system_message,
217
+ workspace_root: active_conversation.workspace_root,
218
+ provider: configured_provider || active_conversation.provider || current_model_provider,
219
+ model: ConfigFiles.shell_agent_model(config) || (configured_provider ? nil : active_conversation.model || current_model_id),
220
+ reasoning_effort: ConfigFiles.shell_agent_reasoning_effort(config) || (configured_provider ? nil : active_conversation.reasoning_effort || current_reasoning_effort)
221
+ )
222
+ Agent.new(
223
+ client: @client,
224
+ tool_registry: active_agent.tool_registry,
225
+ conversation: conversation,
226
+ strict_provider: !configured_provider.nil?,
227
+ warning_sink: ConfigFiles.warning_sink
228
+ )
229
+ end
230
+
231
+ def run_shell_prompt_turn(input, shell, shell_agent)
232
+ tab = active_tab if respond_to?(:active_tab, true)
233
+ transcript_before = prompt_transcript_text
234
+ instruction = input.to_s.sub(/\A\?\s*/, "").strip
235
+ if instruction.empty?
236
+ runtime_output("Shell prompt instruction required after ?")
237
+ return []
238
+ end
239
+ unless shell_agent.respond_to?(:tool_registry)
240
+ runtime_output("Shell agent is unavailable in this prompt.")
241
+ return []
242
+ end
243
+
244
+ session = ShellPromptSession.new(shell)
245
+ registry = shell_agent.tool_registry.for_shell_prompt(session)
246
+ on_complete = lambda do |successful:, **|
247
+ next unless successful
248
+ next unless session.prepared_command
249
+
250
+ if @prompt.respond_to?(:prefill_input)
251
+ @prompt.prefill_input(session.prepared_command)
252
+ else
253
+ runtime_output("Prepared shell command:\n#{session.prepared_command}")
254
+ end
255
+ end
256
+ pending_inputs = run_interactive_turn(
257
+ shell_agent,
258
+ ShellPrompt.input(instruction, session.context),
259
+ display_input: "? #{instruction}",
260
+ tool_registry: registry,
261
+ busy_label: shell.prompt_label,
262
+ on_complete: on_complete
263
+ )
264
+ @prompt.update_prompt_label(shell.prompt_label) if @prompt.respond_to?(:update_prompt_label)
265
+ pending_inputs
266
+ rescue StandardError => e
267
+ runtime_output("Error: #{e.message}")
268
+ []
269
+ ensure
270
+ record_shell_prompt_transcript(tab, transcript_before)
197
271
  end
198
272
 
199
- def build_ekwsh(agent)
200
- config = ConfigFiles.read_ekwsh_config
201
- Ekwsh.new(
273
+ def shell_prompt_input?(input)
274
+ input.to_s.start_with?("?")
275
+ end
276
+
277
+ def build_kwsh(agent)
278
+ config = ConfigFiles.read_kwsh_config
279
+ PersistentShellSession.new(
202
280
  cwd: interactive_workspace_root(agent),
203
281
  configured_env: config[:env],
204
282
  aliases: config[:aliases],
@@ -208,8 +286,8 @@ module Kward
208
286
  )
209
287
  end
210
288
 
211
- def build_ekwsh_history(agent)
212
- config = ConfigFiles.read_ekwsh_config
289
+ def build_kwsh_history(agent)
290
+ config = ConfigFiles.read_kwsh_config
213
291
  PromptHistory.new(
214
292
  cwd: interactive_workspace_root(agent),
215
293
  limit: config[:history_limit],
@@ -224,7 +302,7 @@ module Kward
224
302
  return
225
303
  end
226
304
 
227
- config = ConfigFiles.read_ekwsh_config
305
+ config = ConfigFiles.read_kwsh_config
228
306
  run_user_interactive_pty_command(
229
307
  command,
230
308
  shell: config[:shell],
@@ -241,8 +319,10 @@ module Kward
241
319
  elsif @prompt.respond_to?(:say)
242
320
  @prompt.say(intro_message)
243
321
  end
322
+ origin_tab = active_tab if respond_to?(:active_tab, true)
244
323
  result = run_interactive_pty_with_terminal_handoff(shell, command, env: env, cwd: cwd) do |sink, completed_result|
245
- record_completed_pty_output(sink, completed_result)
324
+ transcript_output = record_completed_pty_output(sink, completed_result, tab: origin_tab)
325
+ yield(transcript_output, completed_result) if block_given?
246
326
  end
247
327
  refresh_composer_status
248
328
  result
@@ -253,39 +333,78 @@ module Kward
253
333
 
254
334
  def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:, &on_complete)
255
335
  runner = InteractivePtyRunner.new
256
- run_with_sink = lambda do |input, sink|
257
- result = runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, sink: sink)
258
- on_complete.call(sink, result) if on_complete
336
+ origin_tab = active_tab if respond_to?(:active_tab, true)
337
+ with_interactive_terminal_handoff do |input, sink|
338
+ tab_action_handler = terminal_handoff_tab_action_handler
339
+ on_detach = if origin_tab
340
+ -> { BufferedPtyOutputSink.new(max_capture_bytes: Kward::CLI::Tabs::TRANSIENT_SHELL_OUTPUT_LIMIT) }
341
+ end
342
+ result = if shell.respond_to?(:run_interactive)
343
+ shell.run_interactive(command, input: input, sink: sink, tab_action_handler: tab_action_handler, on_detach: on_detach)
344
+ else
345
+ runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, sink: sink, tab_action_handler: tab_action_handler, on_detach: on_detach)
346
+ end
347
+ if result&.background
348
+ register_background_tab_run(origin_tab, result.background, &on_complete)
349
+ else
350
+ on_complete.call(sink, result) if on_complete
351
+ end
259
352
  result
260
353
  end
354
+ end
261
355
 
356
+ def with_interactive_terminal_handoff
262
357
  if @prompt.respond_to?(:with_inline_terminal_handoff)
263
- @prompt.with_inline_terminal_handoff do |input, output, transition|
358
+ with_tab_keybinding_handoff(:with_inline_terminal_handoff) do |input, output, transition|
264
359
  sink = AdaptivePtyOutputSink.new(
265
360
  output: output,
266
361
  on_exclusive: transition,
267
362
  max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
268
363
  )
269
- run_with_sink.call(input, sink)
364
+ yield(input, sink)
270
365
  end
271
366
  elsif @prompt.respond_to?(:with_terminal_handoff)
272
- @prompt.with_terminal_handoff do |input, output|
367
+ with_tab_keybinding_handoff(:with_terminal_handoff) do |input, output|
273
368
  sink = PassthroughPtyOutputSink.new(
274
369
  output: output,
275
370
  max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
276
371
  )
277
- run_with_sink.call(input, sink)
372
+ yield(input, sink)
278
373
  end
279
374
  else
280
375
  sink = PassthroughPtyOutputSink.new(
281
376
  output: $stdout,
282
377
  max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
283
378
  )
284
- run_with_sink.call($stdin, sink)
379
+ yield($stdin, sink)
380
+ end
381
+ end
382
+
383
+ def with_tab_keybinding_handoff(method_name)
384
+ method = @prompt.method(method_name)
385
+ supports_option = method.parameters.any? do |type, name|
386
+ type == :keyrest || (type == :key || type == :keyreq) && name == :preserve_tab_keybindings
387
+ end
388
+ if supports_option
389
+ @prompt.public_send(method_name, preserve_tab_keybindings: true) { |*args| yield(*args) }
390
+ else
391
+ @prompt.public_send(method_name) { |*args| yield(*args) }
392
+ end
393
+ end
394
+
395
+ def terminal_handoff_tab_action_handler
396
+ return unless @prompt.respond_to?(:filter_terminal_handoff_input)
397
+ return unless respond_to?(:handle_tab_action, true)
398
+
399
+ lambda do |chunk|
400
+ filtered = @prompt.filter_terminal_handoff_input(chunk)
401
+ action = filtered[:tab_action]
402
+ handle_tab_action(action, @session_store) if action
403
+ filtered
285
404
  end
286
405
  end
287
406
 
288
- def record_completed_pty_output(sink, result)
407
+ def record_completed_pty_output(sink, result, tab: nil)
289
408
  classified_output = sink.respond_to?(:transcript_safe?)
290
409
  return if classified_output && !sink.transcript_safe?
291
410
 
@@ -302,10 +421,11 @@ module Kward
302
421
  )
303
422
  return unless transcript_output
304
423
 
305
- record_tab_transient_shell_output(transcript_output, render: false)
306
- if @prompt.respond_to?(:record_transient_terminal_output)
424
+ record_tab_transient_shell_output(transcript_output, render: false, tab: tab)
425
+ if @prompt.respond_to?(:record_transient_terminal_output) && (!tab || tab == active_tab)
307
426
  @prompt.record_transient_terminal_output(transcript_output, render: false)
308
427
  end
428
+ transcript_output
309
429
  end
310
430
 
311
431
  def terminal_transcript_output(
@@ -341,13 +461,15 @@ module Kward
341
461
  end
342
462
  end
343
463
 
344
- def run_ekwsh_loop(shell, tab: nil, history: nil)
345
- with_ekwsh_history(history) do
346
- run_ekwsh_loop_with_history(shell, tab: tab)
464
+ def run_kwsh_loop(shell, tab: nil, history: nil, shell_agent: nil)
465
+ shell_agent ||= shell_prompt_agent_for(tab.agent, tab: tab) if tab&.agent
466
+ with_kwsh_history(history) do
467
+ run_kwsh_loop_with_history(shell, tab: tab, shell_agent: shell_agent)
347
468
  end
348
469
  end
349
470
 
350
- def run_ekwsh_loop_with_history(shell, tab: nil)
471
+ def run_kwsh_loop_with_history(shell, tab: nil, shell_agent: nil)
472
+ pending_shell_inputs = []
351
473
  loop do
352
474
  if @prompt.respond_to?(:editing_file?) && @prompt.editing_file?
353
475
  editor_result = @prompt.run_editor
@@ -357,14 +479,19 @@ module Kward
357
479
  end
358
480
  end
359
481
 
360
- input = ask_ekwsh(shell)
482
+ input = pending_shell_inputs.shift || ask_kwsh(shell)
361
483
  if input.is_a?(Hash) && input[:tab_action]
362
484
  (@pending_inputs ||= []).unshift(input)
363
485
  return :tab_action
364
486
  end
365
487
  break if input.nil?
366
488
 
367
- result = run_ekwsh_command(shell, input)
489
+ if shell_prompt_input?(input)
490
+ pending_shell_inputs.concat(run_shell_prompt_turn(input, shell, shell_agent))
491
+ next
492
+ end
493
+
494
+ result = run_kwsh_command(shell, input)
368
495
  if result.clear
369
496
  clear_active_tab_transient_shell_output
370
497
  @prompt.clear_transcript if @prompt.respond_to?(:clear_transcript)
@@ -375,27 +502,39 @@ module Kward
375
502
  return :tab_action if pending_tab_action?
376
503
 
377
504
  if result.open_editor_path
378
- editor_result = open_ekwsh_editor(result.open_editor_path, shell)
505
+ editor_result = open_kwsh_editor(result.open_editor_path, shell)
379
506
  return :tab_action if editor_result == :tab_action
380
507
 
381
508
  next
382
509
  end
383
510
  if result.interactive_command
384
- run_ekwsh_interactive_pty_command(shell, result)
511
+ pty_result = run_kwsh_interactive_pty_command(shell, result)
512
+ return :tab_action if pty_result&.tab_action
513
+
385
514
  next
386
515
  end
387
516
  if result.exit_shell
517
+ close_kwsh_session(shell)
388
518
  tab.shell = nil if tab
519
+ tab.shell_agent = nil if tab
389
520
  runtime_output("Shell exited.")
390
521
  return :exited
391
522
  end
392
523
  end
524
+ close_kwsh_session(shell)
393
525
  tab.shell = nil if tab
526
+ tab.shell_agent = nil if tab
394
527
  runtime_output("Shell exited.")
395
528
  :exited
396
529
  end
397
530
 
398
- def with_ekwsh_history(history)
531
+ def close_kwsh_session(shell)
532
+ shell.close if shell.respond_to?(:close)
533
+ rescue StandardError
534
+ nil
535
+ end
536
+
537
+ def with_kwsh_history(history)
399
538
  if history && @prompt.respond_to?(:with_prompt_history)
400
539
  @prompt.with_prompt_history(history) { yield }
401
540
  else
@@ -403,7 +542,7 @@ module Kward
403
542
  end
404
543
  end
405
544
 
406
- def open_ekwsh_editor(path, shell)
545
+ def open_kwsh_editor(path, shell)
407
546
  unless @prompt.respond_to?(:edit_file)
408
547
  runtime_output("Integrated editor is unavailable in this prompt.")
409
548
  return false
@@ -418,7 +557,7 @@ module Kward
418
557
  result
419
558
  end
420
559
 
421
- def ask_ekwsh(shell)
560
+ def ask_kwsh(shell)
422
561
  provider = ->(input, cursor) { shell.complete(input, cursor) }
423
562
  if @prompt.respond_to?(:with_completion_provider)
424
563
  @prompt.with_completion_provider(provider, slash_overlay: false) { @prompt.ask(shell.prompt_label) }
@@ -427,22 +566,28 @@ module Kward
427
566
  end
428
567
  end
429
568
 
430
- def run_ekwsh_interactive_pty_command(shell, result)
569
+ def run_kwsh_interactive_pty_command(shell, result)
570
+ shell_target = shell.respond_to?(:run_interactive) ? shell : shell.command_shell
431
571
  run_user_interactive_pty_command(
432
572
  result.interactive_command,
433
- shell: shell.command_shell,
573
+ shell: shell_target,
434
574
  env: shell.child_env(interactive: true),
435
575
  cwd: shell.cwd,
436
576
  intro: result.output
437
- )
577
+ ) do |transcript_output, completed_result|
578
+ shell.record_interactive_result(
579
+ output: transcript_output,
580
+ exit_status: completed_result.exit_status
581
+ )
582
+ end
438
583
  end
439
584
 
440
- def run_ekwsh_command(shell, input)
585
+ def run_kwsh_command(shell, input)
441
586
  if @prompt.respond_to?(:begin_busy_input)
442
587
  @prompt.begin_busy_input(shell.prompt_label, activity: "running")
443
588
  end
444
589
  if @prompt.respond_to?(:write_transcript_delta) && @prompt.respond_to?(:poll_input)
445
- run_streaming_ekwsh_command(shell, input)
590
+ run_streaming_kwsh_command(shell, input)
446
591
  elsif @prompt.respond_to?(:write_transcript_delta)
447
592
  shell.run(input) { |chunk| @prompt.write_transcript_delta(chunk) }
448
593
  else
@@ -452,45 +597,67 @@ module Kward
452
597
  @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
453
598
  end
454
599
 
455
- def run_streaming_ekwsh_command(shell, input)
600
+ def run_streaming_kwsh_command(shell, input)
456
601
  cancellation = Cancellation.new
457
602
  chunks = Queue.new
458
603
  queued_inputs = []
604
+ origin_tab = active_tab if respond_to?(:active_tab, true)
605
+ detached_sink = nil
459
606
  result = nil
460
607
  error = nil
461
608
  worker = Thread.new do
462
- result = shell.run(input, cancellation: cancellation) { |chunk| chunks << chunk }
609
+ result = shell.run(input, cancellation: cancellation) do |chunk|
610
+ if detached_sink
611
+ detached_sink.write(chunk)
612
+ else
613
+ chunks << chunk
614
+ end
615
+ end
463
616
  rescue StandardError => e
464
617
  error = e
465
618
  end
466
619
  worker.report_on_exception = false
467
620
 
468
621
  while worker.alive?
469
- drain_ekwsh_chunks(chunks)
622
+ drain_kwsh_chunks(chunks)
470
623
  poll_result = collect_queued_input(queued_inputs)
471
624
  if poll_result == PromptInterface::CANCEL_INPUT
472
625
  cancellation.cancel!
473
626
  elsif poll_result.is_a?(Hash) && poll_result[:tab_action]
474
627
  (@pending_inputs ||= []).unshift(poll_result)
628
+ if origin_tab
629
+ detached_sink = BufferedPtyOutputSink.new(max_capture_bytes: Kward::CLI::Tabs::TRANSIENT_SHELL_OUTPUT_LIMIT)
630
+ background = DetachedRun.new(sink: detached_sink, canceler: -> { cancellation.cancel! }) do
631
+ worker.join
632
+ raise error if error
633
+
634
+ result
635
+ end
636
+ register_background_tab_run(origin_tab, background) do |_sink, completed_result|
637
+ record_tab_transient_shell_output(completed_result.output, render: false, tab: origin_tab)
638
+ @prompt.say(completed_result.output) if origin_tab == active_tab && completed_result.output.to_s != ""
639
+ end
640
+ return Kwsh::Result.new(output: "", exit_status: 0, streamed: true)
641
+ end
475
642
  cancellation.cancel!
476
643
  end
477
644
  sleep 0.01
478
645
  end
479
646
  worker.join
480
- drain_ekwsh_chunks(chunks)
647
+ drain_kwsh_chunks(chunks)
481
648
  raise error if error
482
649
 
483
650
  queued_inputs.reverse_each { |pending_input| (@pending_inputs ||= []).unshift(pending_input) }
484
651
  result
485
652
  end
486
653
 
487
- def record_tab_transient_shell_output(text, render: true)
654
+ def record_tab_transient_shell_output(text, render: true, tab: nil)
488
655
  value = text.to_s
489
656
  return if value.empty?
490
657
 
491
- tab = active_tab if respond_to?(:active_tab, true)
658
+ tab ||= active_tab if respond_to?(:active_tab, true)
492
659
  tab&.append_transient_shell_entry(value)
493
- if render && @prompt.respond_to?(:record_transient_terminal_output)
660
+ if render && @prompt.respond_to?(:record_transient_terminal_output) && tab == active_tab
494
661
  @prompt.record_transient_terminal_output(value)
495
662
  end
496
663
  end
@@ -499,7 +666,45 @@ module Kward
499
666
  clear_active_tab_transient_shell_entries if respond_to?(:clear_active_tab_transient_shell_entries, true)
500
667
  end
501
668
 
502
- def drain_ekwsh_chunks(chunks)
669
+ def prompt_transcript_text
670
+ return nil unless @prompt.respond_to?(:tab_view_snapshot)
671
+
672
+ snapshot = @prompt.tab_view_snapshot
673
+ transcript = snapshot[:transcript_buffer] || snapshot["transcript_buffer"] || snapshot[:transcript] || snapshot["transcript"]
674
+ transcript = transcript.map(&:to_s).join if transcript.is_a?(Array)
675
+ transcript.to_s
676
+ rescue StandardError
677
+ nil
678
+ end
679
+
680
+ def record_shell_prompt_transcript(tab, transcript_before)
681
+ return unless tab && !transcript_before.nil?
682
+
683
+ transcript_after = prompt_transcript_text
684
+ return if transcript_after.nil?
685
+
686
+ delta = shell_prompt_transcript_delta(transcript_before, transcript_after)
687
+ tab.append_transient_shell_entry(delta) unless delta.empty?
688
+ rescue StandardError
689
+ nil
690
+ end
691
+
692
+ def shell_prompt_transcript_delta(before, after)
693
+ return "" if before == after
694
+ return after[before.length..].to_s if after.start_with?(before)
695
+
696
+ # The prompt transcript is bounded and may trim while a large agent
697
+ # response is rendered. Preserve only the part not already retained.
698
+ overlap = [before.length, after.length].min
699
+ while overlap.positive?
700
+ return after[overlap..].to_s if before.end_with?(after[0, overlap])
701
+
702
+ overlap -= 1
703
+ end
704
+ after
705
+ end
706
+
707
+ def drain_kwsh_chunks(chunks)
503
708
  loop do
504
709
  @prompt.write_transcript_delta(chunks.pop(true))
505
710
  rescue ThreadError
@@ -1,3 +1,5 @@
1
+ require_relative "../scratchpad_languages"
2
+
1
3
  # Namespace for the Kward CLI agent runtime.
2
4
  module Kward
3
5
  # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
@@ -48,19 +50,23 @@ module Kward
48
50
  open_project_files_browser
49
51
  [true, nil]
50
52
  when "shell"
51
- run_ekwsh(agent)
53
+ run_kwsh(agent)
52
54
  [true, nil]
53
55
  when "capture"
54
56
  run_captured_shell_command(argument, agent)
55
57
  [true, nil]
56
58
  when "scratchpad"
57
- open_scratchpad_command(argument)
59
+ result = open_scratchpad_command(argument)
60
+ queue_editor_prompt_action(result)
58
61
  [true, nil]
59
62
  when "pty"
60
63
  run_interactive_pty_command(argument, agent)
61
64
  [true, nil]
62
65
  when "tab"
63
66
  [true, handle_tab_command(argument, session_store)]
67
+ when "worktree"
68
+ handle_worktree_command(argument)
69
+ [true, active_tab&.agent]
64
70
  when "settings"
65
71
  configure_settings(agent.conversation)
66
72
  [true, nil]
@@ -300,20 +306,23 @@ module Kward
300
306
  end
301
307
 
302
308
  def open_scratchpad_command(argument)
303
- if @prompt.respond_to?(:scratchpad)
304
- @prompt.scratchpad(scratchpad_language_argument(argument))
305
- else
306
- runtime_output("The scratchpad is only available in the interactive prompt.")
309
+ unless @prompt.respond_to?(:scratchpad)
310
+ return runtime_output("The scratchpad is only available in the interactive prompt.")
307
311
  end
312
+
313
+ value = argument.to_s.strip
314
+ return runtime_output(ScratchpadLanguages.help_text) if value.casecmp("help").zero?
315
+
316
+ @prompt.scratchpad(ScratchpadLanguages.normalize(value))
317
+ rescue ArgumentError => e
318
+ runtime_output(e.message)
308
319
  end
309
320
 
310
- def scratchpad_language_argument(argument)
311
- value = argument.to_s.strip.downcase
312
- return :text if value.empty? || value == "text"
313
- return :markdown if ["markdown", "md"].include?(value)
314
- return :ruby if ["ruby", "rb"].include?(value)
321
+ def queue_editor_prompt_action(result)
322
+ return unless result.is_a?(Hash) && result[:editor_prompt]
315
323
 
316
- :text
324
+ @pending_inputs ||= []
325
+ @pending_inputs.unshift(result)
317
326
  end
318
327
 
319
328
  def open_project_files_browser