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
data/lib/kward/cli.rb CHANGED
@@ -10,12 +10,19 @@ 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"
24
+ require_relative "adaptive_pty_output_sink"
25
+ require_relative "pty_transcript_normalizer"
19
26
  require_relative "auth/anthropic_oauth"
20
27
  require_relative "auth/api_key_store"
21
28
  require_relative "auth/github_oauth"
@@ -475,6 +482,11 @@ module Kward
475
482
  run_ekwsh_loop(active_tab.shell, tab: active_tab, history: build_ekwsh_history(active_tab.agent))
476
483
  end
477
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
478
490
  if input.is_a?(Hash) && input[:tab_action]
479
491
  tab_result = handle_tab_action(input, session_store)
480
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"] : {}
@@ -0,0 +1,46 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Builds the model-facing request for an in-memory editor prompt.
4
+ module EditorPrompt
5
+ module_function
6
+
7
+ def system_message
8
+ {
9
+ role: "system",
10
+ content: <<~PROMPT.strip
11
+ You are Kward's in-memory editor assistant.
12
+
13
+ Act only on the user's explicit request about the active editor buffer. Do not volunteer changes, explanations, or unrelated work. For an actionable edit request, call replace_editor_buffer with the complete replacement buffer. For a request that is only a question or needs clarification, answer briefly without changing the buffer. Never write files to disk and never claim that the buffer was saved.
14
+ PROMPT
15
+ }
16
+ end
17
+
18
+ def input(instruction, context)
19
+ document = context[:display_path].to_s
20
+ language = context[:language].to_s
21
+ language = "text" if language.empty?
22
+ content = context[:content].to_s
23
+
24
+ <<~PROMPT
25
+ You are operating inside an interactive editor.
26
+
27
+ The user requested:
28
+ #{instruction}
29
+
30
+ Active document: #{document.empty? ? "(untitled)" : document}
31
+ Language: #{language}
32
+ Cursor: line #{context.dig(:cursor, 0).to_i + 1}, column #{context.dig(:cursor, 1).to_i + 1}
33
+ Selected text: #{context[:selection].to_s.empty? ? "(none)" : "present; full-buffer replacement is required"}
34
+
35
+ Current buffer:
36
+ <editor_buffer>
37
+ #{content}
38
+ </editor_buffer>
39
+
40
+ Satisfy the user's request by calling replace_editor_buffer with the complete desired buffer contents.
41
+ The operation changes only the in-memory editor buffer. Do not write files to disk.
42
+ If the request is ambiguous, explain the ambiguity instead of changing the buffer.
43
+ PROMPT
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,28 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Transactional draft used while an agent works on an active editor buffer.
4
+ #
5
+ # The live terminal editor remains owned by PromptInterface. Agent tool calls
6
+ # update this draft and the caller commits it only after a successful turn.
7
+ class EditorPromptSession
8
+ attr_reader :context, :content
9
+
10
+ def initialize(context)
11
+ @context = context.dup
12
+ @initial_content = @context.fetch(:content).to_s.dup
13
+ @content = @initial_content.dup
14
+ end
15
+
16
+ def replace(content)
17
+ @content = content.to_s
18
+ end
19
+
20
+ def changed?
21
+ @content != @initial_content
22
+ end
23
+
24
+ def initial_content
25
+ @initial_content.dup
26
+ end
27
+ end
28
+ end
data/lib/kward/ekwsh.rb CHANGED
@@ -18,17 +18,16 @@ module Kward
18
18
  DEFAULT_TIMEOUT_SECONDS = 300
19
19
  DEFAULT_MAX_OUTPUT_BYTES = 1_048_576
20
20
  DEFAULT_HISTORY_LIMIT = 1_000
21
+ CONTEXT_OUTPUT_BYTES = 32_000
21
22
 
22
- attr_reader :cwd
23
+ attr_reader :cwd, :last_command, :timeout_seconds
23
24
 
24
25
  def command_shell
25
26
  @shell
26
27
  end
27
28
 
28
29
  def child_env(interactive: false)
29
- env = @env.dup
30
- env.delete("GIT_PAGER") if interactive && @defaulted_git_pager
31
- env
30
+ @env.dup
32
31
  end
33
32
 
34
33
  def initialize(cwd: Dir.pwd, env: ENV.to_h, shell: DEFAULT_SHELL, configured_env: {}, aliases: {}, timeout_seconds: DEFAULT_TIMEOUT_SECONDS, max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES)
