kward 0.82.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/Gemfile.lock +2 -2
  4. data/doc/agent-tools.md +1 -0
  5. data/doc/composer.md +1 -1
  6. data/doc/configuration.md +18 -3
  7. data/doc/editor.md +20 -10
  8. data/doc/files.md +4 -3
  9. data/doc/permissions.md +1 -0
  10. data/doc/releasing.md +12 -4
  11. data/doc/rpc.md +2 -2
  12. data/doc/sandboxing.md +5 -1
  13. data/doc/security.md +1 -1
  14. data/doc/shell.md +40 -16
  15. data/doc/tabs.md +3 -0
  16. data/doc/usage.md +3 -2
  17. data/lib/kward/agent.rb +1 -0
  18. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  19. data/lib/kward/cli/interactive_turn.rb +101 -19
  20. data/lib/kward/cli/runtime_helpers.rb +156 -14
  21. data/lib/kward/cli/slash_commands.rb +12 -1
  22. data/lib/kward/cli/tabs.rb +12 -5
  23. data/lib/kward/cli.rb +10 -0
  24. data/lib/kward/compaction/token_estimator.rb +12 -6
  25. data/lib/kward/config_files.rb +17 -0
  26. data/lib/kward/editor_prompt.rb +46 -0
  27. data/lib/kward/editor_prompt_session.rb +28 -0
  28. data/lib/kward/ekwsh.rb +70 -10
  29. data/lib/kward/model/client.rb +1 -17
  30. data/lib/kward/model/model_info.rb +3 -2
  31. data/lib/kward/model/payloads.rb +0 -2
  32. data/lib/kward/persistent_shell_session.rb +750 -0
  33. data/lib/kward/project_files.rb +18 -5
  34. data/lib/kward/prompt_interface/composer_renderer.rb +1 -1
  35. data/lib/kward/prompt_interface/editor/auto_indent.rb +3 -0
  36. data/lib/kward/prompt_interface/editor/controller.rb +69 -0
  37. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  38. data/lib/kward/prompt_interface/editor/modes/vibe.rb +10 -4
  39. data/lib/kward/prompt_interface/editor/renderer.rb +1 -0
  40. data/lib/kward/prompt_interface/editor/state.rb +11 -0
  41. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  42. data/lib/kward/prompt_interface/file_overlay.rb +21 -7
  43. data/lib/kward/prompt_interface/key_handler.rb +8 -0
  44. data/lib/kward/prompt_interface/project_browser.rb +30 -5
  45. data/lib/kward/prompt_interface/runtime_state.rb +5 -1
  46. data/lib/kward/prompt_interface.rb +76 -0
  47. data/lib/kward/prompts/commands.rb +1 -0
  48. data/lib/kward/rpc/auth_manager.rb +1 -1
  49. data/lib/kward/rpc/server.rb +2 -1
  50. data/lib/kward/shell_prompt.rb +50 -0
  51. data/lib/kward/shell_prompt_session.rb +58 -0
  52. data/lib/kward/terminal_keys.rb +1 -0
  53. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  54. data/lib/kward/tools/registry.rb +63 -5
  55. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  56. data/lib/kward/tools/run_shell_command.rb +24 -6
  57. data/lib/kward/version.rb +1 -1
  58. data/templates/default/layout/html/layout.erb +1 -1
  59. metadata +9 -1
@@ -6,9 +6,68 @@ module Kward
6
6
  module InteractiveTurn
7
7
  private
8
8
 
