kward 0.81.0 → 0.82.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -1
- data/Rakefile +44 -4
- data/doc/composer.md +2 -2
- data/doc/files.md +8 -2
- data/doc/releasing.md +71 -38
- data/doc/security.md +1 -1
- data/doc/shell.md +3 -3
- data/kward.gemspec +1 -1
- data/lib/kward/adaptive_pty_output_sink.rb +183 -0
- data/lib/kward/cli/git.rb +5 -1
- data/lib/kward/cli/rendering.rb +14 -2
- data/lib/kward/cli/runtime_helpers.rb +103 -39
- data/lib/kward/cli/settings.rb +5 -13
- data/lib/kward/cli/tabs.rb +59 -4
- data/lib/kward/cli.rb +2 -0
- data/lib/kward/image_attachments.rb +98 -15
- data/lib/kward/interactive_pty_runner.rb +25 -19
- data/lib/kward/prompt_interface/composer_controller.rb +3 -3
- data/lib/kward/prompt_interface/composer_renderer.rb +27 -0
- data/lib/kward/prompt_interface/editor/controller.rb +6 -6
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +1 -1
- data/lib/kward/prompt_interface/key_handler.rb +22 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +1 -0
- data/lib/kward/prompt_interface/project_browser.rb +162 -3
- data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
- data/lib/kward/prompt_interface/question_prompt.rb +1 -1
- data/lib/kward/prompt_interface/screen.rb +40 -20
- data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
- data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
- data/lib/kward/prompt_interface.rb +83 -32
- data/lib/kward/pty_output_sink.rb +45 -0
- data/lib/kward/pty_transcript_normalizer.rb +93 -0
- data/lib/kward/session_catalog.rb +87 -0
- data/lib/kward/session_store.rb +94 -5
- data/lib/kward/terminal_image_support.rb +116 -0
- data/lib/kward/terminal_sequences.rb +43 -0
- data/lib/kward/version.rb +1 -1
- metadata +7 -4
- data/.github/workflows/ci.yml +0 -48
- data/.github/workflows/pages.yml +0 -48
|
@@ -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
|
|
@@ -235,65 +235,101 @@ module Kward
|
|
|
235
235
|
|
|
236
236
|
def run_user_interactive_pty_command(command, shell:, env:, cwd:, intro: nil)
|
|
237
237
|
intro_message = intro || "$ #{command}\n"
|
|
238
|
+
record_tab_transient_shell_output(intro_message, render: false)
|
|
238
239
|
if @prompt.respond_to?(:write_transcript)
|
|
239
240
|
@prompt.write_transcript(intro_message)
|
|
240
241
|
elsif @prompt.respond_to?(:say)
|
|
241
242
|
@prompt.say(intro_message)
|
|
242
243
|
end
|
|
243
|
-
|
|
244
|
-
|
|
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)
|
|
244
|
+
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)
|
|
260
246
|
end
|
|
247
|
+
refresh_composer_status
|
|
261
248
|
result
|
|
262
249
|
rescue Errno::ENOENT => e
|
|
263
250
|
runtime_output("Error: #{e.message}")
|
|
264
251
|
nil
|
|
265
252
|
end
|
|
266
253
|
|
|
267
|
-
def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:,
|
|
254
|
+
def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:, &on_complete)
|
|
268
255
|
runner = InteractivePtyRunner.new
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
259
|
+
result
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
if @prompt.respond_to?(:with_inline_terminal_handoff)
|
|
263
|
+
@prompt.with_inline_terminal_handoff do |input, output, transition|
|
|
264
|
+
sink = AdaptivePtyOutputSink.new(
|
|
265
|
+
output: output,
|
|
266
|
+
on_exclusive: transition,
|
|
267
|
+
max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
|
|
268
|
+
)
|
|
269
|
+
run_with_sink.call(input, sink)
|
|
270
|
+
end
|
|
271
|
+
elsif @prompt.respond_to?(:with_terminal_handoff)
|
|
272
|
+
@prompt.with_terminal_handoff do |input, output|
|
|
273
|
+
sink = PassthroughPtyOutputSink.new(
|
|
274
|
+
output: output,
|
|
275
|
+
max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
|
|
276
|
+
)
|
|
277
|
+
run_with_sink.call(input, sink)
|
|
278
278
|
end
|
|
279
279
|
else
|
|
280
|
-
|
|
280
|
+
sink = PassthroughPtyOutputSink.new(
|
|
281
|
+
output: $stdout,
|
|
282
|
+
max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
|
|
283
|
+
)
|
|
284
|
+
run_with_sink.call($stdin, sink)
|
|
281
285
|
end
|
|
282
286
|
end
|
|
283
287
|
|
|
284
|
-
def
|
|
285
|
-
|
|
288
|
+
def record_completed_pty_output(sink, result)
|
|
289
|
+
classified_output = sink.respond_to?(:transcript_safe?)
|
|
290
|
+
return if classified_output && !sink.transcript_safe?
|
|
291
|
+
|
|
292
|
+
output = sink&.captured_output || +"".b
|
|
293
|
+
allow_input = classified_output &&
|
|
294
|
+
sink.respond_to?(:pre_input_capture_only?) &&
|
|
295
|
+
sink.pre_input_capture_only?
|
|
296
|
+
transcript_output = terminal_transcript_output(
|
|
297
|
+
output,
|
|
298
|
+
result,
|
|
299
|
+
truncated: sink&.truncated? || false,
|
|
300
|
+
allow_input: allow_input,
|
|
301
|
+
normalize_line_controls: classified_output
|
|
302
|
+
)
|
|
303
|
+
return unless transcript_output
|
|
304
|
+
|
|
305
|
+
record_tab_transient_shell_output(transcript_output, render: false)
|
|
306
|
+
if @prompt.respond_to?(:record_transient_terminal_output)
|
|
307
|
+
@prompt.record_transient_terminal_output(transcript_output, render: false)
|
|
308
|
+
end
|
|
286
309
|
end
|
|
287
310
|
|
|
288
|
-
def terminal_transcript_output(
|
|
289
|
-
|
|
311
|
+
def terminal_transcript_output(
|
|
312
|
+
output,
|
|
313
|
+
result,
|
|
314
|
+
truncated:,
|
|
315
|
+
allow_input: false,
|
|
316
|
+
normalize_line_controls: false
|
|
317
|
+
)
|
|
318
|
+
return if truncated || (result.input_forwarded && !allow_input)
|
|
290
319
|
|
|
291
320
|
text = ANSI.normalize_transcript_encoding(output).gsub("\r\n", "\n")
|
|
292
|
-
return if text.empty?
|
|
321
|
+
return if text.empty?
|
|
322
|
+
|
|
323
|
+
if normalize_line_controls
|
|
324
|
+
sanitized = PtyTranscriptNormalizer.normalize(text)
|
|
325
|
+
else
|
|
326
|
+
return if text.include?("\r")
|
|
293
327
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
328
|
+
sanitized = ANSI.sanitize_transcript(text)
|
|
329
|
+
return unless sanitized == text
|
|
330
|
+
end
|
|
331
|
+
return if sanitized.empty?
|
|
332
|
+
return if ANSI.strip_control_sequences(sanitized).match?(UNSAFE_TRANSCRIPT_CONTROL_PATTERN)
|
|
297
333
|
|
|
298
334
|
sanitized
|
|
299
335
|
end
|
|
@@ -329,8 +365,13 @@ module Kward
|
|
|
329
365
|
break if input.nil?
|
|
330
366
|
|
|
331
367
|
result = run_ekwsh_command(shell, input)
|
|
332
|
-
|
|
333
|
-
|
|
368
|
+
if result.clear
|
|
369
|
+
clear_active_tab_transient_shell_output
|
|
370
|
+
@prompt.clear_transcript if @prompt.respond_to?(:clear_transcript)
|
|
371
|
+
else
|
|
372
|
+
record_tab_transient_shell_output(result.output, render: false) unless result.interactive_command
|
|
373
|
+
@prompt.say(result.output) unless result.streamed || result.interactive_command || result.output.to_s.empty?
|
|
374
|
+
end
|
|
334
375
|
return :tab_action if pending_tab_action?
|
|
335
376
|
|
|
336
377
|
if result.open_editor_path
|
|
@@ -443,6 +484,21 @@ module Kward
|
|
|
443
484
|
result
|
|
444
485
|
end
|
|
445
486
|
|
|
487
|
+
def record_tab_transient_shell_output(text, render: true)
|
|
488
|
+
value = text.to_s
|
|
489
|
+
return if value.empty?
|
|
490
|
+
|
|
491
|
+
tab = active_tab if respond_to?(:active_tab, true)
|
|
492
|
+
tab&.append_transient_shell_entry(value)
|
|
493
|
+
if render && @prompt.respond_to?(:record_transient_terminal_output)
|
|
494
|
+
@prompt.record_transient_terminal_output(value)
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def clear_active_tab_transient_shell_output
|
|
499
|
+
clear_active_tab_transient_shell_entries if respond_to?(:clear_active_tab_transient_shell_entries, true)
|
|
500
|
+
end
|
|
501
|
+
|
|
446
502
|
def drain_ekwsh_chunks(chunks)
|
|
447
503
|
loop do
|
|
448
504
|
@prompt.write_transcript_delta(chunks.pop(true))
|
|
@@ -559,6 +615,14 @@ module Kward
|
|
|
559
615
|
@client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT
|
|
560
616
|
end
|
|
561
617
|
|
|
618
|
+
def refresh_composer_status
|
|
619
|
+
if @prompt.respond_to?(:refresh_composer_status)
|
|
620
|
+
@prompt.refresh_composer_status
|
|
621
|
+
elsif @prompt.respond_to?(:redraw)
|
|
622
|
+
@prompt.redraw
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
|
|
562
626
|
def reload_client_config
|
|
563
627
|
@client.reload_config if @client.respond_to?(:reload_config)
|
|
564
628
|
end
|
data/lib/kward/cli/settings.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
956
|
+
refresh_composer_status
|
|
957
957
|
else
|
|
958
958
|
refresh_conversation_runtime(conversation, reasoning_effort: effort)
|
|
959
|
-
|
|
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
|
|
data/lib/kward/cli/tabs.rb
CHANGED
|
@@ -35,10 +35,13 @@ module Kward
|
|
|
35
35
|
:unread,
|
|
36
36
|
:pending_question,
|
|
37
37
|
:shell,
|
|
38
|
+
:transient_shell_entries,
|
|
38
39
|
:error_reported,
|
|
39
40
|
:local_busy_activity,
|
|
40
41
|
keyword_init: true
|
|
41
42
|
) do
|
|
43
|
+
TRANSIENT_SHELL_OUTPUT_LIMIT = 200_000
|
|
44
|
+
|
|
42
45
|
def running?
|
|
43
46
|
%w[queued running waiting_for_question].include?(status.to_s)
|
|
44
47
|
end
|
|
@@ -54,6 +57,41 @@ module Kward
|
|
|
54
57
|
def record_event(event)
|
|
55
58
|
event_history << event
|
|
56
59
|
end
|
|
60
|
+
|
|
61
|
+
def append_transient_shell_entry(text)
|
|
62
|
+
value = text.to_s
|
|
63
|
+
return if value.empty?
|
|
64
|
+
|
|
65
|
+
self.transient_shell_entries ||= []
|
|
66
|
+
transient_shell_entries << value
|
|
67
|
+
trim_transient_shell_entries
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def clear_transient_shell_entries
|
|
71
|
+
self.transient_shell_entries = []
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def trim_transient_shell_entries
|
|
77
|
+
combined = transient_shell_entries.join
|
|
78
|
+
return if combined.length <= TRANSIENT_SHELL_OUTPUT_LIMIT
|
|
79
|
+
|
|
80
|
+
target = (TRANSIENT_SHELL_OUTPUT_LIMIT * 0.9).floor
|
|
81
|
+
cutoff = combined.length - target
|
|
82
|
+
newline = combined.index("\n", cutoff)
|
|
83
|
+
cutoff = newline + 1 if newline
|
|
84
|
+
cutoff = transient_shell_escape_boundary(combined, cutoff)
|
|
85
|
+
self.transient_shell_entries = [combined[cutoff..].to_s]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def transient_shell_escape_boundary(text, cutoff)
|
|
89
|
+
escape_start = text.rindex("\e", cutoff)
|
|
90
|
+
return cutoff unless escape_start
|
|
91
|
+
|
|
92
|
+
escape = ANSI.escape_sequence_at(text, escape_start)
|
|
93
|
+
escape && escape_start + escape.length > cutoff ? escape_start + escape.length : cutoff
|
|
94
|
+
end
|
|
57
95
|
end
|
|
58
96
|
|
|
59
97
|
class TabQuestionPrompt
|
|
@@ -279,6 +317,7 @@ module Kward
|
|
|
279
317
|
unread: false,
|
|
280
318
|
pending_question: nil,
|
|
281
319
|
shell: nil,
|
|
320
|
+
transient_shell_entries: [],
|
|
282
321
|
error_reported: false,
|
|
283
322
|
local_busy_activity: nil
|
|
284
323
|
).tap { |tab| assign_tab_question_prompt(agent, tab) }
|
|
@@ -424,6 +463,7 @@ module Kward
|
|
|
424
463
|
tab.queued_inputs.clear
|
|
425
464
|
tab.steering = nil
|
|
426
465
|
tab.shell = nil
|
|
466
|
+
tab.clear_transient_shell_entries
|
|
427
467
|
tab.stream_state = new_tab_stream_state(tab.driver)
|
|
428
468
|
tab.markdown_chunks.clear
|
|
429
469
|
update_prompt_workspace_root(current_workspace_root)
|
|
@@ -451,7 +491,7 @@ module Kward
|
|
|
451
491
|
end
|
|
452
492
|
return snapshot_method.call unless supports_transcript_option
|
|
453
493
|
|
|
454
|
-
snapshot_method.call(include_transcript: tab.running?
|
|
494
|
+
snapshot_method.call(include_transcript: tab.running?)
|
|
455
495
|
end
|
|
456
496
|
|
|
457
497
|
def activate_tab(index, render: true)
|
|
@@ -484,8 +524,8 @@ module Kward
|
|
|
484
524
|
tab.agent
|
|
485
525
|
end
|
|
486
526
|
|
|
487
|
-
def render_tab(tab)
|
|
488
|
-
if tab.snapshot && @prompt.respond_to?(:restore_tab_view_snapshot) &&
|
|
527
|
+
def render_tab(tab, restore_composer: true)
|
|
528
|
+
if restore_composer && tab.snapshot && @prompt.respond_to?(:restore_tab_view_snapshot) && tab.running?
|
|
489
529
|
@prompt.restore_tab_view_snapshot(tab.snapshot)
|
|
490
530
|
return
|
|
491
531
|
end
|
|
@@ -497,8 +537,23 @@ module Kward
|
|
|
497
537
|
render_transcript_messages(tab.driver.messages)
|
|
498
538
|
end
|
|
499
539
|
report_tab_runtime_error(tab) if %w[failed cancelled].include?(tab.status.to_s)
|
|
540
|
+
render_tab_transient_shell_entries(tab)
|
|
500
541
|
end
|
|
501
|
-
restore_tab_composer_snapshot(tab.snapshot)
|
|
542
|
+
restore_tab_composer_snapshot(tab.snapshot) if restore_composer
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def render_tab_transient_shell_entries(tab)
|
|
546
|
+
Array(tab.transient_shell_entries).each do |entry|
|
|
547
|
+
if @prompt.respond_to?(:say)
|
|
548
|
+
@prompt.say(entry)
|
|
549
|
+
elsif @prompt.respond_to?(:write_transcript)
|
|
550
|
+
@prompt.write_transcript(entry)
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def clear_active_tab_transient_shell_entries
|
|
556
|
+
active_tab&.clear_transient_shell_entries
|
|
502
557
|
end
|
|
503
558
|
|
|
504
559
|
def restore_tab_composer_snapshot(snapshot)
|
data/lib/kward/cli.rb
CHANGED
|
@@ -16,6 +16,8 @@ require_relative "events"
|
|
|
16
16
|
require_relative "export_path"
|
|
17
17
|
require_relative "ekwsh"
|
|
18
18
|
require_relative "interactive_pty_runner"
|
|
19
|
+
require_relative "adaptive_pty_output_sink"
|
|
20
|
+
require_relative "pty_transcript_normalizer"
|
|
19
21
|
require_relative "auth/anthropic_oauth"
|
|
20
22
|
require_relative "auth/api_key_store"
|
|
21
23
|
require_relative "auth/github_oauth"
|
|
@@ -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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
391
|
+
TerminalImageSupport.iterm2_hint?(env)
|
|
317
392
|
end
|
|
318
393
|
|
|
319
394
|
def kitty_image_protocol?(env)
|
|
320
|
-
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
330
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
76
|
+
forwarded
|
|
70
77
|
end
|
|
71
78
|
|
|
72
|
-
def forward_io(reader, writer, pid, input,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
103
|
-
|
|
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,
|
|
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
|
-
|
|
116
|
-
yield chunk if block_given?
|
|
121
|
+
sink.write(chunk)
|
|
117
122
|
end
|
|
118
|
-
|
|
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]
|
|
@@ -257,12 +257,12 @@ module Kward
|
|
|
257
257
|
value = submitted_input
|
|
258
258
|
add_history(composer_input)
|
|
259
259
|
if !@busy && value.strip.empty?
|
|
260
|
-
|
|
260
|
+
flush_output_locked
|
|
261
261
|
return value
|
|
262
262
|
end
|
|
263
263
|
|
|
264
264
|
clear_submitted_input_locked
|
|
265
|
-
|
|
265
|
+
flush_output_locked
|
|
266
266
|
value
|
|
267
267
|
end
|
|
268
268
|
|
|
@@ -306,7 +306,7 @@ module Kward
|
|
|
306
306
|
|
|
307
307
|
def exit_input
|
|
308
308
|
clear_finished_input_locked
|
|
309
|
-
|
|
309
|
+
flush_output_locked
|
|
310
310
|
EXIT_INPUT
|
|
311
311
|
end
|
|
312
312
|
|