@@ -43,6 +42,8 @@ module Kward
43
42
  @shell = shell.to_s.empty? ? DEFAULT_SHELL : shell.to_s
44
43
  @timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i : DEFAULT_TIMEOUT_SECONDS
45
44
  @max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i : DEFAULT_MAX_OUTPUT_BYTES
45
+ @last_command = nil
46
+ @last_result = nil
46
47
  end
47
48
 
48
49
  def prompt_label
@@ -53,7 +54,45 @@ module Kward
53
54
  command = input.to_s.strip
54
55
  return Result.new(output: "", exit_status: 0) if command.empty?
55
56
 
56
- run_expanded_command(command, cancellation: cancellation, &block)
57
+ result = run_expanded_command(command, cancellation: cancellation, &block)
58
+ remember_result(command, result)
59
+ result
60
+ end
61
+
62
+ # Runs a command for the shell assistant without handing it the terminal.
63
+ # Built-ins still execute in this Ekwsh instance so their state persists.
64
+ def run_for_agent(input, timeout_seconds: nil, cancellation: nil)
65
+ command = input.to_s.strip
66
+ return run(command, cancellation: cancellation) if command.empty?
67
+
68
+ result = run(command, cancellation: cancellation)
69
+ return result unless result.interactive_command
70
+
71
+ result = execute(
72
+ result.interactive_command,
73
+ display_command: command,
74
+ timeout_seconds: timeout_seconds,
75
+ cancellation: cancellation
76
+ )
77
+ remember_result(command, result)
78
+ result
79
+ end
80
+
81
+ # Records the output of an interactive command after terminal handoff.
82
+ def record_interactive_result(output:, exit_status:)
83
+ return unless @last_command
84
+
85
+ @last_result = Result.new(output: ANSI.sanitize_transcript(output.to_s), exit_status: exit_status)
86
+ end
87
+
88
+ # Returns bounded shell state for an explicit shell-agent prompt.
89
+ def context_snapshot(max_output_bytes: CONTEXT_OUTPUT_BYTES)
90
+ {
91
+ cwd: @cwd,
92
+ last_command: @last_command,
93
+ exit_status: @last_result&.exit_status,
94
+ last_output: context_output(@last_result&.output, max_output_bytes)
95
+ }
57
96
  end
58
97
 
59
98
  def complete(input, cursor)
@@ -112,8 +151,6 @@ module Kward
112
151
  def configure_color_environment
113
152
  @env["CLICOLOR"] ||= "1"
114
153
  @env["COLORTERM"] ||= "truecolor"
115
- @defaulted_git_pager = !@env.key?("GIT_PAGER")
116
- @env["GIT_PAGER"] ||= "cat"
117
154
  @env["TERM"] = "xterm-256color" if @env["TERM"].to_s.empty? || @env["TERM"] == "dumb"
118
155
  end
119
156
 
@@ -522,12 +559,13 @@ module Kward
522
559
  key.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
523
560
  end
524
561
 
525
- def execute(command, display_command: command, cancellation: nil)
562
+ def execute(command, display_command: command, timeout_seconds: nil, cancellation: nil)
526
563
  output = command_echo(display_command)
527
564
  streamed = block_given?
528
565
  yield output.dup if streamed
566
+ timeout = effective_timeout(timeout_seconds)
529
567
  result = external_command_runner.new(
530
- timeout_seconds: @timeout_seconds,
568
+ timeout_seconds: timeout,
531
569
  max_output_bytes: @max_output_bytes,
532
570
  terminate_on_output_limit: true
533
571
  ).run(@shell, "-c", command, env: @env, cwd: @cwd, cancellation: cancellation) do |_stream, chunk|
@@ -537,7 +575,7 @@ module Kward
537
575
  end
538
576
  append_output_newline(output) { |text| yield text if streamed }
539
577
  exit_status = result.timed_out || result.truncated ? 1 : (result.exit_status || 1)
540
- append_streamed(output, "ekwsh: command timed out after #{@timeout_seconds} seconds\n", streamed) { |text| yield text } if result.timed_out
578
+ append_streamed(output, "ekwsh: command timed out after #{timeout} seconds\n", streamed) { |text| yield text } if result.timed_out
541
579
  append_streamed(output, "ekwsh: output exceeded #{@max_output_bytes} bytes; command terminated\n", streamed) { |text| yield text } if result.truncated