9
- def run_interactive_turn(agent, input, display_input: nil)
10
- prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
11
- return run_blocking_interactive_turn(agent, input, display_input: display_input) unless prompt_interface?
9
+ def run_editor_prompt_turn(action, agent)
10
+ unless agent.respond_to?(:tool_registry) && agent.tool_registry.respond_to?(:for_editor_prompt)
11
+ runtime_output("Editor prompts are unavailable in this tab.")
12
+ return []
13
+ end
14
+ unless @prompt.respond_to?(:editor_prompt_context)
15
+ runtime_output("Editor prompts are unavailable in this prompt.")
16
+ return []
17
+ end
18
+
19
+ context = @prompt.editor_prompt_context
20
+ unless context
21
+ runtime_output("Open a Modern or Vibe editor buffer before prompting the agent.")
22
+ return []
23
+ end
24
+
25
+ unless @prompt.suspend_editor_for_agent
26
+ runtime_output("The editor is busy.")
27
+ return []
28
+ end
29
+
30
+ session = EditorPromptSession.new(context)
31
+ registry = agent.tool_registry.for_editor_prompt(session)
32
+ editor_agent = build_editor_prompt_agent(agent, context, registry)
33
+ instruction = action[:instruction] || action["instruction"]
34
+ run_interactive_turn(
35
+ editor_agent,
36
+ EditorPrompt.input(instruction, context),
37
+ tool_registry: registry,
38
+ editor_prompt_session: session,
39
+ suppress_transcript: true
40
+ )
41
+ rescue StandardError => e
42
+ @prompt.resume_editor_for_agent(status: "Agent request failed") if @prompt.respond_to?(:resume_editor_for_agent)
43
+ runtime_output("Error: #{e.message}")
44
+ []
45
+ end
46
+
47
+ def build_editor_prompt_agent(active_agent, context, registry)
48
+ active_conversation = active_agent.conversation
49
+ config = safely_read_config.to_h
50
+ provider = active_conversation.provider || current_model_provider
51
+ model = ConfigFiles.editor_agent_model(config) || active_conversation.model || current_model_id
52
+ reasoning = ConfigFiles.editor_agent_reasoning_effort(config) || active_conversation.reasoning_effort || current_reasoning_effort
53
+ conversation = Conversation.new(
54
+ system_message: EditorPrompt.system_message,
55
+ workspace_root: context[:workspace_root] || active_conversation.workspace_root,
56
+ provider: provider,
57
+ model: model,
58
+ reasoning_effort: reasoning
59
+ )
60
+ Agent.new(
61
+ client: @client,
62
+ tool_registry: registry,
63
+ conversation: conversation,
64
+ warning_sink: ConfigFiles.warning_sink
65
+ )
66
+ end
67
+
68
+ def run_interactive_turn(agent, input, display_input: nil, tool_registry: nil, editor_prompt_session: nil, suppress_transcript: false, busy_label: "You>", on_complete: nil)
69
+ prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation) && !suppress_transcript
70
+ return run_blocking_interactive_turn(agent, input, display_input: display_input, tool_registry: tool_registry, suppress_transcript: suppress_transcript, on_complete: on_complete) unless prompt_interface?
12
71
 
13
72
  queued_inputs = []
14
73
  cancellation = Cancellation.new
@@ -25,11 +84,11 @@ module Kward
25
84
  markdown_chunks = []
26
85
  answer = nil
27
86
  error = nil
28
- @prompt.begin_busy_input("You>") if @prompt.respond_to?(:begin_busy_input)
29
- print_user_transcript(input, display_input: display_input)
87
+ @prompt.begin_busy_input(busy_label) if @prompt.respond_to?(:begin_busy_input)
88
+ print_user_transcript(input, display_input: display_input) unless suppress_transcript
30
89
 
31
90
  worker = Thread.new do
32
- options = agent_display_options(display_input)
91
+ options = agent_display_options(display_input, tool_registry: tool_registry)
33
92
  options[:cancellation] = cancellation
34
93
  options[:steering] = steering if steering
35
94
  answer = agent.ask(input, **options) do |event|
@@ -52,7 +111,7 @@ module Kward
52
111
  cancellation.cancel!
53
112
  worker.raise(Cancellation::CancelledError, "cancelled") if worker.alive?
54
113
  end
55
- if busy_replacement_agent?
114
+ if busy_replacement_agent? || suppress_transcript
56
115
  discard_interactive_events(event_queue, markdown_chunks, stream_state)
57
116
  else
