kward 0.81.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -1
  5. data/Rakefile +44 -4
  6. data/doc/agent-tools.md +1 -0
  7. data/doc/composer.md +2 -2
  8. data/doc/configuration.md +18 -3
  9. data/doc/editor.md +20 -10
  10. data/doc/files.md +12 -5
  11. data/doc/permissions.md +1 -0
  12. data/doc/releasing.md +79 -38
  13. data/doc/rpc.md +2 -2
  14. data/doc/sandboxing.md +5 -1
  15. data/doc/security.md +2 -2
  16. data/doc/shell.md +42 -18
  17. data/doc/tabs.md +3 -0
  18. data/doc/usage.md +3 -2
  19. data/kward.gemspec +1 -1
  20. data/lib/kward/adaptive_pty_output_sink.rb +183 -0
  21. data/lib/kward/agent.rb +1 -0
  22. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  23. data/lib/kward/cli/git.rb +5 -1
  24. data/lib/kward/cli/interactive_turn.rb +101 -19
  25. data/lib/kward/cli/rendering.rb +14 -2
  26. data/lib/kward/cli/runtime_helpers.rb +252 -46
  27. data/lib/kward/cli/settings.rb +5 -13
  28. data/lib/kward/cli/slash_commands.rb +12 -1
  29. data/lib/kward/cli/tabs.rb +71 -9
  30. data/lib/kward/cli.rb +12 -0
  31. data/lib/kward/compaction/token_estimator.rb +12 -6
  32. data/lib/kward/config_files.rb +17 -0
  33. data/lib/kward/editor_prompt.rb +46 -0
  34. data/lib/kward/editor_prompt_session.rb +28 -0
  35. data/lib/kward/ekwsh.rb +70 -10
  36. data/lib/kward/image_attachments.rb +98 -15
  37. data/lib/kward/interactive_pty_runner.rb +25 -19
  38. data/lib/kward/model/client.rb +1 -17
  39. data/lib/kward/model/model_info.rb +3 -2
  40. data/lib/kward/model/payloads.rb +0 -2
  41. data/lib/kward/persistent_shell_session.rb +750 -0
  42. data/lib/kward/project_files.rb +18 -5
  43. data/lib/kward/prompt_interface/composer_controller.rb +3 -3
  44. data/lib/kward/prompt_interface/composer_renderer.rb +28 -1
  45. data/lib/kward/prompt_interface/editor/auto_indent.rb +3 -0
  46. data/lib/kward/prompt_interface/editor/controller.rb +75 -6
  47. data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
  48. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  49. data/lib/kward/prompt_interface/editor/modes/vibe.rb +12 -6
  50. data/lib/kward/prompt_interface/editor/renderer.rb +1 -0
  51. data/lib/kward/prompt_interface/editor/state.rb +11 -0
  52. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  53. data/lib/kward/prompt_interface/file_overlay.rb +21 -7
  54. data/lib/kward/prompt_interface/git_prompt.rb +1 -1
  55. data/lib/kward/prompt_interface/key_handler.rb +30 -0
  56. data/lib/kward/prompt_interface/overlay_renderer.rb +1 -0
  57. data/lib/kward/prompt_interface/project_browser.rb +192 -8
  58. data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
  59. data/lib/kward/prompt_interface/question_prompt.rb +1 -1
  60. data/lib/kward/prompt_interface/runtime_state.rb +5 -1
  61. data/lib/kward/prompt_interface/screen.rb +40 -20
  62. data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
  63. data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
  64. data/lib/kward/prompt_interface.rb +159 -32
  65. data/lib/kward/prompts/commands.rb +1 -0
  66. data/lib/kward/pty_output_sink.rb +45 -0
  67. data/lib/kward/pty_transcript_normalizer.rb +93 -0
  68. data/lib/kward/rpc/auth_manager.rb +1 -1
  69. data/lib/kward/rpc/server.rb +2 -1
  70. data/lib/kward/session_catalog.rb +87 -0
  71. data/lib/kward/session_store.rb +94 -5
  72. data/lib/kward/shell_prompt.rb +50 -0
  73. data/lib/kward/shell_prompt_session.rb +58 -0
  74. data/lib/kward/terminal_image_support.rb +116 -0
  75. data/lib/kward/terminal_keys.rb +1 -0
  76. data/lib/kward/terminal_sequences.rb +43 -0
  77. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  78. data/lib/kward/tools/registry.rb +63 -5
  79. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  80. data/lib/kward/tools/run_shell_command.rb +24 -6
  81. data/lib/kward/version.rb +1 -1
  82. data/templates/default/layout/html/layout.erb +1 -1
  83. metadata +15 -4
  84. data/.github/workflows/ci.yml +0 -48
  85. data/.github/workflows/pages.yml +0 -48