542
580
  append_streamed(output, "Exit status: #{exit_status}\n", streamed) { |text| yield text } unless exit_status.zero?
543
581
  Result.new(output: output, exit_status: exit_status, streamed: streamed)
@@ -549,6 +587,28 @@ module Kward
549
587
  Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 127)
550
588
  end
551
589
 
590
+ def effective_timeout(timeout_seconds)
591
+ timeout = timeout_seconds.to_i
592
+ timeout.positive? ? timeout : @timeout_seconds
593
+ end
594
+
595
+ def remember_result(command, result)
596
+ @last_command = command.to_s
597
+ @last_result = result
598
+ end
599
+
600
+ def context_output(output, max_output_bytes)
601
+ text = ANSI.sanitize_transcript(output.to_s)
602
+ limit = max_output_bytes.to_i
603
+ return text if limit <= 0 || text.bytesize <= limit
604
+
605
+ prefix = "[Earlier output omitted; showing the end of the command output.]\n"
606
+ tail_limit = [limit - prefix.bytesize, 0].max
607
+ tail = text.byteslice(-tail_limit, tail_limit).to_s if tail_limit.positive?
608
+ value = "#{prefix}#{tail}"
609
+ ANSI.normalize_transcript_encoding(value.byteslice(-limit, limit).to_s)
610
+ end
611
+
552
612
  def external_command_runner
553
613
  defined?(LocalPtyCommandRunner) ? LocalPtyCommandRunner : LocalCommandRunner
554
614
  end
@@ -1,8 +1,10 @@
1
1
  require "base64"
2
2
  require "cgi/escape"
3
+ require "open3"
3
4
  require "shellwords"
4
5
  require "tmpdir"
5
6
  require "uri"
7
+ require_relative "terminal_image_support"
6
8
 
7
9
  # Namespace for the Kward CLI agent runtime.
8
10
  module Kward
@@ -291,6 +293,71 @@ module Kward
291
293
  MIME_TYPES.key?(File.extname(path.to_s).downcase)
292
294
  end
293
295
 
296
+ def image_part_from_path(path)
297
+ expanded_path = File.expand_path(path.to_s)
298
+ return nil unless File.file?(expanded_path)
299
+
300
+ media_type = mime_type(expanded_path)
301
+ return nil unless media_type
302
+
303
+ size = File.size(expanded_path)
304
+ return nil if size > MAX_IMAGE_BYTES
305
+
306
+ {
307
+ type: "image",
308
+ media_type: media_type,
309
+ data: Base64.strict_encode64(File.binread(expanded_path)),
310
+ path: expanded_path,
311
+ size_bytes: size
312
+ }
313
+ rescue SystemCallError
314
+ nil
315
+ end
316
+
317
+ def terminal_image_part(part, protocol)
318
+ return part unless protocol == :kitty
319
+ return part if image_media_type(part) == "image/png"
320
+
321
+ convert_image_part_to_png(part)
322
+ end
323
+
324
+ def png_dimensions(part)
325
+ encoded = part[:data] || part["data"]
326
+ header = Base64.strict_decode64(encoded.to_s[0, 32])
327
+ return nil unless header.start_with?("\x89PNG\r\n\x1A\n".b)
328
+ return nil unless header.byteslice(12, 4) == "IHDR"
329
+
330
+ width, height = header.byteslice(16, 8).unpack("NN")
331
+ return nil unless width.positive? && height.positive?
332
+
333
+ [width, height]
334
+ rescue ArgumentError, NoMethodError
335
+ nil
336
+ end
337
+
338
+ def convert_image_part_to_png(part)
339
+ path = part[:path] || part["path"]
340
+ return nil unless path && File.file?(path)
341
+
342
+ stdout, _stderr, status = Open3.capture3(
343
+ "magick",
344
+ "-limit", "memory", "128MiB",
345
+ "-limit", "map", "256MiB",
346
+ "#{path}[0]",
347
+ "png:-"
348
+ )
349
+ return nil unless status.success?
350
+ return nil if stdout.empty? || stdout.bytesize > MAX_IMAGE_BYTES
351
+
352
+ part.merge(
353
+ media_type: "image/png",
354
+ data: Base64.strict_encode64(stdout),
355
+ size_bytes: stdout.bytesize
356
+ )
357
+ rescue SystemCallError
358
+ nil
359
+ end
360
+
294
361
  def mime_type(path)