58
117
  drain_interactive_events(event_queue, markdown_chunks, stream_state, agent)
@@ -64,19 +123,39 @@ module Kward
64
123
  error ||= e
65
124
  end
66
125
  drain_busy_input(queued_inputs, nil) unless cancelled
67
- if busy_replacement_agent?
126
+ if busy_replacement_agent? || suppress_transcript
68
127
  discard_interactive_events(event_queue, markdown_chunks, stream_state, force: true)
69
128
  else
70
129
  drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
71
130
  end
131
+ completed = !cancelled && !busy_replacement_agent? && !error
132
+ on_complete&.call(successful: completed, cancelled: cancelled, error: error, answer: answer)
72
133
  raise error if error && !error.is_a?(Cancellation::CancelledError) && !busy_replacement_agent?
73
134
 
74
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless cancelled || busy_replacement_agent? || stream_state[:streamed] || answer.to_s.empty?
75
- persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
76
- auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && queued_inputs.empty? && !cancelled
135
+ @prompt.commit_editor_prompt(editor_prompt_session) if editor_prompt_session && completed
136
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless suppress_transcript || cancelled || busy_replacement_agent? || stream_state[:streamed] || answer.to_s.empty?
137
+ persist_memory_state(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript
138
+ auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript && queued_inputs.empty? && !cancelled
77
139
  queued_inputs
78
140
  ensure
79
141
  @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
142
+ if editor_prompt_session && @prompt.respond_to?(:resume_editor_for_agent)
143
+ status = if cancelled
144
+ "Agent request cancelled"
145
+ elsif error
146
+ "Agent request failed"
147
+ elsif editor_prompt_session && !editor_prompt_session.changed? && !answer.to_s.strip.empty?
148
+ editor_prompt_answer_status(answer)
149
+ end
150
+ @prompt.resume_editor_for_agent(status: status)
151
+ end
152
+ end
153
+
154
+ def editor_prompt_answer_status(answer)
155
+ text = answer.to_s.gsub(/\s+/, " ").strip
156
+ text = text[0, 120].to_s
157
+ text += "…" if answer.to_s.gsub(/\s+/, " ").strip.length > text.length
158
+ "Agent: #{text}"
80
159
  end
81
160
 
82
161
  def drain_interactive_events(event_queue, markdown_chunks, stream_state, agent = nil, force: false)
@@ -261,21 +340,24 @@ module Kward
261
340
  ModelInfo.reasoning_supported?(current_model_provider, model)
262
341
  end
263
342
 
264
- def run_blocking_interactive_turn(agent, input, display_input: nil)
343
+ def run_blocking_interactive_turn(agent, input, display_input: nil, tool_registry: nil, suppress_transcript: false, on_complete: nil)
265
344
  streamed = false
266
345
  markdown_chunks = []
267
- answer = agent.ask(input, **agent_display_options(display_input)) do |event|
268
- streamed = true if render_blocking_turn_event(event, markdown_chunks, tool_line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
346
+ answer = agent.ask(input, **agent_display_options(display_input, tool_registry: tool_registry)) do |event|
347
+ streamed = true if !suppress_transcript && render_blocking_turn_event(event, markdown_chunks, tool_line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
269
348
  end
270
349
  flush_markdown_deltas(markdown_chunks) if streamed
271
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless streamed || answer.to_s.empty?
272
- persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
273
- auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation)
350
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless suppress_transcript || streamed || answer.to_s.empty?
351
+ persist_memory_state(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript
352
+ auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript
353
+ on_complete&.call(successful: true, cancelled: false, error: nil, answer: answer)
274
354
  []
275
355
  end
276
356
 
277
- def agent_display_options(display_input)
278
- display_input.nil? ? {} : { display_input: display_input }
357
+ def agent_display_options(display_input, tool_registry: nil)
358
+ options = display_input.nil? ? {} : { display_input: display_input }
359
+ options[:tool_registry] = tool_registry if tool_registry
360
+ options
279
361
  end
280
362
 
281
363
  end
@@ -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],
@@ -242,7 +315,8 @@ module Kward
242
315
  @prompt.say(intro_message)
243
316
  end
244
317
  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)
318
+ transcript_output = record_completed_pty_output(sink, completed_result)
319
+ yield(transcript_output, completed_result) if block_given?
246
320
  end
247
321
  refresh_composer_status
248
322
  result
@@ -253,12 +327,18 @@ module Kward
253
327
 
254
328
  def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:, &on_complete)