@@ -12,7 +12,12 @@ module Kward
12
12
 
13
13
  def redraw_interactive_prompt
14
14
  tab = active_tab if respond_to?(:active_tab, true)
15
- if tab && (tab.running? || tab.shell)
15
+ clear_active_tab_transient_shell_output if tab && respond_to?(:clear_active_tab_transient_shell_output, true)
16
+ if tab&.shell
17
+ render_tab(tab, restore_composer: false)
18
+ return
19
+ end
20
+ if tab&.running?
16
21
  @prompt.redraw if @prompt.respond_to?(:redraw)
17
22
  return
18
23
  end
@@ -25,6 +30,7 @@ module Kward
25
30
 
26
31
  restore_prompt_transcript do
27
32
  tab ? render_transcript_messages(tab.driver.messages) : render_conversation_transcript(conversation)
33
+ render_tab_transient_shell_entries(tab) if tab && respond_to?(:render_tab_transient_shell_entries, true)
28
34
  end
29
35
  end
30
36
 
@@ -283,8 +289,14 @@ module Kward
283
289
  # Writes the pasted images output for the terminal CLI flow.
284
290
  def print_pasted_images(input, image_parts: nil)
285
291
  parts = image_parts || Kward::ImageAttachments.image_parts_from_text(input)
292
+ protocol = @prompt.inline_image_protocol if @prompt.respond_to?(:inline_image_protocol)
286
293
  parts.each do |part|
287
- sequence = Kward::ImageAttachments.terminal_image_sequence(part)
294
+ next if @prompt.respond_to?(:inline_image_protocol) && protocol.nil?
295
+
296
+ part = Kward::ImageAttachments.terminal_image_part(part, protocol) if protocol
297
+ next if protocol && part.nil?
298
+
299
+ sequence = Kward::ImageAttachments.terminal_image_sequence(part, protocol: protocol)
288
300
  next unless sequence
289
301
 
290
302
  if @prompt.respond_to?(:say_visual)
@@ -6,7 +6,6 @@ module Kward
6
6
  module RuntimeHelpers
7
7
  MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES = 1_048_576
8
8
  UNSAFE_TRANSCRIPT_CONTROL_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.freeze
9
- FROZEN_COMPOSER_COMMANDS = /\Agit\s+(?:fetch|ls-remote|push|remote|status)(?:\s|\z)/.freeze
10
9
 
11
10
  private
12
11
 
@@ -101,6 +100,7 @@ module Kward
101
100
  shell = bang_shell(agent)
102
101
  editor_result = shell.editor_command_result(command)
103
102
  if editor_result
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
105
  open_ekwsh_editor(editor_result.open_editor_path, shell) if editor_result.open_editor_path
106
106
  return true
@@ -192,13 +192,86 @@ module Kward
192
192
  entering = tab.nil? || tab.shell.nil?
193
193
  shell = tab&.shell || build_ekwsh(agent)
194
194
  tab.shell = shell if tab
195
+ shell_agent = shell_prompt_agent_for(agent, tab: tab)
195
196
  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