295
362
  MIME_TYPES[File.extname(path.to_s).downcase]
296
363
  end
@@ -300,36 +367,52 @@ module Kward
300
367
  "data:#{media_type};base64,#{part[:data] || part["data"]}"
301
368
  end
302
369
 
303
- def terminal_image_sequence(part, width: DEFAULT_TERMINAL_IMAGE_WIDTH, env: ENV)
370
+ def terminal_image_sequence(part, width: DEFAULT_TERMINAL_IMAGE_WIDTH, height: nil, protocol: nil, image_id: nil, move_cursor: true, env: ENV)
304
371
  data = part[:data] || part["data"]
305
372
  return nil if data.to_s.empty?
306
373
 
374
+ protocol ||= terminal_image_protocol(env)
307
375
  name = part[:path] || part["path"]
308
- if iterm_image_protocol?(env)
309
- iterm_image_sequence(data, name, width)
310
- elsif kitty_image_protocol?(env)
311
- kitty_image_sequence(data, name, width)
376
+ case protocol
377
+ when :iterm2
378
+ iterm_image_sequence(data, name, width, height, env: env)
379
+ when :kitty
380
+ return nil unless image_media_type(part) == "image/png"
381
+
382
+ kitty_image_sequence(data, name, width, height, image_id, move_cursor: move_cursor, env: env)
312
383
  end
313
384
  end
314
385
 
386
+ def terminal_image_protocol(env)
387
+ TerminalImageSupport.static_protocol(env)
388
+ end
389
+
315
390
  def iterm_image_protocol?(env)
316
- env["TERM_PROGRAM"] == "iTerm.app"
391
+ TerminalImageSupport.iterm2_hint?(env)
317
392
  end
318
393
 
319
394
  def kitty_image_protocol?(env)
320
- env["KITTY_WINDOW_ID"].to_s != "" || env["TERM"].to_s.include?("kitty") || env["TERM_PROGRAM"] == "WezTerm"
395
+ TerminalImageSupport.kitty_hint?(env)
396
+ end
397
+
398
+ def iterm_image_sequence(data, name, width, height = nil, env: ENV)
399
+ sequence = TerminalSequences.iterm2_image(data, name: name, width: width, height: height)
400
+ env["TMUX"].to_s.empty? ? sequence : TerminalSequences.tmux_passthrough(sequence)
321
401
  end
322
402
 
323
- def iterm_image_sequence(data, name, width)
324
- params = ["inline=1", "preserveAspectRatio=1", "width=#{width}"]
325
- params << "name=#{Base64.strict_encode64(File.basename(name))}" if name
326
- "\e]1337;File=#{params.join(";")}:#{data}\a"
403
+ def kitty_image_sequence(data, _name, width, height = nil, image_id = nil, move_cursor: true, env: ENV)
404
+ sequence = TerminalSequences.kitty_graphics(
405
+ data,
406
+ image_id: image_id,
407
+ columns: width,
408
+ rows: height,
409
+ move_cursor: move_cursor
410
+ )
411
+ env["TMUX"].to_s.empty? ? sequence : TerminalSequences.tmux_passthrough(sequence)
327
412
  end
328
413
 
329
- def kitty_image_sequence(data, name, width)
330
- params = ["inline=1", "preserveAspectRatio=1", "width=#{width}"]
331
- params << "name=#{Base64.strict_encode64(File.basename(name))}" if name
332
- "\e_G#{params.join(";")}:#{data}\e\\"
414
+ def image_media_type(part)
415
+ part[:media_type] || part["media_type"] || part[:mimeType] || part["mimeType"]
333
416
  end
334
417
  end
335
418
  end
@@ -2,10 +2,11 @@ require "io/console"
2
2
  require "pty"
3
3
  require "timeout"
4
4
  require_relative "local_pty_command_runner"
5
+ require_relative "pty_output_sink"
5
6
 
6
7
  # Namespace for the Kward CLI agent runtime.
7
8
  module Kward
8
- # Runs a command in a PTY while forwarding caller-owned input and output IOs.
9
+ # Runs a command in a PTY while forwarding caller-owned input and output sink.
9
10
  # This is intentionally low level: UI orchestration decides when terminal
10
11
  # ownership is handed to the child process and how the result is presented.
