kward 0.83.0 → 0.85.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 (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/agent-tools.md +13 -1
  9. data/doc/api.md +21 -2
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +95 -25
  12. data/doc/editor.md +28 -13
  13. data/doc/extensibility.md +2 -1
  14. data/doc/files.md +8 -4
  15. data/doc/getting-started.md +3 -0
  16. data/doc/git.md +3 -1
  17. data/doc/pan.md +25 -15
  18. data/doc/permissions.md +4 -4
  19. data/doc/platform-support.md +48 -0
  20. data/doc/plugins.md +464 -15
  21. data/doc/rpc.md +154 -16
  22. data/doc/sandboxing.md +11 -5
  23. data/doc/security.md +10 -3
  24. data/doc/session-management.md +5 -4
  25. data/doc/shell.md +62 -45
  26. data/doc/tabs.md +6 -2
  27. data/doc/transports.md +15 -0
  28. data/doc/troubleshooting.md +12 -2
  29. data/doc/usage.md +9 -6
  30. data/doc/workspace-tools.md +9 -0
  31. data/examples/plugins/space_invaders.rb +1 -1
  32. data/examples/plugins/stardate_footer.rb +2 -2
  33. data/examples/plugins/telegram/plugin.rb +1 -1
  34. data/kward.gemspec +5 -4
  35. data/lib/kward/agent.rb +30 -14
  36. data/lib/kward/cli/auth_commands.rb +34 -13
  37. data/lib/kward/cli/commands.rb +83 -62
  38. data/lib/kward/cli/compaction.rb +9 -3
  39. data/lib/kward/cli/doctor.rb +39 -17
  40. data/lib/kward/cli/hook_commands.rb +22 -12
  41. data/lib/kward/cli/interactive_turn.rb +48 -7
  42. data/lib/kward/cli/plugins.rb +81 -12
  43. data/lib/kward/cli/project_skills_commands.rb +8 -4
  44. data/lib/kward/cli/prompt_interface.rb +52 -5
  45. data/lib/kward/cli/rendering.rb +18 -9
  46. data/lib/kward/cli/runtime_helpers.rb +228 -71
  47. data/lib/kward/cli/sessions.rb +9 -5
  48. data/lib/kward/cli/settings/menus.rb +745 -0
  49. data/lib/kward/cli/settings/model.rb +327 -0
  50. data/lib/kward/cli/settings.rb +6 -1055
  51. data/lib/kward/cli/slash_commands.rb +56 -17
  52. data/lib/kward/cli/tabs.rb +244 -33
  53. data/lib/kward/cli/tool_summaries.rb +14 -0
  54. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
  55. data/lib/kward/cli/worktrees.rb +65 -2
  56. data/lib/kward/cli.rb +70 -30
  57. data/lib/kward/compactor.rb +18 -7
  58. data/lib/kward/config/core.rb +389 -0
  59. data/lib/kward/config/extensions.rb +96 -0
  60. data/lib/kward/config/prompts.rb +313 -0
  61. data/lib/kward/config/settings.rb +250 -0
  62. data/lib/kward/config_files.rb +14 -994
  63. data/lib/kward/conversation.rb +31 -2
  64. data/lib/kward/image_attachments.rb +1 -1
  65. data/lib/kward/model/client.rb +36 -24
  66. data/lib/kward/model/copilot_models.rb +2 -2
  67. data/lib/kward/model/model_info.rb +20 -3
  68. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  69. data/lib/kward/model/payloads.rb +12 -3
  70. data/lib/kward/model/provider_catalog.rb +5 -0
  71. data/lib/kward/model/stream_parser.rb +20 -4
  72. data/lib/kward/model/typesafe_client.rb +78 -0
  73. data/lib/kward/pan/index.html.erb +3 -3
  74. data/lib/kward/pan/server.rb +33 -10
  75. data/lib/kward/permissions/policy.rb +6 -2
  76. data/lib/kward/plugin_registry.rb +2 -659
  77. data/lib/kward/plugins/actions.rb +453 -0
  78. data/lib/kward/plugins/chat_contract.rb +121 -0
  79. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  80. data/lib/kward/plugins/host.rb +232 -0
  81. data/lib/kward/plugins/registry.rb +1190 -0
  82. data/lib/kward/plugins/resources.rb +206 -0
  83. data/lib/kward/plugins/turn_request.rb +36 -0
  84. data/lib/kward/plugins/ui.rb +219 -0
  85. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  86. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  87. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  88. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  89. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  90. data/lib/kward/prompt_interface/editor/markdown_code_block.rb +136 -0
  91. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  92. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  93. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  94. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  95. data/lib/kward/prompt_interface/editor/scratchpad_languages.rb +74 -0
  96. data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
  97. data/lib/kward/prompt_interface/editor/state.rb +11 -11
  98. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  99. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  100. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  101. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  102. data/lib/kward/prompt_interface/layout.rb +1 -1
  103. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  104. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  105. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  106. data/lib/kward/prompt_interface/runtime_state.rb +56 -2
  107. data/lib/kward/prompt_interface/screen.rb +11 -4
  108. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  109. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  110. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  111. data/lib/kward/prompt_interface.rb +151 -27
  112. data/lib/kward/prompts/commands.rb +3 -2
  113. data/lib/kward/prompts.rb +1 -1
  114. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  115. data/lib/kward/pty/detached_run.rb +44 -0
  116. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
  117. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  118. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  119. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +52 -0
  120. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  121. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  122. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  123. data/lib/kward/rpc/server.rb +91 -12
  124. data/lib/kward/rpc/session_manager.rb +147 -44
  125. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  126. data/lib/kward/rpc/tool_metadata.rb +1 -1
  127. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  128. data/lib/kward/sandbox/command_runner.rb +1 -1
  129. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  130. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  131. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  132. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  133. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  134. data/lib/kward/{ekwsh.rb → shell/kwsh.rb} +38 -19
  135. data/lib/kward/shell/kwshrc.rb +233 -0
  136. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
  137. data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
  138. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  139. data/lib/kward/skills/trust_store.rb +1 -1
  140. data/lib/kward/tabs/driver.rb +194 -0
  141. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  142. data/lib/kward/{ansi.rb → terminal/ansi.rb} +110 -10
  143. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  144. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  145. data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +12 -0
  146. data/lib/kward/terminal/text.rb +121 -0
  147. data/lib/kward/text_matcher.rb +18 -0
  148. data/lib/kward/tools/base.rb +18 -0
  149. data/lib/kward/tools/context_for_task.rb +15 -6
  150. data/lib/kward/tools/edit_file.rb +9 -6
  151. data/lib/kward/tools/git_commit.rb +13 -7
  152. data/lib/kward/tools/list_directory.rb +4 -4
  153. data/lib/kward/tools/open_editor.rb +41 -0
  154. data/lib/kward/tools/plugin_tool.rb +41 -0
  155. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  156. data/lib/kward/tools/read_file.rb +7 -6
  157. data/lib/kward/tools/registry.rb +109 -17
  158. data/lib/kward/tools/run_shell_command.rb +10 -8
  159. data/lib/kward/tools/search/code.rb +1 -1
  160. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  161. data/lib/kward/tools/tool_call.rb +3 -1
  162. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  163. data/lib/kward/tools/workspace_targets.rb +58 -0
  164. data/lib/kward/tools/write_file.rb +9 -6
  165. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  166. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  167. data/lib/kward/transport/contracts.rb +200 -0
  168. data/lib/kward/transport/gateway.rb +79 -35
  169. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  170. data/lib/kward/transport.rb +1 -200
  171. data/lib/kward/version.rb +1 -1
  172. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  173. data/lib/kward/{project_files.rb → workspace/files.rb} +2 -2
  174. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  175. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  176. data/templates/default/kward_navigation.rb +1 -0
  177. data/templates/default/layout/html/footer.erb +10 -0
  178. data/templates/default/layout/html/headers.erb +23 -0
  179. data/templates/default/layout/html/layout.erb +2 -2
  180. data/templates/default/layout/html/setup.rb +41 -2
  181. metadata +94 -47
  182. data/lib/kward/scratchpad_runner.rb +0 -56
  183. data/lib/kward/tab_driver.rb +0 -90
  184. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  185. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  186. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  187. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  188. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  189. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  190. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  191. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  192. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  193. /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
@@ -0,0 +1,206 @@
1
+ require "thread"
2
+ require_relative "../cancellation"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Idempotent cleanup registration owned by a plugin runtime.
7
+ class PluginDisposable
8
+ def initialize(resource = nil, &cleanup)
9
+ raise ArgumentError, "plugin cleanup requires a block or closeable resource" unless cleanup || resource&.respond_to?(:close)
10
+
11
+ @resource = resource
12
+ @cleanup = cleanup
13
+ @mutex = Mutex.new
14
+ @disposed = false
15
+ end
16
+
17
+ def disposed?
18
+ @mutex.synchronize { @disposed }
19
+ end
20
+
21
+ def dispose
22
+ cleanup = @mutex.synchronize do
23
+ return false if @disposed
24
+
25
+ @disposed = true
26
+ @cleanup
27
+ end
28
+
29
+ if cleanup
30
+ cleanup.arity.zero? ? cleanup.call : cleanup.call(@resource)
31
+ else
32
+ @resource.close
33
+ end
34
+ true
35
+ end
36
+ end
37
+
38
+ # Cooperative background task owned by a plugin runtime.
39
+ class PluginTask
40
+ attr_reader :name, :cancellation
41
+
42
+ def initialize(name:, cancellation:, &work)
43
+ @name = name.to_s.freeze
44
+ @cancellation = cancellation
45
+ @work = work
46
+ @mutex = Mutex.new
47
+ @thread = nil
48
+ @result = nil
49
+ @error = nil
50
+ end
51
+
52
+ def start(&finished)
53
+ @mutex.synchronize do
54
+ raise "Plugin task #{name.inspect} has already started" if @thread
55
+
56
+ @thread = Thread.new do
57
+ begin
58
+ cancellation.raise_if_cancelled!
59
+ result = @work.arity.zero? ? @work.call : @work.call(cancellation)
60
+ @mutex.synchronize { @result = result }
61
+ rescue Cancellation::CancelledError
62
+ nil
63
+ rescue StandardError => e
64
+ @mutex.synchronize { @error = e }
65
+ ensure
66
+ finished&.call(self)
67
+ end
68
+ end
69
+ @thread.report_on_exception = false
70
+ end
71
+ self
72
+ end
73
+
74
+ def cancel!
75
+ cancellation.cancel!
76
+ self
77
+ end
78
+
79
+ def cancelled?
80
+ cancellation.cancelled?
81
+ end
82
+
83
+ def alive?
84
+ thread = @mutex.synchronize { @thread }
85
+ thread&.alive? == true
86
+ end
87
+
88
+ def complete?
89
+ !alive?
90
+ end
91
+
92
+ def join(timeout = nil)
93
+ thread = @mutex.synchronize { @thread }
94
+ thread&.join(timeout)
95
+ self
96
+ end
97
+
98
+ def result
99
+ join
100
+ @mutex.synchronize { @result }
101
+ end
102
+
103
+ def error
104
+ @mutex.synchronize { @error }
105
+ end
106
+ end
107
+
108
+ # Owns cleanup callbacks and cooperative background tasks for a plugin or tab.
109
+ class PluginResources
110
+ DEFAULT_SHUTDOWN_TIMEOUT = 1.0
111
+
112
+ def initialize(name:, warning_sink: nil)
113
+ @name = name.to_s
114
+ @warning_sink = warning_sink
115
+ @mutex = Mutex.new
116
+ @state = :initialized
117
+ @tasks = []
118
+ @disposables = []
119
+ @next_task_id = 0
120
+ end
121
+
122
+ def activate!
123
+ @mutex.synchronize do
124
+ raise "#{@name} resources have already been stopped" if @state == :stopped
125
+
126
+ @state = :active
127
+ end
128
+ self
129
+ end
130
+
131
+ def background(name: nil, cancellation: nil, &work)
132
+ raise ArgumentError, "plugin background task requires a block" unless work
133
+
134
+ task = @mutex.synchronize do
135
+ raise "#{@name} background work cannot start before plugin activation" unless @state == :active
136
+
137
+ @next_task_id += 1
138
+ task_name = name.to_s.empty? ? "task-#{@next_task_id}" : name.to_s
139
+ task_cancellation = Cancellation.new
140
+ cancellation&.on_cancel { task_cancellation.cancel! }
141
+ PluginTask.new(name: task_name, cancellation: task_cancellation, &work).tap do |created|
142
+ @tasks << created
143
+ created.start { |finished| finish_task(finished) }
144
+ end
145
+ end
146
+ task
147
+ end
148
+
149
+ def on_cleanup(resource = nil, &cleanup)
150
+ disposable = PluginDisposable.new(resource, &cleanup)
151
+ @mutex.synchronize do
152
+ raise "#{@name} resources have already been stopped" if @state == :stopped
153
+
154
+ @disposables << disposable
155
+ end
156
+ disposable
157
+ end
158
+
159
+ alias manage on_cleanup
160
+
161
+ def shutdown(timeout: DEFAULT_SHUTDOWN_TIMEOUT)
162
+ tasks, disposables = @mutex.synchronize do
163
+ return self if @state == :stopped
164
+
165
+ @state = :stopped
166
+ [@tasks.dup, @disposables.reverse]
167
+ end
168
+
169
+ tasks.each(&:cancel!)
170
+ disposables.each { |disposable| dispose(disposable) }
171
+ join_tasks(tasks, timeout)
172
+ self
173
+ end
174
+
175
+ private
176
+
177
+ def finish_task(task)
178
+ error = task.error
179
+ @mutex.synchronize { @tasks.delete(task) }
180
+ warn("#{@name} background task #{task.name.inspect} failed: #{error.message}") if error
181
+ end
182
+
183
+ def dispose(disposable)
184
+ disposable.dispose
185
+ rescue StandardError => e
186
+ warn("#{@name} cleanup failed: #{e.message}")
187
+ end
188
+
189
+ def join_tasks(tasks, timeout)
190
+ deadline = monotonic_time + [timeout.to_f, 0].max
191
+ tasks.each do |task|
192
+ remaining = deadline - monotonic_time
193
+ task.join(remaining) if remaining.positive?
194
+ warn("#{@name} background task #{task.name.inspect} did not stop before shutdown") if task.alive?
195
+ end
196
+ end
197
+
198
+ def monotonic_time
199
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
200
+ end
201
+
202
+ def warn(message)
203
+ @warning_sink ? @warning_sink.call("Warning: #{message}") : Kernel.warn("Warning: #{message}")
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,36 @@
1
+ require "securerandom"
2
+
3
+ module Kward
4
+ # Immutable, host-dispatched system instructions for one session turn.
5
+ # Historical records retain the instructions for display, not model replay.
6
+ class PluginTurnRequest
7
+ MAX_SYSTEM_BYTES = 65_536
8
+
9
+ attr_reader :system, :command, :plugin_id, :id
10
+
11
+ def initialize(system:, command:, plugin_id: nil, id: SecureRandom.uuid)
12
+ unless system.is_a?(String) && system.valid_encoding? && !system.strip.empty? && system.bytesize <= MAX_SYSTEM_BYTES
13
+ raise ArgumentError, "System instructions must be nonblank text of at most #{MAX_SYSTEM_BYTES} bytes"
14
+ end
15
+
16
+ @system = system.dup.freeze
17
+ @command = command.to_s.dup.freeze
18
+ @plugin_id = plugin_id&.to_s&.dup&.freeze
19
+ @id = id.to_s.dup.freeze
20
+ freeze
21
+ end
22
+
23
+ def to_s
24
+ "/#{command} #{system}"
25
+ end
26
+
27
+ def history_message
28
+ {
29
+ role: "user",
30
+ content: "A plugin requested a system-instruction turn. Those instructions are not active in later turns.",
31
+ display_content: to_s,
32
+ plugin_system_turn: { id: id, command: command, plugin_id: plugin_id, system: system, scope: "turn" }
33
+ }
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,219 @@
1
+ require_relative "../deep_copy"
2
+ require_relative "../question_contract"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Frontend-neutral structured UI exposed to trusted plugin handlers.
7
+ #
8
+ # Frontends provide a small backend that handles blocking requests and
9
+ # non-blocking events. Unsupported requests fail closed: selection, question,
10
+ # and input return nil, while confirmation returns false.
11
+ class PluginUI
12
+ FEATURES = %i[question select confirm input progress notify].freeze
13
+ NOTIFICATION_LEVELS = %i[info success warning error].freeze
14
+ MAX_TEXT_BYTES = 16_384
15
+ MAX_OPTIONS = 100
16
+ MAX_ID_BYTES = 128
17
+
18
+ # Adapts frontend callbacks to the structured plugin UI contract.
19
+ class Backend
20
+ def initialize(capabilities: {}, requester: nil, emitter: nil)
21
+ @capabilities = FEATURES.to_h { |feature| [feature, capability_enabled?(capabilities, feature)] }.freeze
22
+ @requester = requester
23
+ @emitter = emitter
24
+ end
25
+
26
+ attr_reader :capabilities
27
+
28
+ def supported?(feature)
29
+ @capabilities.fetch(feature.to_sym, false)
30
+ end
31
+
32
+ def request(kind, payload, cancellation: nil)
33
+ return nil unless supported?(kind) && @requester
34
+
35
+ @requester.call(kind.to_sym, payload, cancellation: cancellation)
36
+ end
37
+
38
+ def emit(kind, payload)
39
+ return false unless supported?(kind) && @emitter
40
+
41
+ @emitter.call(kind.to_sym, payload)
42
+ true
43
+ end
44
+
45
+ private
46
+
47
+ def capability_enabled?(capabilities, feature)
48
+ value = capabilities[feature] || capabilities[feature.to_s]
49
+ value.is_a?(Hash) ? value.fetch(:supported, value.fetch("supported", false)) == true : value == true
50
+ end
51
+ end
52
+
53
+ def initialize(backend: nil, say_callback: nil, cancellation: nil)
54
+ @backend = backend || Backend.new
55
+ @say_callback = say_callback
56
+ @cancellation = cancellation
57
+ end
58
+
59
+ def capabilities
60
+ @backend.capabilities.dup.freeze
61
+ end
62
+
63
+ def supported?(feature)
64
+ @backend.supported?(feature)
65
+ end
66
+
67
+ def question(questions)
68
+ normalized = QuestionContract.normalize_questions(questions)
69
+ request(:question, questions: normalized)
70
+ end
71
+
72
+ def select(title, options, message: nil)
73
+ normalized = normalize_options(options)
74
+ answer = request(
75
+ :select,
76
+ title: required_text(title, "select title"),
77
+ message: optional_text(message),
78
+ options: normalized
79
+ )
80
+ return nil if answer.nil?
81
+
82
+ selected = normalized.find { |option| option[:value] == answer.to_s || option[:label] == answer.to_s }
83
+ raise ArgumentError, "plugin UI returned an unknown selection" unless selected
84
+
85
+ selected[:value]
86
+ end
87
+
88
+ def confirm(title, message = nil, default: false)
89
+ if message.nil?
90
+ message = title
91
+ title = "Confirm"
92
+ end
93
+ answer = request(
94
+ :confirm,
95
+ title: required_text(title, "confirm title"),
96
+ message: required_text(message, "confirm message"),
97
+ default: default == true
98
+ )
99
+ answer == true
100
+ end
101
+
102
+ def input(title, placeholder = nil, default: nil)
103
+ answer = request(
104
+ :input,
105
+ title: required_text(title, "input title"),
106
+ placeholder: optional_text(placeholder),
107
+ default: optional_text(default)
108
+ )
109
+ return nil if answer.nil?
110
+ raise ArgumentError, "plugin UI input answer must be a string" unless answer.is_a?(String)
111
+
112
+ bounded_text(answer, "input answer")
113
+ end
114
+
115
+ def notify(message, level = :info, **options)
116
+ level = options[:level] if options.key?(:level)
117
+ level = level.to_sym
118
+ raise ArgumentError, "unsupported plugin notification level: #{level}" unless NOTIFICATION_LEVELS.include?(level)
119
+
120
+ payload = { message: required_text(message, "notification message"), level: level }
121
+ @say_callback&.call(payload[:message]) unless @backend.emit(:notify, immutable(payload))
122
+ nil
123
+ end
124
+
125
+ def progress(id = nil, message = nil, percent: nil, done: false, **values)
126
+ id ||= values[:id]
127
+ message ||= values[:message]
128
+ payload = {
129
+ id: required_text(id, "progress id", max_bytes: MAX_ID_BYTES),
130
+ message: required_text(message, "progress message"),
131
+ percent: normalize_percent(percent),
132
+ done: done == true
133
+ }.compact
134
+ unless @backend.emit(:progress, immutable(payload))
135
+ fallback = payload[:percent] ? "#{payload[:message]} (#{payload[:percent]}%)" : payload[:message]
136
+ @say_callback&.call(fallback)
137
+ end
138
+ nil
139
+ end
140
+
141
+ def with_cancellation(cancellation)
142
+ self.class.new(backend: @backend, say_callback: @say_callback, cancellation: cancellation)
143
+ end
144
+
145
+ private
146
+
147
+ def request(kind, payload)
148
+ return false if kind == :confirm && !supported?(kind)
149
+ return nil unless supported?(kind)
150
+
151
+ @cancellation&.raise_if_cancelled!
152
+ answer = @backend.request(kind, immutable(payload), cancellation: @cancellation)
153
+ @cancellation&.raise_if_cancelled!
154
+ answer
155
+ end
156
+
157
+ def normalize_options(options)
158
+ raise ArgumentError, "select options must be an array" unless options.is_a?(Array)
159
+ unless options.length.between?(1, MAX_OPTIONS)
160
+ raise ArgumentError, "select requires 1-#{MAX_OPTIONS} options"
161
+ end
162
+
163
+ normalized = options.map.with_index(1) do |option, index|
164
+ if option.is_a?(Hash)
165
+ label = option[:label] || option["label"]
166
+ value = option.key?(:value) ? option[:value] : option.fetch("value", label)
167
+ description = option[:description] || option["description"]
168
+ {
169
+ label: required_text(label, "select option #{index} label"),
170
+ value: required_text(value, "select option #{index} value"),
171
+ description: optional_text(description)
172
+ }.compact
173
+ else
174
+ text = required_text(option, "select option #{index}")
175
+ { label: text, value: text }
176
+ end
177
+ end
178
+ labels = normalized.map { |option| option[:label] }
179
+ values = normalized.map { |option| option[:value] }
180
+ raise ArgumentError, "select option labels must be unique" unless labels.uniq.length == labels.length
181
+ raise ArgumentError, "select option values must be unique" unless values.uniq.length == values.length
182
+
183
+ normalized
184
+ end
185
+
186
+ def normalize_percent(percent)
187
+ return nil if percent.nil?
188
+
189
+ number = Float(percent)
190
+ raise ArgumentError, "progress percent must be between 0 and 100" unless number.between?(0, 100)
191
+
192
+ number % 1 == 0 ? number.to_i : number
193
+ rescue TypeError, ArgumentError
194
+ raise ArgumentError, "progress percent must be between 0 and 100"
195
+ end
196
+
197
+ def required_text(value, name, max_bytes: MAX_TEXT_BYTES)
198
+ text = bounded_text(value, name, max_bytes: max_bytes)
199
+ raise ArgumentError, "#{name} is required" if text.strip.empty?
200
+
201
+ text
202
+ end
203
+
204
+ def optional_text(value)
205
+ value.nil? ? nil : bounded_text(value, "text")
206
+ end
207
+
208
+ def bounded_text(value, name, max_bytes: MAX_TEXT_BYTES)
209
+ text = value.to_s
210
+ raise ArgumentError, "#{name} exceeds #{max_bytes} bytes" if text.bytesize > max_bytes
211
+
212
+ text.freeze
213
+ end
214
+
215
+ def immutable(value)
216
+ DeepCopy.freeze(DeepCopy.dup(value))
217
+ end
218
+ end
219
+ end
@@ -1,3 +1,5 @@
1
+ require_relative "../terminal/text"
2
+
1
3
  # Namespace for the Kward CLI agent runtime.
2
4
  module Kward
3
5
  # Renderer for the editable composer text area.
@@ -35,45 +37,42 @@ module Kward
35
37
  end
36
38
 
37
39
  def compact_composer_layout(width)
38
- cursor_line, cursor_col = cursor_logical_position
40
+ lines, cursor_line, cursor_col = @composer.lines_and_cursor
39
41
  prefix = "#{@prompt_label} "
40
- line = input_lines[cursor_line] || ""
41
- input_width = [width - prefix.length, 1].max
42
- visible_start = [[cursor_col - input_width + 1, 0].max, [line.length - input_width, 0].max].min
43
- visible = line[visible_start, input_width].to_s
44
- row = "#{prefix}#{visible}"[0, width].to_s.ljust(width)
45
- [[row], 0, [prefix.length + cursor_col - visible_start, width - 1].min]
42
+ prefix_width = TerminalText.width(prefix)
43
+ line = lines[cursor_line] || ""
44
+ input_width = [width - prefix_width, 1].max
45
+ layout = TerminalText.wrap(line, width: input_width, cursor: cursor_col)
46
+ visible = layout[:rows][layout[:cursor_row]].to_s
47
+ row = visible_ljust(TerminalText.truncate("#{prefix}#{visible}", width), width)
48
+ [[row], 0, [prefix_width + layout[:cursor_col], width - 1].min]
46
49
  end
47
50
 
48
51
  def input_layout(content_width)
49
- cursor_line, cursor_col = cursor_logical_position
52
+ lines, cursor_line, cursor_col = @composer.lines_and_cursor
50
53
  rows = []
51
54
  cursor_row = 0
55
+ cursor_col_in_row = 0
52
56
  rendered_row_offset = 0
53
57
 
54
- input_lines.each_with_index do |line, index|
58
+ lines.each_with_index do |line, index|
55
59
  prefix = input_prefix(index)
56
- continuation_prefix = " " * prefix.length
57
- available = [content_width - prefix.length, 1].max
58
- chunks = line.scan(/.{1,#{available}}/m)
59
- chunks = [""] if chunks.empty?
60
- if index == cursor_line && cursor_col == line.length && line.length.positive? && (line.length % available).zero?
61
- chunks << ""
62
- end
60
+ prefix_width = TerminalText.width(prefix)
61
+ continuation_prefix = " " * prefix_width
62
+ available = [content_width - prefix_width, 1].max
63
+ layout = TerminalText.wrap(line, width: available, cursor: index == cursor_line ? cursor_col : nil)
63
64
 
64
65
  if index == cursor_line
65
- cursor_row = rendered_row_offset + (cursor_col / available)
66
+ cursor_row = rendered_row_offset + layout[:cursor_row]
67
+ cursor_col_in_row = prefix_width + layout[:cursor_col]
66
68
  end
67
69
 
68
- chunks.each_with_index do |chunk, chunk_index|
70
+ layout[:rows].each_with_index do |chunk, chunk_index|
69
71
  rows << "#{chunk_index.zero? ? prefix : continuation_prefix}#{chunk}"
70
72
  end
71
- rendered_row_offset += chunks.length
73
+ rendered_row_offset += layout[:rows].length
72
74
  end
73
75
 
74
- prefix = input_prefix(cursor_line)
75
- available = [content_width - prefix.length, 1].max
76
- cursor_col_in_row = prefix.length + (cursor_col % available)
77
76
  [rows, cursor_row, cursor_col_in_row]
78
77
  end
79
78
 
@@ -81,23 +80,28 @@ module Kward
81
80
  title = composer_title
82
81
  status = cached_composer_status_text
83
82
  if status
84
- gap = width - 2 - ANSI.strip(title).length - ANSI.strip(status).length
83
+ gap = width - 2 - TerminalText.width(ANSI.strip(title)) - TerminalText.width(ANSI.strip(status))
85
84
  if gap >= 0
86
85
  return colored("╭", :primary_green) + title + colored("─" * gap, :primary_green) + status + colored("╮", :primary_green)
87
86
  end
88
87
  end
89
88
  plain_title = ANSI.strip(title)
90
- "#{colored("╭", :primary_green)}#{title}#{colored("─" * [width - plain_title.length - 2, 0].max, :primary_green)}#{colored("╮", :primary_green)}"
89
+ "#{colored("╭", :primary_green)}#{title}#{colored("─" * [width - TerminalText.width(plain_title) - 2, 0].max, :primary_green)}#{colored("╮", :primary_green)}"
91
90
  end
92
91
 
93
92
  def composer_title
94
93
  label = composer_title_label
95
94
  if @busy && @queued_count.positive?
96
- status_composer_text(busy_title("#{label} · #{@queued_count} queued"))
95
+ status_composer_text(busy_title("#{label} · #{colored("#{@queued_count} queued", :metadata)}"))
97
96
  elsif @busy && @steered_count.to_i.positive?
98
- status_composer_text(busy_title("#{label} · #{spinner_frame} steering"))
97
+ status_composer_text(busy_title("#{label} · #{colored(spinner_frame, :activity, :bold)} steering"))
98
+ elsif @busy && response_arriving?
99
+ status_composer_text(busy_title("#{label} · #{colored("✦", :activity, :bold)} responding"))
99
100
  elsif @busy
100
- status_composer_text(busy_title("#{label} · #{spinner_frame} #{@busy_activity}"))
101
+ status_composer_text(busy_title("#{label} · #{colored(spinner_frame, :activity, :bold)} #{@busy_activity}"))
102
+ elsif @completion_status
103
+ completion = colored(@completion_status[:text], @completion_status[:style], :bold)
104
+ status_composer_text("#{label} · #{completion}")
101
105
  else
102
106
  status_composer_text(label)
103
107
  end
@@ -110,6 +114,8 @@ module Kward
110
114
  end
111
115
 
112
116
  def busy_title(text)
117
+ elapsed = busy_elapsed_text
118
+ text = "#{text} · #{colored(elapsed, :metadata)}" if elapsed
113
119
  @busy_help ? "#{text} · #{BUSY_HELP_TEXT}" : text
114
120
  end
115
121
 
@@ -140,7 +146,7 @@ module Kward
140
146
  label_left = 4
141
147
  @tabs.each_with_index.map do |label, index|
142
148
  text = tab_label(label, index)
143
- width = ANSI.strip(text).length
149
+ width = TerminalText.width(ANSI.strip(text))
144
150
  slot = {
145
151
  left: label_left - 2,
146
152
  label_left: label_left,
@@ -185,18 +191,25 @@ module Kward
185
191
 
186
192
  visible_offset = 0
187
193
  last_index = nil
188
- text.to_s.scan(/\e\[[0-9;:]*m|./m).each do |part|
194
+ text.to_s.scan(/\e\[[0-9;:]*m|\X/m).each do |part|
189
195
  if part.start_with?("\e")
190
196
  index = visible_offset.positive? ? last_index : left
191
197
  row[index] = row[index].to_s + part if index&.between?(0, row.length - 1)
192
198
  next
193
199
  end
194
200
 
201
+ part_width = TerminalText.width(part)
202
+ if part_width.zero?
203
+ row[last_index] = row[last_index].to_s + part if last_index
204
+ next
205
+ end
206
+
195
207
  index = left + visible_offset
196
208
  break if index >= row.length
197
209
  row[index] = row[index].to_s.sub(/\A /, "") + part unless index.negative?
210
+ 1.upto(part_width - 1) { |offset| row[index + offset] = "" if (index + offset).between?(0, row.length - 1) }
198
211
  last_index = index
199
- visible_offset += 1
212
+ visible_offset += part_width
200
213
  end
201
214
  end
202
215
 
@@ -252,19 +265,10 @@ module Kward
252
265
  [[input_cap, height - 3 - overlay_count - footer_count - attachment_count].min, 1].max
253
266
  end
254
267
 
255
- def input_lines
256
- lines = composer_input.split("\n", -1)
257
- lines.empty? ? [""] : lines
258
- end
259
-
260
268
  def input_prefix(_index)
261
269
  ""
262
270
  end
263
271
 
264
- def cursor_logical_position
265
- @composer.cursor_logical_position
266
- end
267
-
268
272
  def image_viewer_preview_rows(height, width: screen_width)
269
273
  available_rows = max_project_browser_rows(height, width: width)
270
274
  desired_rows = [(IMAGE_VIEWER_MAX_ROWS * image_viewer_zoom).round, 1].max
@@ -276,7 +280,7 @@ module Kward
276
280
  preview_rows = image_viewer_preview_rows(height, width: width)
277
281
  title = visible_truncate(" Image: #{@image_viewer_state[:display_path]} ", content_width)
278
282
  rows = [
279
- colored("╭", :primary_green) + title + colored("─" * [width - ANSI.strip(title).length - 2, 0].max, :primary_green) + colored("╮", :primary_green),
283
+ colored("╭", :primary_green) + title + colored("─" * [width - TerminalText.width(ANSI.strip(title)) - 2, 0].max, :primary_green) + colored("╮", :primary_green),
280
284
  image_viewer_graphic_row(width, image_viewer_sequence(width, height))
281
285
  ]
282
286
  rows.concat(Array.new(preview_rows - 1) { box_content_row("", content_width) })