+ run_ekwsh_loop(shell, tab: tab, history: build_ekwsh_history(agent), shell_agent: shell_agent)
198
+ end
199
+
200
+ def shell_prompt_agent_for(agent, tab: nil)
201
+ return tab.shell_agent if tab&.shell_agent
202
+ return nil unless agent.respond_to?(:conversation) && agent.respond_to?(:tool_registry)
203
+
204
+ shell_agent = build_shell_prompt_agent(agent)
205
+ tab.shell_agent = shell_agent if tab
206
+ shell_agent
207
+ end
208
+
209
+ def build_shell_prompt_agent(active_agent)
210
+ active_conversation = active_agent.conversation
211
+ conversation = Conversation.new(
212
+ system_message: ShellPrompt.system_message,
213
+ workspace_root: active_conversation.workspace_root,
214
+ provider: active_conversation.provider || current_model_provider,
215
+ model: active_conversation.model || current_model_id,
216
+ reasoning_effort: active_conversation.reasoning_effort || current_reasoning_effort
217
+ )
218
+ Agent.new(
219
+ client: @client,
220
+ tool_registry: active_agent.tool_registry,
221
+ conversation: conversation,
222
+ warning_sink: ConfigFiles.warning_sink
223
+ )
224
+ end
225
+
226
+ def run_shell_prompt_turn(input, shell, shell_agent)
227
+ tab = active_tab if respond_to?(:active_tab, true)
228
+ transcript_before = prompt_transcript_text
229
+ instruction = input.to_s.sub(/\A\?\s*/, "").strip
230
+ if instruction.empty?
231
+ runtime_output("Shell prompt instruction required after ?")
232
+ return []
233
+ end
234
+ unless shell_agent.respond_to?(:tool_registry)
235
+ runtime_output("Shell agent is unavailable in this prompt.")
236
+ return []
237
+ end
238
+
239
+ session = ShellPromptSession.new(shell)
240
+ registry = shell_agent.tool_registry.for_shell_prompt(session)
241
+ on_complete = lambda do |successful:, **|
242
+ next unless successful
243
+ next unless session.prepared_command
244
+
245
+ if @prompt.respond_to?(:prefill_input)
246
+ @prompt.prefill_input(session.prepared_command)
247
+ else
248
+ runtime_output("Prepared shell command:\n#{session.prepared_command}")
249
+ end
250
+ end
251
+ pending_inputs = run_interactive_turn(
252
+ shell_agent,
253
+ ShellPrompt.input(instruction, session.context),
254
+ display_input: "? #{instruction}",
255
+ tool_registry: registry,
256
+ busy_label: shell.prompt_label,
257
+ on_complete: on_complete
258
+ )
259
+ @prompt.update_prompt_label(shell.prompt_label) if @prompt.respond_to?(:update_prompt_label)
260
+ pending_inputs
261
+ rescue StandardError => e
262
+ runtime_output("Error: #{e.message}")
263
+ []
264
+ ensure
265
+ record_shell_prompt_transcript(tab, transcript_before)
266
+ end
267
+
268
+ def shell_prompt_input?(input)
269
+ input.to_s.start_with?("?")
197
270
  end
198
271
 
199
272
  def build_ekwsh(agent)
200
273
  config = ConfigFiles.read_ekwsh_config
201
- Ekwsh.new(
274
+ PersistentShellSession.new(
202
275
  cwd: interactive_workspace_root(agent),
203
276
  configured_env: config[:env],
204
277
  aliases: config[:aliases],
@@ -235,65 +308,109 @@ module Kward
235
308
 
236
309
  def run_user_interactive_pty_command(command, shell:, env:, cwd:, intro: nil)
237
310
  intro_message = intro || "$ #{command}\n"
311
+ record_tab_transient_shell_output(intro_message, render: false)
238
312
  if @prompt.respond_to?(:write_transcript)
239
313
  @prompt.write_transcript(intro_message)
240
314
  elsif @prompt.respond_to?(:say)
241
315
  @prompt.say(intro_message)
242
316
  end
243
- output = +"".b
244
- output_truncated = false
245
- handoff_options = {}
246
- handoff_options[:preserve_composer] = true if frozen_composer_command?(command)
247
- result = run_interactive_pty_with_terminal_handoff(shell, command, env: env, cwd: cwd, **handoff_options) do |chunk|
248
- remaining = MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES - output.bytesize
249
- if chunk.bytesize > remaining
250
- output << chunk.byteslice(0, remaining) if remaining.positive?
251
- output_truncated = true
252
- else
253
- output << chunk
254
- end
255
- end
256
- @prompt.refresh_composer_status if @prompt.respond_to?(:refresh_composer_status)
257
- transcript_output = terminal_transcript_output(output, result, truncated: output_truncated)
258
- if transcript_output && @prompt.respond_to?(:record_transient_terminal_output)
259
- @prompt.record_transient_terminal_output(transcript_output)
317
+ result = run_interactive_pty_with_terminal_handoff(shell, command, env: env, cwd: cwd) do |sink, completed_result|
318
+ transcript_output = record_completed_pty_output(sink, completed_result)
319
+ yield(transcript_output, completed_result) if block_given?
260
320
  end
321
+ refresh_composer_status
261
322
  result
262
323
  rescue Errno::ENOENT => e
263
324
  runtime_output("Error: #{e.message}")
264
325
  nil
265
326
  end
266
327
 
267
- def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:, preserve_composer: false, &block)
328
+ def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:, &on_complete)
268
329
  runner = InteractivePtyRunner.new