11
12
  class InteractivePtyRunner
@@ -18,7 +19,7 @@ module Kward
18
19
  @window_size = nil
19
20
  end
20
21
 
21
- def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, output: $stdout, &block)
22
+ def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, sink:)
22
23
  pid = nil
23
24
  status = nil
24
25
  writer = nil
@@ -29,9 +30,11 @@ module Kward
29
30
  writer = child_writer
30
31
  update_window_size(reader, pid)
31
32
  with_raw_input(input) do
32
- drain_initial_input(input, writer)
33
- status, input_forwarded = forward_io(reader, writer, pid, input, output, &block)
33
+ input_forwarded = drain_initial_input(input, writer, sink)
34
+ status, forwarded = forward_io(reader, writer, pid, input, sink)
35
+ input_forwarded ||= forwarded
34
36
  end
37
+ sink.finish if sink.respond_to?(:finish)
35
38
  status ||= wait_for_status(pid)
36
39
  end
37
40
 
@@ -57,19 +60,23 @@ module Kward
57
60
  yield
58
61
  end
59
62
 
60
- def drain_initial_input(input, writer)
63
+ def drain_initial_input(input, writer, sink)
64
+ forwarded = false
61
65
  loop do
62
66
  chunk = input.read_nonblock(READ_SIZE, exception: false)
63
67
  break if chunk.nil? || chunk == :wait_readable
64
68
 
69
+ sink.input_forwarded if sink.respond_to?(:input_forwarded)
65
70
  writer.write(chunk)
71
+ forwarded = true
66
72
  end
67
73
  writer.flush
74
+ forwarded
68
75
  rescue Errno::EIO, Errno::EPIPE, IOError
69
- nil
76
+ forwarded
70
77
  end
71
78
 
72
- def forward_io(reader, writer, pid, input, output, &block)
79
+ def forward_io(reader, writer, pid, input, sink)
73
80
  input_open = true
74
81
  input_forwarded = false
75
82
  loop do
@@ -77,9 +84,9 @@ module Kward
77
84
  readers = [reader]
78
85
  readers << input if input_open
79
86
  readable = IO.select(readers, nil, nil, 0.02)&.first || []
80
- forward_pty_output(reader, output, &block) if readable.include?(reader)
87
+ forward_pty_output(reader, sink) if readable.include?(reader)
81
88
  if input_open && readable.include?(input)
82
- input_open, forwarded = forward_input(input, writer)
89
+ input_open, forwarded = forward_input(input, writer, sink)
83
90
  input_forwarded ||= forwarded
84
91
  end
85
92
 
@@ -88,23 +95,22 @@ module Kward
88
95
 
89
96
  return [finish_stopped_process(pid), input_forwarded] if status.stopped?
90
97
 
91
- drain_pty_output(reader, output, &block)
98
+ drain_pty_output(reader, sink)
92
99
  return [status, input_forwarded]
93
100
  rescue Errno::EIO
94
101
  return [wait_for_status(pid), input_forwarded]
95
102
  end
96
103
  end
97
104
 
98
- def forward_pty_output(reader, output)
105
+ def forward_pty_output(reader, sink)
99
106
  chunk = reader.read_nonblock(READ_SIZE, exception: false)
100
107
  return if chunk.nil? || chunk == :wait_readable
101
108
 
102
- output.write(chunk)
103
- output.flush if output.respond_to?(:flush)
104
- yield chunk if block_given?
109
+ sink.write(chunk)
110
+ sink.flush if sink.respond_to?(:flush)
105
111
  end
106
112
 
107
- def drain_pty_output(reader, output)
113
+ def drain_pty_output(reader, sink)
108
114
  loop do
109
115
  readable, = IO.select([reader], nil, nil, 0.02)
110
116
  break unless readable
@@ -112,19 +118,19 @@ module Kward
112
118
  chunk = reader.read_nonblock(READ_SIZE, exception: false)
113
119
  break if chunk.nil? || chunk == :wait_readable
114
120
 
115
- output.write(chunk)
116
- yield chunk if block_given?
121
+ sink.write(chunk)
117
122
  end
118
- output.flush if output.respond_to?(:flush)
123
+ sink.flush if sink.respond_to?(:flush)
119
124
  rescue Errno::EIO
120
125
  nil
121
126
  end
122
127
 