255
329
  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)
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)
333
+ else
334
+ runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, sink: sink)
335
+ end
258
336
  on_complete.call(sink, result) if on_complete
259
337
  result
260
338
  end
339
+ end
261
340
 
341
+ def with_interactive_terminal_handoff
262
342
  if @prompt.respond_to?(:with_inline_terminal_handoff)
263
343
  @prompt.with_inline_terminal_handoff do |input, output, transition|
264
344
  sink = AdaptivePtyOutputSink.new(
@@ -266,7 +346,7 @@ module Kward
266
346
  on_exclusive: transition,
267
347
  max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
268
348
  )
269
- run_with_sink.call(input, sink)
349
+ yield(input, sink)
270
350
  end
271
351
  elsif @prompt.respond_to?(:with_terminal_handoff)
272
352
  @prompt.with_terminal_handoff do |input, output|
@@ -274,14 +354,14 @@ module Kward
274
354
  output: output,
275
355
  max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
276
356
  )
277
- run_with_sink.call(input, sink)
357
+ yield(input, sink)
278
358
  end
279
359
  else
280
360
  sink = PassthroughPtyOutputSink.new(
281
361
  output: $stdout,
282
362
  max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
283
363
  )
284
- run_with_sink.call($stdin, sink)
364
+ yield($stdin, sink)
285
365
  end
286
366
  end
287
367
 
@@ -306,6 +386,7 @@ module Kward
306
386
  if @prompt.respond_to?(:record_transient_terminal_output)
307
387
  @prompt.record_transient_terminal_output(transcript_output, render: false)
308
388
  end
389
+ transcript_output
309
390
  end
310
391
 