269
- if @prompt.respond_to?(:with_terminal_handoff)
270
- if preserve_composer
271
- @prompt.with_terminal_handoff(preserve_composer: true) do |input, output|
272
- runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, output: output, &block)
273
- end
330
+ with_interactive_terminal_handoff do |input, sink|
331
+ result = if shell.respond_to?(:run_interactive)
332
+ shell.run_interactive(command, input: input, sink: sink)
274
333
  else
275
- @prompt.with_terminal_handoff do |input, output|
276
- runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, output: output, &block)
277
- end
334
+ runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, sink: sink)
335
+ end
336
+ on_complete.call(sink, result) if on_complete
337
+ result
338
+ end
339
+ end
340
+
341
+ def with_interactive_terminal_handoff
342
+ if @prompt.respond_to?(:with_inline_terminal_handoff)
343
+ @prompt.with_inline_terminal_handoff do |input, output, transition|
344
+ sink = AdaptivePtyOutputSink.new(
345
+ output: output,
346
+ on_exclusive: transition,
347
+ max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
348
+ )
349
+ yield(input, sink)
350
+ end
351
+ elsif @prompt.respond_to?(:with_terminal_handoff)
352
+ @prompt.with_terminal_handoff do |input, output|
353
+ sink = PassthroughPtyOutputSink.new(
354
+ output: output,
355
+ max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
356
+ )
357
+ yield(input, sink)
278
358
  end
279
359
  else
280
- runner.run(shell, "-c", command, env: env, cwd: cwd, &block)
360
+ sink = PassthroughPtyOutputSink.new(
361
+ output: $stdout,
362
+ max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
363
+ )
364
+ yield($stdin, sink)
281
365
  end
282
366
  end
283
367
 
284
- def frozen_composer_command?(command)
285
- FROZEN_COMPOSER_COMMANDS.match?(command.to_s.strip)
368
+ def record_completed_pty_output(sink, result)
369
+ classified_output = sink.respond_to?(:transcript_safe?)
370
+ return if classified_output && !sink.transcript_safe?
371
+
372
+ output = sink&.captured_output || +"".b
373
+ allow_input = classified_output &&
374
+ sink.respond_to?(:pre_input_capture_only?) &&
375
+ sink.pre_input_capture_only?
376
+ transcript_output = terminal_transcript_output(
377
+ output,
378
+ result,
379
+ truncated: sink&.truncated? || false,
380
+ allow_input: allow_input,
381
+ normalize_line_controls: classified_output
382
+ )
383
+ return unless transcript_output
384
+
385
+ record_tab_transient_shell_output(transcript_output, render: false)
386
+ if @prompt.respond_to?(:record_transient_terminal_output)
387
+ @prompt.record_transient_terminal_output(transcript_output, render: false)
388
+ end
389
+ transcript_output
286
390
  end
287
391
 
288
- def terminal_transcript_output(output, result, truncated:)
289
- return if truncated || result.input_forwarded
392
+ def terminal_transcript_output(
393
+ output,
394
+ result,
395
+ truncated:,
396
+ allow_input: false,
397
+ normalize_line_controls: false
398
+ )
399
+ return if truncated || (result.input_forwarded && !allow_input)
290
400
 
291
401
  text = ANSI.normalize_transcript_encoding(output).gsub("\r\n", "\n")
292
- return if text.empty? || text.include?("\r")
402
+ return if text.empty?
403
+
404
+ if normalize_line_controls
405
+ sanitized = PtyTranscriptNormalizer.normalize(text)
406
+ else
407
+ return if text.include?("\r")
293
408
 
294
- sanitized = ANSI.sanitize_transcript(text)
295
- return unless sanitized == text
296
- return if ANSI.strip_control_sequences(text).match?(UNSAFE_TRANSCRIPT_CONTROL_PATTERN)
409
+ sanitized = ANSI.sanitize_transcript(text)
410
+ return unless sanitized == text
411
+ end
412
+ return if sanitized.empty?
413
+ return if ANSI.strip_control_sequences(sanitized).match?(UNSAFE_TRANSCRIPT_CONTROL_PATTERN)
297
414
 