123
- def forward_input(input, writer)
128
+ def forward_input(input, writer, sink)
124
129
  chunk = input.read_nonblock(READ_SIZE, exception: false)
125
130
  return [false, false] if chunk.nil?
126
131
  return [true, false] if chunk == :wait_readable
127
132
 
133
+ sink.input_forwarded if sink.respond_to?(:input_forwarded)
128
134
  writer.write(chunk)
129
135
  writer.flush
130
136
  [true, true]
@@ -906,7 +906,7 @@ module Kward
906
906
  request["Content-Type"] = "application/json"
907
907
  request["Accept"] = "text/event-stream"
908
908
  request.body = request_body || JSON.dump(codex_payload(messages, tools, max_tokens: max_tokens))
909
- apply_codex_identity(request, luna: luna_request?(request.body))
909
+ request["originator"] = "kward"
910
910
 
911
911
  message = nil
912
912
  Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
@@ -931,22 +931,6 @@ module Kward
931
931
  raise e
932
932
  end
933
933
 
934
- def apply_codex_identity(request, luna:)
935
- request["originator"] = "kward"
936
- return unless luna
937
-
938
- # TODO: Remove this Luna-specific Responses Lite workaround when Codex accepts Kward's own client identity.
939
- request["originator"] = "codex_cli_rs"
940
- request["User-Agent"] = "codex_cli_rs/0.144.1"
941
- request["x-openai-internal-codex-responses-lite"] = "true"
942
- end
943
-
944
- def luna_request?(request_body)
945
- JSON.parse(request_body)["model"] == "gpt-5.6-luna"
946
- rescue JSON::ParserError
947
- false
948
- end
949
-
950
934
  def parse_codex_sse(body, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil)
951
935
  ModelStreamParser.parse_codex_sse(body, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, show_raw_reasoning: codex_show_raw_reasoning?, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
952
936
  end
@@ -12,6 +12,7 @@ module Kward
12
12
  OPENAI_MODEL_CHOICES = %w[gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna gpt-5.5 gpt-5.4 gpt-5.4-mini gpt-5.3-codex-spark].freeze
13
13
  ANTHROPIC_MODEL_CHOICES = %w[
14
14
  claude-fable-5
15
+ claude-opus-5
15
16
  claude-opus-4-8
16
17
  claude-sonnet-5
17
18
  claude-sonnet-4-6
@@ -113,7 +114,7 @@ module Kward
113
114
  [/\Agpt-3\.5-turbo/, 16_385]
114
115
  ].freeze
115
116
  ANTHROPIC_CONTEXT_WINDOWS = [
116
- [/\Aclaude-(?:fable|mythos|sonnet)-5(?:\z|-)/, 1_000_000],
117
+ [/\Aclaude-(?:fable|mythos|opus|sonnet)-5(?:\z|-)/, 1_000_000],
117
118
  [/\Aclaude-opus-4-(?:6|7|8)(?:\z|-)/, 1_000_000],
118
119
  [/\Aclaude-sonnet-4-6(?:\z|-)/, 1_000_000],
119
120
  [/\Aclaude-(?:haiku|opus|sonnet)-4-5(?:\z|-)/, 200_000],
@@ -454,7 +455,7 @@ module Kward
454
455
 
455
456
  def anthropic_reasoning_effort_choices(id)
456
457
  text = normalize_anthropic_model(id)
457
- return ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:fable|mythos|sonnet)-5(?:\z|-)|\Aclaude-opus-4-(?:7|8)(?:\z|-)/)
458
+ return ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:fable|mythos|opus|sonnet)-5(?:\z|-)|\Aclaude-opus-4-(?:7|8)(?:\z|-)/)
458
459
  return ANTHROPIC_STANDARD_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:opus-4-6|sonnet-4-6)(?:\z|-)/)
459
460
  return ANTHROPIC_OPUS_4_5_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-opus-4-5(?:\z|-)/)
460
461
 
@@ -189,8 +189,6 @@ module Kward
189
189
  unless reasoning == false
190
190
  effort = reasoning || reasoning_effort(provider)
191
191
  payload[:reasoning] = { effort: effort, summary: "auto" }
192
- # TODO: Remove this Luna-specific Responses Lite workaround when Codex accepts Kward's own client identity.
193
- payload[:reasoning][:context] = "all_turns" if provider == "Codex" && parts[:model] == "gpt-5.6-luna"
194
192
  end
195
193
  payload
196
194
  end