kward 0.80.1 → 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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +2 -2
  5. data/Rakefile +44 -4
  6. data/doc/composer.md +6 -9
  7. data/doc/configuration.md +13 -3
  8. data/doc/files.md +8 -2
  9. data/doc/permissions.md +1 -1
  10. data/doc/releasing.md +71 -38
  11. data/doc/rpc.md +2 -1
  12. data/doc/sandboxing.md +4 -4
  13. data/doc/security.md +6 -9
  14. data/doc/shell.md +161 -196
  15. data/doc/skills.md +17 -5
  16. data/doc/tabs.md +1 -1
  17. data/doc/usage.md +6 -5
  18. data/kward.gemspec +1 -1
  19. data/lib/kward/adaptive_pty_output_sink.rb +183 -0
  20. data/lib/kward/ansi.rb +9 -1
  21. data/lib/kward/cli/commands.rb +7 -0
  22. data/lib/kward/cli/git.rb +5 -1
  23. data/lib/kward/cli/interactive_turn.rb +1 -1
  24. data/lib/kward/cli/plugins.rb +1 -1
  25. data/lib/kward/cli/project_skills.rb +99 -0
  26. data/lib/kward/cli/project_skills_commands.rb +87 -0
  27. data/lib/kward/cli/prompt_interface.rb +18 -4
  28. data/lib/kward/cli/rendering.rb +41 -3
  29. data/lib/kward/cli/runtime_helpers.rb +222 -21
  30. data/lib/kward/cli/sessions.rb +6 -4
  31. data/lib/kward/cli/settings.rb +5 -13
  32. data/lib/kward/cli/slash_commands.rb +6 -0
  33. data/lib/kward/cli/tabs.rb +62 -5
  34. data/lib/kward/cli.rb +21 -1
  35. data/lib/kward/config_files.rb +9 -4
  36. data/lib/kward/conversation.rb +8 -5
  37. data/lib/kward/ekwsh.rb +37 -16
  38. data/lib/kward/image_attachments.rb +98 -15
  39. data/lib/kward/interactive_pty_runner.rb +102 -30
  40. data/lib/kward/prompt_interface/composer_controller.rb +15 -3
  41. data/lib/kward/prompt_interface/composer_renderer.rb +27 -0
  42. data/lib/kward/prompt_interface/editor/controller.rb +10 -6
  43. data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
  44. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  45. data/lib/kward/prompt_interface/git_prompt.rb +1 -1
  46. data/lib/kward/prompt_interface/key_handler.rb +100 -43
  47. data/lib/kward/prompt_interface/overlay_renderer.rb +13 -0
  48. data/lib/kward/prompt_interface/project_browser.rb +162 -3
  49. data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
  50. data/lib/kward/prompt_interface/question_prompt.rb +1 -1
  51. data/lib/kward/prompt_interface/screen.rb +40 -15
  52. data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
  53. data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
  54. data/lib/kward/prompt_interface.rb +167 -44
  55. data/lib/kward/prompts/commands.rb +2 -0
  56. data/lib/kward/prompts.rb +6 -6
  57. data/lib/kward/pty_output_sink.rb +45 -0
  58. data/lib/kward/pty_transcript_normalizer.rb +93 -0
  59. data/lib/kward/rpc/server.rb +1 -0
  60. data/lib/kward/session_catalog.rb +87 -0
  61. data/lib/kward/session_store.rb +97 -7
  62. data/lib/kward/skills/registry.rb +52 -2
  63. data/lib/kward/skills/trust_coordinator.rb +45 -0
  64. data/lib/kward/skills/trust_store.rb +107 -0
  65. data/lib/kward/terminal_image_support.rb +116 -0
  66. data/lib/kward/terminal_sequences.rb +43 -0
  67. data/lib/kward/version.rb +1 -1
  68. metadata +11 -4
  69. data/.github/workflows/ci.yml +0 -48
  70. data/.github/workflows/pages.yml +0 -48