298
415
  sanitized
299
416
  end
@@ -305,13 +422,15 @@ module Kward
305
422
  end
306
423
  end
307
424
 
308
- def run_ekwsh_loop(shell, tab: nil, history: nil)
425
+ def run_ekwsh_loop(shell, tab: nil, history: nil, shell_agent: nil)
426
+ shell_agent ||= shell_prompt_agent_for(tab.agent, tab: tab) if tab&.agent
309
427
  with_ekwsh_history(history) do
310
- run_ekwsh_loop_with_history(shell, tab: tab)
428
+ run_ekwsh_loop_with_history(shell, tab: tab, shell_agent: shell_agent)
311
429
  end
312
430
  end
313
431
 
314
- def run_ekwsh_loop_with_history(shell, tab: nil)
432
+ def run_ekwsh_loop_with_history(shell, tab: nil, shell_agent: nil)
433
+ pending_shell_inputs = []
315
434
  loop do
316
435
  if @prompt.respond_to?(:editing_file?) && @prompt.editing_file?
317
436
  editor_result = @prompt.run_editor
@@ -321,16 +440,26 @@ module Kward
321
440
  end
322
441
  end
323
442
 
324
- input = ask_ekwsh(shell)
443
+ input = pending_shell_inputs.shift || ask_ekwsh(shell)
325
444
  if input.is_a?(Hash) && input[:tab_action]
326
445
  (@pending_inputs ||= []).unshift(input)
327
446
  return :tab_action
328
447
  end
329
448
  break if input.nil?
330
449
 
450
+ if shell_prompt_input?(input)
451
+ pending_shell_inputs.concat(run_shell_prompt_turn(input, shell, shell_agent))
452
+ next
453
+ end
454
+
331
455
  result = run_ekwsh_command(shell, input)
332
- @prompt.clear_transcript if result.clear && @prompt.respond_to?(:clear_transcript)
333
- @prompt.say(result.output) unless result.streamed || result.interactive_command || result.output.to_s.empty?
456
+ if result.clear
457
+ clear_active_tab_transient_shell_output
458
+ @prompt.clear_transcript if @prompt.respond_to?(:clear_transcript)
459
+ else
460
+ record_tab_transient_shell_output(result.output, render: false) unless result.interactive_command
461
+ @prompt.say(result.output) unless result.streamed || result.interactive_command || result.output.to_s.empty?
462
+ end
334
463
  return :tab_action if pending_tab_action?
335
464
 
336
465
  if result.open_editor_path
@@ -344,16 +473,26 @@ module Kward
344
473
  next
345
474
  end
346
475
  if result.exit_shell
476
+ close_ekwsh_session(shell)
347
477
  tab.shell = nil if tab
478
+ tab.shell_agent = nil if tab
348
479
  runtime_output("Shell exited.")
349
480
  return :exited
350
481
  end
351
482
  end
483
+ close_ekwsh_session(shell)
352
484
  tab.shell = nil if tab
485
+ tab.shell_agent = nil if tab
353
486
  runtime_output("Shell exited.")
354
487
  :exited
355
488
  end
356
489
 
490
+ def close_ekwsh_session(shell)
491
+ shell.close if shell.respond_to?(:close)
492
+ rescue StandardError
493
+ nil
494
+ end
495
+
357
496
  def with_ekwsh_history(history)
358
497
  if history && @prompt.respond_to?(:with_prompt_history)
359
498
  @prompt.with_prompt_history(history) { yield }
@@ -387,13 +526,19 @@ module Kward
387
526
  end
388
527
 
389
528
  def run_ekwsh_interactive_pty_command(shell, result)
529
+ shell_target = shell.respond_to?(:run_interactive) ? shell : shell.command_shell
390
530
  run_user_interactive_pty_command(
391
531
  result.interactive_command,
392
- shell: shell.command_shell,
532
+ shell: shell_target,
393
533
  env: shell.child_env(interactive: true),
394
534
  cwd: shell.cwd,
395
535
  intro: result.output
396
- )
536
+ ) do |transcript_output, completed_result|
537
+ shell.record_interactive_result(
538
+ output: transcript_output,
539
+ exit_status: completed_result.exit_status
540
+ )
541
+ end
397
542
  end
398
543
 
399
544
  def run_ekwsh_command(shell, input)
