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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -1
- data/Rakefile +44 -4
- data/doc/agent-tools.md +1 -0
- data/doc/composer.md +2 -2
- data/doc/configuration.md +18 -3
- data/doc/editor.md +20 -10
- data/doc/files.md +12 -5
- data/doc/permissions.md +1 -0
- data/doc/releasing.md +79 -38
- data/doc/rpc.md +2 -2
- data/doc/sandboxing.md +5 -1
- data/doc/security.md +2 -2
- data/doc/shell.md +42 -18
- data/doc/tabs.md +3 -0
- data/doc/usage.md +3 -2
- data/kward.gemspec +1 -1
- data/lib/kward/adaptive_pty_output_sink.rb +183 -0
- data/lib/kward/agent.rb +1 -0
- data/lib/kward/auth/anthropic_oauth.rb +7 -7
- data/lib/kward/cli/git.rb +5 -1
- data/lib/kward/cli/interactive_turn.rb +101 -19
- data/lib/kward/cli/rendering.rb +14 -2
- data/lib/kward/cli/runtime_helpers.rb +252 -46
- data/lib/kward/cli/settings.rb +5 -13
- data/lib/kward/cli/slash_commands.rb +12 -1
- data/lib/kward/cli/tabs.rb +71 -9
- data/lib/kward/cli.rb +12 -0
- data/lib/kward/compaction/token_estimator.rb +12 -6
- data/lib/kward/config_files.rb +17 -0
- data/lib/kward/editor_prompt.rb +46 -0
- data/lib/kward/editor_prompt_session.rb +28 -0
- data/lib/kward/ekwsh.rb +70 -10
- data/lib/kward/image_attachments.rb +98 -15
- data/lib/kward/interactive_pty_runner.rb +25 -19
- data/lib/kward/model/client.rb +1 -17
- data/lib/kward/model/model_info.rb +3 -2
- data/lib/kward/model/payloads.rb +0 -2
- data/lib/kward/persistent_shell_session.rb +750 -0
- data/lib/kward/project_files.rb +18 -5
- data/lib/kward/prompt_interface/composer_controller.rb +3 -3
- data/lib/kward/prompt_interface/composer_renderer.rb +28 -1
- data/lib/kward/prompt_interface/editor/auto_indent.rb +3 -0
- data/lib/kward/prompt_interface/editor/controller.rb +75 -6
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
- data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +12 -6
- data/lib/kward/prompt_interface/editor/renderer.rb +1 -0
- data/lib/kward/prompt_interface/editor/state.rb +11 -0
- data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
- data/lib/kward/prompt_interface/file_overlay.rb +21 -7
- data/lib/kward/prompt_interface/git_prompt.rb +1 -1
- data/lib/kward/prompt_interface/key_handler.rb +30 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +1 -0
- data/lib/kward/prompt_interface/project_browser.rb +192 -8
- 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/runtime_state.rb +5 -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 +159 -32
- data/lib/kward/prompts/commands.rb +1 -0
- data/lib/kward/pty_output_sink.rb +45 -0
- data/lib/kward/pty_transcript_normalizer.rb +93 -0
- data/lib/kward/rpc/auth_manager.rb +1 -1
- data/lib/kward/rpc/server.rb +2 -1
- data/lib/kward/session_catalog.rb +87 -0
- data/lib/kward/session_store.rb +94 -5
- data/lib/kward/shell_prompt.rb +50 -0
- data/lib/kward/shell_prompt_session.rb +58 -0
- data/lib/kward/terminal_image_support.rb +116 -0
- data/lib/kward/terminal_keys.rb +1 -0
- data/lib/kward/terminal_sequences.rb +43 -0
- data/lib/kward/tools/prepare_shell_command.rb +28 -0
- data/lib/kward/tools/registry.rb +63 -5
- data/lib/kward/tools/replace_editor_buffer.rb +30 -0
- data/lib/kward/tools/run_shell_command.rb +24 -6
- data/lib/kward/version.rb +1 -1
- data/templates/default/layout/html/layout.erb +1 -1
- metadata +15 -4
- data/.github/workflows/ci.yml +0 -48
- data/.github/workflows/pages.yml +0 -48
data/lib/kward/cli/rendering.rb
CHANGED
|
@@ -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 && (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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:,
|
|
328
|
+
def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:, &on_complete)
|
|
268
329
|
runner = InteractivePtyRunner.new
|
|
269
|
-
|
|
270
|
-
if
|
|
271
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
|
285
|
-
|
|
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(
|
|
289
|
-
|
|
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?
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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:
|
|
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
|
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
|
|
|
@@ -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"
|
data/lib/kward/cli/tabs.rb
CHANGED
|
@@ -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?
|
|
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) &&
|
|
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
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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
|