@@ -0,0 +1,183 @@
1
+ require_relative "terminal_sequences"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Streams a conservative subset of PTY output into Kward's inline terminal
6
+ # region and permanently switches to exclusive passthrough before forwarding
7
+ # screen-oriented or unknown terminal controls.
8
+ class AdaptivePtyOutputSink
9
+ MAX_SEQUENCE_BYTES = 4096
10
+ SAFE_CONTROLS = [0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d].freeze
11
+ TERMINAL_STRING_INTRODUCERS = [0x5d, 0x50, 0x5e, 0x5f, 0x58].freeze
12
+
13
+ attr_reader :captured_output
14
+
15
+ def initialize(output:, on_exclusive:, max_capture_bytes: nil)
16
+ @output = output
17
+ @on_exclusive = on_exclusive
18
+ @max_capture_bytes = max_capture_bytes
19
+ @captured_output = max_capture_bytes ? +"".b : nil
20
+ @capture_open = true
21
+ @truncated = false
22
+ @mode = :inline
23
+ @sequence = +"".b
24
+ @synchronized_output = false
25
+ end
26
+
27
+ def write(chunk)
28
+ value = chunk.to_s.b
29
+ capture(value)
30
+ return @output.write(value) if exclusive?
31
+
32
+ write_inline(value)
33
+ end
34
+
35
+ def flush
36
+ @output.flush if @output.respond_to?(:flush)
37
+ end
38
+
39
+ def finish
40
+ switch_to_exclusive(@sequence) unless @sequence.empty? || exclusive?
41
+ end_synchronized_output if inline?
42
+ flush
43
+ end
44
+
45
+ def input_forwarded
46
+ @capture_open = false
47
+ end
48
+
49
+ def inline?
50
+ @mode == :inline
51
+ end
52
+
53
+ def transcript_safe?
54
+ inline? && @sequence.empty?
55
+ end
56
+
57
+ def pre_input_capture_only?
58
+ true
59
+ end
60
+
61
+ def truncated?
62
+ @truncated
63
+ end
64
+
65
+ private
66
+
67
+ def exclusive?
68
+ @mode == :exclusive
69
+ end
70
+
71
+ def write_inline(value)
72
+ safe_output = +"".b
73
+ index = 0
74
+ while index < value.bytesize
75
+ byte = value.getbyte(index)
76
+ if @sequence.empty?
77
+ if byte == 0x1b
78
+ @sequence << byte
79
+ elsif byte >= 0x20 || SAFE_CONTROLS.include?(byte)
80
+ safe_output << byte
81
+ else
82
+ flush_safe_output(safe_output)
83
+ switch_to_exclusive(value.byteslice(index..))
84
+ return
85
+ end
86
+ else
87
+ @sequence << byte
88
+ status = sequence_status
89
+ if status == :safe
90
+ track_safe_sequence(@sequence)
91
+ safe_output << @sequence
92
+ @sequence.clear
93
+ elsif status == :exclusive
94
+ flush_safe_output(safe_output)
95
+ remainder = value.byteslice((index + 1)..).to_s.b
96
+ switch_to_exclusive(@sequence + remainder)
97
+ @sequence.clear
98
+ return
99
+ end
100
+ end
101
+ index += 1
102
+ end
103
+ flush_safe_output(safe_output)
104
+ end
105
+
106
+ def sequence_status
107
+ return :exclusive if @sequence.bytesize > MAX_SEQUENCE_BYTES
108
+ return :pending if @sequence.bytesize == 1
109
+
110
+ second = @sequence.getbyte(1)
111
+ return csi_status if second == "[".ord
112
+ return :exclusive if TERMINAL_STRING_INTRODUCERS.include?(second)
113
+ return escape_intermediate_status if second.between?(0x20, 0x2f)
114
+
115
+ :exclusive
116
+ end
117
+
118
+ def csi_status
119
+ return :pending if @sequence.bytesize == 2
120
+
121
+ byte = @sequence.getbyte(-1)
122
+ return safe_csi? ? :safe : :exclusive if byte.between?(0x40, 0x7e)
123
+ return :pending if byte.between?(0x20, 0x3f)
124
+
125
+ :exclusive
126
+ end
127
+
128
+ def escape_intermediate_status
129
+ byte = @sequence.getbyte(-1)
130
+ return :pending if byte.between?(0x20, 0x2f)
131
+
132
+ :exclusive
133
+ end
134
+
135
+ def safe_csi?
136
+ value = @sequence
137
+ value.match?(/\A\e\[[0-9:;]*m\z/) ||
138
+ value.match?(/\A\e\[[0-2]?K\z/) ||
139
+ value.match?(/\A\e\[[0-9;]*[CDG`]\z/) ||
140
+ value.match?(/\A\e\[\?25[hl]\z/) ||
141
+ value.match?(/\A\e\[\?(?:2004|2026)[hl]\z/) ||
142
+ value.match?(/\A\e\[[0-9;]* q\z/)
143
+ end
144
+
145
+ def track_safe_sequence(sequence)
146
+ @synchronized_output = true if sequence == TerminalSequences::SYNCHRONIZED_OUTPUT_ENABLE
147
+ @synchronized_output = false if sequence == TerminalSequences::SYNCHRONIZED_OUTPUT_DISABLE
148
+ end
149
+
150
+ def end_synchronized_output
151
+ return unless @synchronized_output
152
+
153
+ @output.write(TerminalSequences::SYNCHRONIZED_OUTPUT_DISABLE)
154
+ @synchronized_output = false
155
+ end
156
+
157
+ def flush_safe_output(value)
158
+ return if value.empty?
159
+
160
+ @output.write(value)
161
+ value.clear
162
+ end
163
+
164
+ def switch_to_exclusive(value)
165
+ end_synchronized_output
166
+ @on_exclusive.call
167
+ @mode = :exclusive
168
+ @output.write(value) unless value.empty?
169
+ end
170
+
171
+ def capture(value)
172
+ return unless @captured_output && @capture_open
173
+
174
+ remaining = @max_capture_bytes - @captured_output.bytesize
175
+ if value.bytesize > remaining
176
+ @captured_output << value.byteslice(0, remaining) if remaining.positive?
177
+ @truncated = true
178
+ else
179
+ @captured_output << value
180
+ end
181
+ end
182
+ end
183
+ end
data/lib/kward/ansi.rb CHANGED
@@ -67,7 +67,7 @@ module Kward
67
67
 
68
68
  # Drops unsafe terminal controls from transcript text while preserving SGR color.
69
69
  def sanitize_transcript(text)
70
- scan_escape_tokens(text).each_with_object(+"") do |token, sanitized|
70
+ scan_escape_tokens(normalize_transcript_encoding(text)).each_with_object(+"") do |token, sanitized|
71
71
  if token[:escape]
72
72
  sanitized << token[:text] if token[:text].match?(SGR_PATTERN)
73
73
  else
@@ -76,6 +76,14 @@ module Kward
76
76
  end
77
77
  end
78
78
 
79
+ def normalize_transcript_encoding(text)
80
+ string = text.to_s.dup
81
+ return string unless string.encoding == Encoding::ASCII_8BIT
82
+
83
+ string.force_encoding(Encoding::UTF_8)
84
+ string.valid_encoding? ? string : string.scrub
85
+ end
86
+
79
87
  def wrap_visible(text, width)
80
88
  line_width = [width.to_i, 1].max
81
89
  rows = []
@@ -54,6 +54,7 @@ module Kward
54
54
  #{command.call("kward init")} Install starter prompts and PRINCIPLES.md
55
55
  #{command.call("kward doctor")} Check local Kward setup
56
56
  #{command.call("kward hooks doctor")} Inspect lifecycle hook setup
57
+ #{command.call("kward skills status")} Inspect project skill trust
57
58
  #{command.call("kward edit")} #{option.call("<filename>")} Open a file in the integrated editor
58
59
  #{command.call("kward sysprompt")} Inspect the effective system prompt
59
60
  #{command.call("kward openrouter refresh")} Refresh cached OpenRouter models
@@ -69,6 +70,7 @@ module Kward
69
70
  #{command.call("init")} Install starter prompts and PRINCIPLES.md
70
71
  #{command.call("doctor")} Check local Kward setup
71
72
  #{command.call("hooks list|events|logs|doctor|trust|untrust")} Inspect lifecycle hooks
73
+ #{command.call("skills status|trust|untrust|review")} Manage project skill trust
72
74
  #{command.call("edit")} #{option.call("<filename>")} Open a file in the integrated editor
73
75
  #{command.call("sysprompt")} [--raw] Inspect the effective system prompt
74
76
  #{command.call("stats tokens")} [range] [options] Export local token telemetry as CSV
@@ -136,6 +138,11 @@ module Kward
136
138
  description: "Inspect lifecycle hooks, recent audit records, diagnostics, and workspace hook trust.",
137
139
  examples: ["kward hooks list", "kward hooks doctor", "kward hooks logs 50", "kward hooks trust"]
138
140
  },
141
+ "skills" => {
142
+ usage: "kward skills [status|trust|untrust|review]",
143
+ description: "Inspect or manage project skill trust for the current workspace.",
144
+ examples: ["kward skills status", "kward skills review", "kward skills trust"]
145
+ },
139
146
  "edit" => {
140
147
  usage: "kward edit <filename>",
141
148
  description: "Open a file in the integrated editor.",
data/lib/kward/cli/git.rb CHANGED
@@ -30,12 +30,16 @@ module Kward
30
30
  status = result.is_a?(Hash) && result.key?(:status_lines) ? result[:status_lines] : result
31
31
  result
32
32
  end
33
- return if message.nil?
33
+ if message.nil?
34
+ refresh_composer_status
35
+ return
36
+ end
34
37
 
35
38
  result = run_busy_local_command_and_requeue(activity: "committing") do
36
39
  git_commit(git_root, message)
37
40
  end
38
41
  print_git_commit_result(result)
42
+ refresh_composer_status
39
43
  ensure
40
44
  @git_hook_conversation = previous_git_hook_conversation
41
45
  end
@@ -8,7 +8,6 @@ module Kward
8
8
 
9
9
  def run_interactive_turn(agent, input, display_input: nil)
10
10
  prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
11
- print_user_transcript(input, display_input: display_input) if prompt_interface?
12
11
  return run_blocking_interactive_turn(agent, input, display_input: display_input) unless prompt_interface?
13
12
 
14
13
  queued_inputs = []
@@ -27,6 +26,7 @@ module Kward
27
26
  answer = nil
28
27
  error = nil
29
28
  @prompt.begin_busy_input("You>") if @prompt.respond_to?(:begin_busy_input)
29
+ print_user_transcript(input, display_input: display_input)
30
30
 
31
31
  worker = Thread.new do
32
32
  options = agent_display_options(display_input)
@@ -122,7 +122,7 @@ module Kward
122
122
  argument_hint: template.argument_hint
123
123
  }
124
124
  end
125
- skill_entries = ConfigFiles.skills.map do |skill|
125
+ skill_entries = ConfigFiles.skills(workspace_root: current_workspace_root, project_skill_paths: @interactive_project_skill_paths || []).map do |skill|
126
126
  {
127
127
  name: "skill:#{skill.name}",
128
128
  description: skill.description,
@@ -0,0 +1,99 @@
1
+ require "pathname"
2
+ require_relative "../skills/trust_coordinator"
3
+ require_relative "../skills/trust_store"
4
+
5
+ # Namespace for CLI orchestration helpers.
6
+ module Kward
7
+ class CLI
8
+ # Coordinates interactive trust decisions for project-provided Agent Skills.
9
+ module ProjectSkills
10
+ private
11
+
12
+ def prepare_interactive_project_skills
13
+ return unless prompt_interface?
14
+
15
+ workspace_root = current_workspace_root
16
+ candidates = ConfigFiles.project_skill_candidates(workspace_root: workspace_root)
17
+ trust_store = Skills::TrustStore.new(config_dir: ConfigFiles.config_dir)
18
+ coordinator = Skills::TrustCoordinator.new(workspace_root: workspace_root, trust_store: trust_store)
19
+
20
+ if ConfigFiles.project_skills_trusted?
21
+ @interactive_project_skill_paths = candidates.map(&:path)
22
+ else
23
+ pending = coordinator.pending(candidates)
24
+ unless pending.empty?
25
+ decision = prompt_for_project_skill_trust(pending)
26
+ coordinator.record!(pending, decision)
27
+ end
28
+
29
+ @interactive_project_skill_paths = coordinator.allowed_paths(candidates)
30
+ end
31
+ end
32
+
33
+ def prompt_for_project_skill_trust(candidates)
34
+ paths = candidates.map { |candidate| " #{relative_workspace_path(candidate.path)}" }
35
+ message = (["Project skills found in #{current_workspace_root}:", *paths, "", "These files contain instructions that may influence the model."]).join("\n")
36
+ loop do
37
+ choice = @prompt.select(message, ["Allow", "Deny", "Review"], title: "Trust project skills")
38
+ case choice.to_s.downcase
39
+ when "review"
40
+ review_project_skills(candidates)
41
+ when "allow"
42
+ return "allow"
43
+ else
44
+ return "deny"
45
+ end
46
+ end
47
+ end
48
+
49
+ def review_project_skills(candidates)
50
+ candidates.each do |candidate|
51
+ content = read_project_skill_for_review(candidate.path)
52
+ resources = project_skill_resources(candidate.path)
53
+ details = [
54
+ "Project skill: #{relative_workspace_path(candidate.path)}",
55
+ resources.empty? ? nil : "Referenced resources:\n#{resources.map { |path| " #{path}" }.join("\n")}",
56
+ "",
57
+ content
58
+ ].compact.join("\n")
59
+ @prompt.say("\n#{ANSI.strip(details)}\n")
60
+ end
61
+ end
62
+
63
+ def read_project_skill_for_review(path)
64
+ return "Unable to review: file is too large." if File.size(path) > ConfigFiles::MAX_SKILL_FILE_BYTES
65
+
66
+ ANSI.strip(File.read(path, ConfigFiles::MAX_SKILL_FILE_BYTES + 1))
67
+ rescue StandardError => e
68
+ "Unable to review: #{e.message}"
69
+ end
70
+
71
+ def project_skill_resources(path)
72
+ folder = File.dirname(path)
73
+ roots = %w[scripts references assets].map { |name| File.join(folder, name) }.select { |root| Dir.exist?(root) }
74
+ roots.flat_map { |root| Dir.glob(File.join(root, "**", "*")).select { |entry| File.file?(entry) } }.sort.first(200).map do |resource|
75
+ Pathname.new(resource).relative_path_from(Pathname.new(folder)).to_s
76
+ end
77
+ rescue StandardError
78
+ []
79
+ end
80
+
81
+ def project_skill_paths_for(workspace_root)
82
+ return unless @interactive_project_skill_paths
83
+ return unless canonical_path(workspace_root) == canonical_path(current_workspace_root)
84
+
85
+ @interactive_project_skill_paths
86
+ end
87
+
88
+ def canonical_path(path)
89
+ File.realpath(path)
90
+ rescue SystemCallError
91
+ File.expand_path(path)
92
+ end
93
+
94
+ def relative_workspace_path(path)
95
+ Pathname.new(File.expand_path(path)).relative_path_from(Pathname.new(File.expand_path(current_workspace_root))).to_s
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,87 @@
1
+ # Namespace for CLI orchestration helpers.
2
+ module Kward
3
+ class CLI
4
+ # Handles explicit project skill trust management commands.
5
+ module ProjectSkillsCommands
6
+ def handle_project_skills_cli_command(arguments)
7
+ argument = Array(arguments).join(" ").strip.downcase
8
+ _workspace_root, candidates, coordinator = project_skill_trust_coordinator
9
+ if candidates.empty? && argument != "untrust"
10
+ @prompt.say("No project skills found in the current workspace.")
11
+ return
12
+ end
13
+
14
+ case argument
15
+ when "", "status"
16
+ lines = candidates.empty? ? ["No project skills found in the current workspace."] : candidates.map { |candidate| "#{relative_workspace_path(candidate.path)}: #{coordinator.decision(candidate) || "needs review"}" }
17
+ @prompt.say(lines.join("\n"))
18
+ when "trust"
19
+ coordinator.record!(candidates, "allow")
20
+ @prompt.say("Project skills trusted for the current skill snapshots.")
21
+ when "untrust"
22
+ coordinator.remove_workspace!
23
+ @prompt.say("Project skill trust removed for this workspace.")
24
+ when "review"
25
+ review_project_skills(candidates)
26
+ else
27
+ raise ArgumentError, "Usage: kward skills [status|trust|untrust|review]"
28
+ end
29
+ end
30
+
31
+ def handle_project_skills_command(argument)
32
+ case argument.to_s.strip.downcase
33
+ when "", "status"
34
+ show_project_skills_status
35
+ when "trust"
36
+ trust_current_project_skills
37
+ when "untrust"
38
+ untrust_current_project_skills
39
+ else
40
+ runtime_output("Usage: /skills [status|trust|untrust]")
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def project_skill_trust_coordinator
47
+ workspace_root = current_workspace_root
48
+ candidates = ConfigFiles.project_skill_candidates(workspace_root: workspace_root)
49
+ store = Skills::TrustStore.new(config_dir: ConfigFiles.config_dir)
50
+ [workspace_root, candidates, Skills::TrustCoordinator.new(workspace_root: workspace_root, trust_store: store)]
51
+ end
52
+
53
+ def show_project_skills_status
54
+ _workspace_root, candidates, coordinator = project_skill_trust_coordinator
55
+ if candidates.empty?
56
+ runtime_output("No project skills found in the current workspace.")
57
+ return
58
+ end
59
+
60
+ lines = candidates.map do |candidate|
61
+ status = coordinator.decision(candidate) || "needs review"
62
+ "#{relative_workspace_path(candidate.path)}: #{status}"
63
+ end
64
+ runtime_output(lines.join("\n"))
65
+ end
66
+
67
+ def trust_current_project_skills
68
+ _workspace_root, candidates, coordinator = project_skill_trust_coordinator
69
+ if candidates.empty?
70
+ runtime_output("No project skills found in the current workspace.")
71
+ return
72
+ end
73
+
74
+ coordinator.record!(candidates, "allow")
75
+ @interactive_project_skill_paths = coordinator.allowed_paths(candidates)
76
+ runtime_output("Project skills trusted for the current skill snapshots. Use /new to rebuild the agent.")
77
+ end
78
+
79
+ def untrust_current_project_skills
80
+ _workspace_root, _candidates, coordinator = project_skill_trust_coordinator
81
+ coordinator.remove_workspace!
82
+ @interactive_project_skill_paths = []
83
+ runtime_output("Project skill trust removed for this workspace. Use /new to rebuild the agent.")
84
+ end
85
+ end
86
+ end
87
+ end
@@ -8,7 +8,7 @@ module Kward
8
8
  module PromptInterfaceSupport
9
9
  private
10
10
 
11
- def setup_interactive_prompt
11
+ def setup_interactive_prompt(defer_warnings: false)
12
12
  return unless @stdin.tty?
13
13
  return unless @prompt.is_a?(TTY::Prompt)
14
14
 
@@ -16,6 +16,7 @@ module Kward
16
16
  return unless prompt_interface
17
17
 
18
18
  @interactive_warning_sink_active = true
19
+ @interactive_warning_output_ready = !defer_warnings
19
20
  ConfigFiles.warning_sink = interactive_warning_sink
20
21
  @prompt = prompt_interface.new(
21
22
  slash_commands: slash_command_entries,
@@ -43,13 +44,19 @@ module Kward
43
44
  editor_line_numbers: ConfigFiles.editor_line_numbers,
44
45
  editor_line_numbers_source: -> { ConfigFiles.editor_line_numbers },
45
46
  diff_view: ConfigFiles.diff_view,
46
- diff_view_source: -> { ConfigFiles.diff_view }
47
+ diff_view_source: -> { ConfigFiles.diff_view },
48
+ redraw_handler: method(:redraw_interactive_prompt)
47
49
  )
48
50
  if @prompt.method(:start).parameters.any? { |kind, name| [:key, :keyreq].include?(kind) && name == :render }
49
51
  @prompt.start(render: false)
50
52
  else
51
53
  @prompt.start
52
54
  end
55
+ flush_interactive_warnings if @interactive_warning_output_ready
56
+ end
57
+
58
+ def enable_interactive_warnings
59
+ @interactive_warning_output_ready = true
53
60
  flush_interactive_warnings
54
61
  end
55
62
 
@@ -60,7 +67,7 @@ module Kward
60
67
  next
61
68
  end
62
69
 
63
- if prompt_interface?
70
+ if @interactive_warning_output_ready && prompt_interface?
64
71
  runtime_output(message)
65
72
  else
66
73
  (@pending_interactive_warnings ||= []) << message
@@ -100,6 +107,7 @@ module Kward
100
107
  def clear_interactive_warning_sink
101
108
  ConfigFiles.warning_sink = nil
102
109
  @interactive_warning_sink_active = false
110
+ @interactive_warning_output_ready = false
103
111
  @pending_interactive_warnings = nil
104
112
  @interactive_warning_sink = nil
105
113
  end
@@ -158,10 +166,16 @@ module Kward
158
166
  end
159
167
 
160
168
  def startup_plugins_value
161
- filenames = plugin_registry.paths.map { |path| File.basename(path) }
169
+ filenames = plugin_registry.paths.map { |path| startup_plugin_name(path) }
162
170
  filenames.empty? ? "none" : filenames.join(", ")
163
171
  end
164
172
 
173
+ def startup_plugin_name(path)
174
+ plugin_root = ConfigFiles.plugin_dir
175
+ prefix = "#{plugin_root}#{File::SEPARATOR}"
176
+ path.start_with?(prefix) ? path.delete_prefix(prefix) : File.basename(path)
177
+ end
178
+
165
179
  def startup_status_line(refresh_update_check: false)
166
180
  color = startup_update_notice(refresh: refresh_update_check) ? :yellow : :green
167
181
  "#{ANSI.colorize("●", color, enabled: @color_enabled)} Kward v#{Kward::VERSION} is online."
@@ -10,6 +10,30 @@ module Kward
10
10
  render_transcript_messages(conversation.messages)
11
11
  end
12
12
 
13
+ def redraw_interactive_prompt
14
+ tab = active_tab if respond_to?(:active_tab, true)
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?
21
+ @prompt.redraw if @prompt.respond_to?(:redraw)
22
+ return
23
+ end
24
+
25
+ conversation = tab&.agent&.conversation || @footer_conversation
26
+ unless conversation && @prompt.respond_to?(:restore_transcript)
27
+ @prompt.redraw if @prompt.respond_to?(:redraw)
28
+ return
29
+ end
30
+
31
+ restore_prompt_transcript do
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)
34
+ end
35
+ end
36
+
13
37
  # Renders a transcript supplied by either a Kward conversation or a
14
38
  # plugin-owned tab driver.
15
39
  def render_transcript_messages(messages)
@@ -199,7 +223,7 @@ module Kward
199
223
  # Writes the user transcript output for the terminal CLI flow.
200
224
  def print_user_transcript(input, display_input: nil, attachment_references: nil, image_parts: nil)
201
225
  visible_input = display_input.nil? ? input : display_input
202
- @prompt.say("\n#{colored("You>", :blue, :bold)} #{visible_input}\n")
226
+ write_prompt_transcript("\n#{colored("You>", :blue, :bold)} #{visible_input}\n")
203
227
  print_attachment_badges(input, references: attachment_references)
204
228
  print_pasted_images(input, image_parts: image_parts)
205
229
  end
@@ -209,7 +233,15 @@ module Kward
209
233
  badges = references ? Array(references).map { |reference| attachment_badge_text(reference) } : composer_attachment_badges(input)
210
234
  return if badges.empty?
211
235
 
212
- @prompt.say("#{badges.join("\n")}\n")
236
+ write_prompt_transcript("#{badges.join("\n")}\n")
237
+ end
238
+
239
+ def write_prompt_transcript(message)
240
+ if @prompt.respond_to?(:write_transcript)
241
+ @prompt.write_transcript(message)
242
+ else
243
+ @prompt.say(message)
244
+ end
213
245
  end
214
246
 
215
247
  def composer_attachment_badges(input, attachments = [])
@@ -257,8 +289,14 @@ module Kward
257
289
  # Writes the pasted images output for the terminal CLI flow.
258
290
  def print_pasted_images(input, image_parts: nil)
259
291
  parts = image_parts || Kward::ImageAttachments.image_parts_from_text(input)
292
+ protocol = @prompt.inline_image_protocol if @prompt.respond_to?(:inline_image_protocol)
260
293
  parts.each do |part|
261
- sequence = Kward::ImageAttachments.terminal_image_sequence(part)
294
+ next if @prompt.respond_to?(:inline_image_protocol) && protocol.nil?
295
+
296
+ part = Kward::ImageAttachments.terminal_image_part(part, protocol) if protocol
297
+ next if protocol && part.nil?
298
+
299
+ sequence = Kward::ImageAttachments.terminal_image_sequence(part, protocol: protocol)
262
300
  next unless sequence
263
301
 
264
302
  if @prompt.respond_to?(:say_visual)