@@ -443,6 +588,59 @@ module Kward
443
588
  result
444
589
  end
445
590
 
591
+ def record_tab_transient_shell_output(text, render: true)
592
+ value = text.to_s
593
+ return if value.empty?
594
+
595
+ tab = active_tab if respond_to?(:active_tab, true)
596
+ tab&.append_transient_shell_entry(value)
597
+ if render && @prompt.respond_to?(:record_transient_terminal_output)
598
+ @prompt.record_transient_terminal_output(value)
599
+ end
600
+ end
601
+
602
+ def clear_active_tab_transient_shell_output
603
+ clear_active_tab_transient_shell_entries if respond_to?(:clear_active_tab_transient_shell_entries, true)
604
+ end
605
+
606
+ def prompt_transcript_text
607
+ return nil unless @prompt.respond_to?(:tab_view_snapshot)
608
+
609
+ snapshot = @prompt.tab_view_snapshot
610
+ transcript = snapshot[:transcript_buffer] || snapshot["transcript_buffer"] || snapshot[:transcript] || snapshot["transcript"]
611
+ transcript = transcript.map(&:to_s).join if transcript.is_a?(Array)
612
+ transcript.to_s
613
+ rescue StandardError
614
+ nil
615
+ end
616
+
617
+ def record_shell_prompt_transcript(tab, transcript_before)
618
+ return unless tab && !transcript_before.nil?
619
+
620
+ transcript_after = prompt_transcript_text
621
+ return if transcript_after.nil?
622
+
623
+ delta = shell_prompt_transcript_delta(transcript_before, transcript_after)
624
+ tab.append_transient_shell_entry(delta) unless delta.empty?
625
+ rescue StandardError
626
+ nil
627
+ end
628
+
629
+ def shell_prompt_transcript_delta(before, after)
630
+ return "" if before == after
631
+ return after[before.length..].to_s if after.start_with?(before)
632
+
633
+ # The prompt transcript is bounded and may trim while a large agent
634
+ # response is rendered. Preserve only the part not already retained.
635
+ overlap = [before.length, after.length].min
636
+ while overlap.positive?
637
+ return after[overlap..].to_s if before.end_with?(after[0, overlap])
638
+
639
+ overlap -= 1
640
+ end
641
+ after
642
+ end
643
+
446
644
  def drain_ekwsh_chunks(chunks)
447
645
  loop do
448
646
  @prompt.write_transcript_delta(chunks.pop(true))
@@ -559,6 +757,14 @@ module Kward
559
757
  @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT
560
758
  end
561
759
 
760
+ def refresh_composer_status
761
+ if @prompt.respond_to?(:refresh_composer_status)
762
+ @prompt.refresh_composer_status
763
+ elsif @prompt.respond_to?(:redraw)
764
+ @prompt.redraw
765
+ end
766
+ end
767
+
562
768
  def reload_client_config
563
769
  @client.reload_config if @client.respond_to?(:reload_config)
564
770
  end
@@ -115,7 +115,7 @@ module Kward
115
115
  ConfigFiles.update_config("provider" => ModelInfo.config_provider_for_provider(provider))
116
116
  reload_client_config
117
117
  refresh_conversation_runtime(conversation)
118
- @prompt.redraw if @prompt.respond_to?(:redraw)
118
+ refresh_composer_status
119
119
  end
120
120
 
121
121
  def provider_choices
@@ -166,7 +166,7 @@ module Kward
166
166
  ConfigFiles.update_config("local_backend" => backend, "local_base_url" => url)
167
167
  reload_client_config
168
168
  refresh_conversation_runtime(conversation)
169
- @prompt.redraw if @prompt.respond_to?(:redraw)
169
+ refresh_composer_status
170
170
  end
171
171
 
172
172
  def configure_account_settings
@@ -920,7 +920,7 @@ module Kward
920
920
  ConfigFiles.update_config(ModelInfo.config_values_for_selection(provider, model))
921
921
  reload_client_config
922
922
  refresh_conversation_runtime(conversation)
923
- @prompt.redraw if @prompt.respond_to?(:redraw)
923
+ refresh_composer_status
924
924
  end
925
925
 
926
926
  REASONING_CONFIG_DEBOUNCE_SECONDS = 0.5
@@ -953,18 +953,10 @@ module Kward
953
953
  queue_reasoning_config(effort, provider: provider, conversation: conversation) if queue_config