311
392
  def terminal_transcript_output(
@@ -341,13 +422,15 @@ module Kward
341
422
  end
342
423
  end
343
424
 
344
- 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
345
427
  with_ekwsh_history(history) do
346
- run_ekwsh_loop_with_history(shell, tab: tab)
428
+ run_ekwsh_loop_with_history(shell, tab: tab, shell_agent: shell_agent)
347
429
  end
348
430
  end
349
431
 
350
- 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 = []
351
434
  loop do
352
435
  if @prompt.respond_to?(:editing_file?) && @prompt.editing_file?
353
436
  editor_result = @prompt.run_editor
@@ -357,13 +440,18 @@ module Kward
357
440
  end
358
441
  end
359
442
 
360
- input = ask_ekwsh(shell)
443
+ input = pending_shell_inputs.shift || ask_ekwsh(shell)
361
444
  if input.is_a?(Hash) && input[:tab_action]
362
445
  (@pending_inputs ||= []).unshift(input)
363
446
  return :tab_action
364
447
  end
365
448
  break if input.nil?
366
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
+
367
455
  result = run_ekwsh_command(shell, input)
368
456
  if result.clear
369
457
  clear_active_tab_transient_shell_output
@@ -385,16 +473,26 @@ module Kward
385
473
  next
386
474
  end
387
475
  if result.exit_shell
476
+ close_ekwsh_session(shell)
388
477
  tab.shell = nil if tab
478
+ tab.shell_agent = nil if tab
389
479
  runtime_output("Shell exited.")
390
480
  return :exited
391
481
  end
392
482
  end
483
+ close_ekwsh_session(shell)
393
484
  tab.shell = nil if tab
485
+ tab.shell_agent = nil if tab
394
486
  runtime_output("Shell exited.")
395
487
  :exited
396
488
  end
397
489
 
490
+ def close_ekwsh_session(shell)
491
+ shell.close if shell.respond_to?(:close)
492
+ rescue StandardError
493
+ nil
494
+ end
495
+
398
496
  def with_ekwsh_history(history)
399
497
  if history && @prompt.respond_to?(:with_prompt_history)
400
498
  @prompt.with_prompt_history(history) { yield }
@@ -428,13 +526,19 @@ module Kward
428
526
  end
429
527
 
430
528
  def run_ekwsh_interactive_pty_command(shell, result)
529
+ shell_target = shell.respond_to?(:run_interactive) ? shell : shell.command_shell
431
530
  run_user_interactive_pty_command(
432
531
  result.interactive_command,
433
- shell: shell.command_shell,
532
+ shell: shell_target,
434
533
  env: shell.child_env(interactive: true),
435
534
  cwd: shell.cwd,
436
535
  intro: result.output
437
- )
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
438
542
  end
439
543
 
440
544
  def run_ekwsh_command(shell, input)
@@ -499,6 +603,44 @@ module Kward
499
603
  clear_active_tab_transient_shell_entries if respond_to?(:clear_active_tab_transient_shell_entries, true)
500
604
  end
501
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
+
502
644
  def drain_ekwsh_chunks(chunks)
503
645
  loop do
504
646
  @prompt.write_transcript_delta(chunks.pop(true))
@@ -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,6 +35,7 @@ module Kward
35
35
  :unread,
36
36
  :pending_question,
37
37
  :shell,
38
+ :shell_agent,
38
39
  :transient_shell_entries,
39
40
  :error_reported,
40
41
  :local_busy_activity,
@@ -317,6 +318,7 @@ module Kward
317
318
  unread: false,
318
319
  pending_question: nil,
319
320
  shell: nil,
321
+ shell_agent: nil,
320
322
  transient_shell_entries: [],
321
323
  error_reported: false,
322
324
  local_busy_activity: nil
@@ -418,12 +420,14 @@ module Kward
418
420
  end
419
421
 
420
422
  if @tabs.length <= 1
423
+ close_ekwsh_session(tab.shell) if tab&.shell && respond_to?(:close_ekwsh_session, true)
421
424
  @tabs.clear
422
425
  persist_tabs
423
426
  return PromptInterface::EXIT_INPUT
424
427
  end
425
428
 
426
429
  stop_tab_live_view
430
+ close_ekwsh_session(tab.shell) if tab&.shell && respond_to?(:close_ekwsh_session, true)
427
431
  tab.session&.delete_if_unused if tab&.session.respond_to?(:delete_if_unused)
428
432
  tab.driver.close if tab.driver.respond_to?(:close)
429
433
  @tabs.delete_at(@active_tab_index)
@@ -462,7 +466,9 @@ module Kward
462
466
  tab.seen_events = 0
463
467
  tab.queued_inputs.clear
464
468
  tab.steering = nil
469
+ close_ekwsh_session(tab.shell) if tab.shell && respond_to?(:close_ekwsh_session, true)
465
470
  tab.shell = nil
471
+ tab.shell_agent = nil
466
472
  tab.clear_transient_shell_entries
467
473
  tab.stream_state = new_tab_stream_state(tab.driver)
468
474
  tab.markdown_chunks.clear
@@ -959,11 +965,12 @@ module Kward
959
965
  def stop_tabs
960
966
  stop_tab_live_view
961
967
  Array(@tabs).each do |tab|
962
- next unless tab&.running?
963
-
964
- tab.cancellation&.cancel!
965
- tab.thread&.raise(Cancellation::CancelledError, "cancelled") if tab.thread&.alive?
966
- 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)
967
974
  rescue StandardError
968
975
  nil
969
976
  end
data/lib/kward/cli.rb CHANGED
@@ -10,11 +10,16 @@ require_relative "compactor"
10
10
  require_relative "config_files"
11
11
  require_relative "clipboard"
12
12
  require_relative "cancellation"