954
954
  if queue_config
955
955
  update_conversation_reasoning_effort(conversation, effort)
956
- refresh_reasoning_status
956
+ refresh_composer_status
957
957
  else
958
958
  refresh_conversation_runtime(conversation, reasoning_effort: effort)
959
- refresh_reasoning_status
960
- end
961
- end
962
-
963
- def refresh_reasoning_status
964
- if @prompt.respond_to?(:refresh_composer_status)
965
- @prompt.refresh_composer_status
966
- else
967
- @prompt.redraw if @prompt.respond_to?(:redraw)
959
+ refresh_composer_status
968
960
  end
969
961
  end
970
962
 
@@ -54,13 +54,17 @@ module Kward
54
54
  run_captured_shell_command(argument, agent)
55
55
  [true, nil]
56
56
  when "scratchpad"
57
- open_scratchpad_command(argument)
57
+ result = open_scratchpad_command(argument)
58
+ queue_editor_prompt_action(result)
58
59
  [true, nil]
59
60
  when "pty"
60
61
  run_interactive_pty_command(argument, agent)
61
62
  [true, nil]
62
63
  when "tab"
63
64
  [true, handle_tab_command(argument, session_store)]
65
+ when "worktree"
66
+ handle_worktree_command(argument)
67
+ [true, active_tab&.agent]
64
68
  when "settings"
65
69
  configure_settings(agent.conversation)
66
70
  [true, nil]
@@ -307,6 +311,13 @@ module Kward
307
311
  end
308
312
  end
309
313
 
314
+ def queue_editor_prompt_action(result)
315
+ return unless result.is_a?(Hash) && result[:editor_prompt]
316
+
317
+ @pending_inputs ||= []
318
+ @pending_inputs.unshift(result)
319
+ end
320
+
310
321
  def scratchpad_language_argument(argument)
311
322
  value = argument.to_s.strip.downcase
312
323
  return :text if value.empty? || value == "text"
@@ -35,10 +35,14 @@ module Kward
35
35
  :unread,
36
36
  :pending_question,
37
37
  :shell,
38
+ :shell_agent,
39
+ :transient_shell_entries,
38
40
  :error_reported,
39
41
  :local_busy_activity,
40
42
  keyword_init: true
41
43
  ) do
44
+ TRANSIENT_SHELL_OUTPUT_LIMIT = 200_000
45
+
42
46
  def running?
43
47
  %w[queued running waiting_for_question].include?(status.to_s)
44
48
  end
@@ -54,6 +58,41 @@ module Kward
54
58
  def record_event(event)
55
59
  event_history << event
56
60
  end
61
+
62
+ def append_transient_shell_entry(text)
63
+ value = text.to_s
64
+ return if value.empty?
65
+
66
+ self.transient_shell_entries ||= []
67
+ transient_shell_entries << value
68
+ trim_transient_shell_entries
69
+ end
70
+
71
+ def clear_transient_shell_entries
72
+ self.transient_shell_entries = []
73
+ end
74
+
75
+ private
76
+
77
+ def trim_transient_shell_entries
78
+ combined = transient_shell_entries.join
79
+ return if combined.length <= TRANSIENT_SHELL_OUTPUT_LIMIT
80
+
81
+ target = (TRANSIENT_SHELL_OUTPUT_LIMIT * 0.9).floor
82
+ cutoff = combined.length - target
83
+ newline = combined.index("\n", cutoff)
84
+ cutoff = newline + 1 if newline
85
+ cutoff = transient_shell_escape_boundary(combined, cutoff)
86
+ self.transient_shell_entries = [combined[cutoff..].to_s]
87
+ end
88
+
89
+ def transient_shell_escape_boundary(text, cutoff)
90
+ escape_start = text.rindex("\e", cutoff)
91
+ return cutoff unless escape_start
92
+
93
+ escape = ANSI.escape_sequence_at(text, escape_start)
94
+ escape && escape_start + escape.length > cutoff ? escape_start + escape.length : cutoff
95
+ end
57
96
  end
58
97
 
59
98
  class TabQuestionPrompt
@@ -279,6 +318,8 @@ module Kward
279
318
  unread: false,
280
319
  pending_question: nil,
281
320
  shell: nil,
321
+ shell_agent: nil,
322
+ transient_shell_entries: [],
282
323
  error_reported: false,
283
324
  local_busy_activity: nil
284
325
  ).tap { |tab| assign_tab_question_prompt(agent, tab) }
@@ -379,12 +420,14 @@ module Kward
379
420
  end
380
421
 
381
422
  if @tabs.length <= 1
423
+ close_ekwsh_session(tab.shell) if tab&.shell && respond_to?(:close_ekwsh_session, true)
382
424
  @tabs.clear
383
425
  persist_tabs
384
426
  return PromptInterface::EXIT_INPUT
385
427
  end
386
428
 
387
429
  stop_tab_live_view
430
+ close_ekwsh_session(tab.shell) if tab&.shell && respond_to?(:close_ekwsh_session, true)
388
431
  tab.session&.delete_if_unused if tab&.session.respond_to?(:delete_if_unused)
389
432
  tab.driver.close if tab.driver.respond_to?(:close)
390
433
  @tabs.delete_at(@active_tab_index)
@@ -423,7 +466,10 @@ module Kward
423
466
  tab.seen_events = 0
424
467
  tab.queued_inputs.clear
425
468
  tab.steering = nil
469
+ close_ekwsh_session(tab.shell) if tab.shell && respond_to?(:close_ekwsh_session, true)
426
470
  tab.shell = nil
471
+ tab.shell_agent = nil
472
+ tab.clear_transient_shell_entries
427
473
  tab.stream_state = new_tab_stream_state(tab.driver)
428
474
  tab.markdown_chunks.clear
429
475
  update_prompt_workspace_root(current_workspace_root)
@@ -451,7 +497,7 @@ module Kward
451
497
  end
452
498
  return snapshot_method.call unless supports_transcript_option
453
499
 
454
- snapshot_method.call(include_transcript: tab.running? || !tab.shell.nil?)
500
+ snapshot_method.call(include_transcript: tab.running?)
455
501
  end
456
502
 
457
503
  def activate_tab(index, render: true)
@@ -484,8 +530,8 @@ module Kward
484
530
  tab.agent
485
531
  end
486
532
 
487
- def render_tab(tab)
488
- if tab.snapshot && @prompt.respond_to?(:restore_tab_view_snapshot) && (tab.running? || tab.shell)
533
+ def render_tab(tab, restore_composer: true)
534
+ if restore_composer && tab.snapshot && @prompt.respond_to?(:restore_tab_view_snapshot) && tab.running?
489
535
  @prompt.restore_tab_view_snapshot(tab.snapshot)
490
536
  return
491
537
  end
@@ -497,8 +543,23 @@ module Kward
497
543
  render_transcript_messages(tab.driver.messages)
498
544
  end
499
545
  report_tab_runtime_error(tab) if %w[failed cancelled].include?(tab.status.to_s)
546
+ render_tab_transient_shell_entries(tab)
547
+ end
548
+ restore_tab_composer_snapshot(tab.snapshot) if restore_composer
549
+ end
550
+
551
+ def render_tab_transient_shell_entries(tab)
552
+ Array(tab.transient_shell_entries).each do |entry|
553
+ if @prompt.respond_to?(:say)
554
+ @prompt.say(entry)
555
+ elsif @prompt.respond_to?(:write_transcript)
556
+ @prompt.write_transcript(entry)
557
+ end
500
558
  end
501
- restore_tab_composer_snapshot(tab.snapshot)
559
+ end
560
+
561
+ def clear_active_tab_transient_shell_entries
562
+ active_tab&.clear_transient_shell_entries
502
563
  end
503
564
 
504
565
  def restore_tab_composer_snapshot(snapshot)
@@ -904,11 +965,12 @@ module Kward
904
965
  def stop_tabs
905
966
  stop_tab_live_view
906
967
  Array(@tabs).each do |tab|
907
- next unless tab&.running?
908
-
909
- tab.cancellation&.cancel!
910
- tab.thread&.raise(Cancellation::CancelledError, "cancelled") if tab.thread&.alive?
911
- tab.thread&.join(0.2)
968
+ if tab&.running?
969
+ tab.cancellation&.cancel!
970
+ tab.thread&.raise(Cancellation::CancelledError, "cancelled") if tab.thread&.alive?
971
+ tab.thread&.join(0.2)
972
+ end
973
+ close_ekwsh_session(tab.shell) if tab&.shell && respond_to?(:close_ekwsh_session, true)
912
974
  rescue StandardError
913
975
  nil
914
976
  end