13
+ require_relative "editor_prompt"
14
+ require_relative "editor_prompt_session"
15
+ require_relative "shell_prompt"
16
+ require_relative "shell_prompt_session"
13
17
  require_relative "cli_transcript_formatter"
14
18
  require_relative "model/context_usage"
15
19
  require_relative "events"
16
20
  require_relative "export_path"
17
21
  require_relative "ekwsh"
22
+ require_relative "persistent_shell_session"
18
23
  require_relative "interactive_pty_runner"
19
24
  require_relative "adaptive_pty_output_sink"
20
25
  require_relative "pty_transcript_normalizer"
@@ -477,6 +482,11 @@ module Kward
477
482
  run_ekwsh_loop(active_tab.shell, tab: active_tab, history: build_ekwsh_history(active_tab.agent))
478
483
  end
479
484
  input = @pending_inputs.shift || (active_tab ? poll_active_tab_input : @prompt.ask("You>"))
485
+ if input.is_a?(Hash) && input[:editor_prompt]
486
+ pending_inputs = run_editor_prompt_turn(input[:editor_prompt], active_tab&.agent || agent)
487
+ pending_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
488
+ next
489
+ end
480
490
  if input.is_a?(Hash) && input[:tab_action]
481
491
  tab_result = handle_tab_action(input, session_store)
482
492
  break if tab_result == PromptInterface::EXIT_INPUT
@@ -31,13 +31,15 @@ module Kward
31
31
  parts = [role]
32
32
  if role.to_s == "compactionSummary"
33
33
  parts << value(message, :summary)
34
- else
34
+ elsif response_items(message).empty?
35
35
  parts << content_text(value(message, :content))
36
- end
37
- parts << value(message, :reasoning_summary)
38
- tool_calls(message).each do |tool_call|
39
- parts << tool_call_name(tool_call)
40
- parts << tool_call_arguments(tool_call)
36
+ parts << value(message, :reasoning_summary)
37
+ tool_calls(message).each do |tool_call|
38
+ parts << tool_call_name(tool_call)
39
+ parts << tool_call_arguments(tool_call)
40
+ end
41
+ else
42
+ parts << JSON.generate(response_items(message))
41
43
  end
42
44
  parts << value(message, :tool_call_id)
43
45
  parts << value(message, :name)
@@ -65,6 +67,10 @@ module Kward
65
67
  MessageAccess.tool_calls(message)
66
68
  end
67
69
 
70
+ def response_items(message)
71
+ MessageAccess.response_items(message)
72
+ end
73
+
68
74
  def tool_call_name(tool_call)
69
75
  ToolCall.name(tool_call)
70
76
  end
@@ -136,6 +136,7 @@ module Kward
136
136
  },
137
137
  "editor" => {
138
138
  "mode" => "modern",
139
+ "agent" => {},
139
140
  "auto_indent" => true,
140
141
  "auto_close_pairs" => true,
141
142
  "soft_wrap" => true,
@@ -482,6 +483,22 @@ module Kward
482
483
  EditorMode.normalize(editor["mode"])
483
484
  end
484
485
 
486
+ # Returns the optional model override for editor-agent turns.
487
+ def editor_agent_model(config = read_config)
488
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
489
+ agent = editor["agent"].is_a?(Hash) ? editor["agent"] : {}
490
+ value = agent["model"].to_s.strip
491
+ value.empty? ? nil : value
492
+ end
493
+
494
+ # Returns the optional reasoning-effort override for editor-agent turns.
495
+ def editor_agent_reasoning_effort(config = read_config)
496
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
497
+ agent = editor["agent"].is_a?(Hash) ? editor["agent"] : {}
498
+ value = agent["reasoning_effort"].to_s.strip
499
+ value.empty? ? nil : value
500
+ end
501
+
485
502
  # Returns whether the built-in TUI editor should auto-indent new lines.
486
503
  def editor_auto_indent?(config = read_config)
487
504
  editor = config["editor"].is_a?(Hash) ? config["editor"] : {}