kward 0.84.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +2 -2
  5. data/doc/agent-tools.md +13 -1
  6. data/doc/api.md +17 -2
  7. data/doc/composer.md +1 -1
  8. data/doc/configuration.md +27 -4
  9. data/doc/extensibility.md +2 -1
  10. data/doc/git.md +3 -1
  11. data/doc/pan.md +6 -0
  12. data/doc/permissions.md +4 -4
  13. data/doc/plugins.md +464 -15
  14. data/doc/rpc.md +154 -16
  15. data/doc/sandboxing.md +11 -5
  16. data/doc/security.md +7 -1
  17. data/doc/session-management.md +5 -4
  18. data/doc/tabs.md +6 -2
  19. data/doc/transports.md +15 -0
  20. data/doc/usage.md +4 -1
  21. data/doc/workspace-tools.md +9 -0
  22. data/examples/plugins/space_invaders.rb +1 -1
  23. data/examples/plugins/stardate_footer.rb +2 -2
  24. data/examples/plugins/telegram/plugin.rb +1 -1
  25. data/lib/kward/agent.rb +24 -11
  26. data/lib/kward/cli/compaction.rb +9 -3
  27. data/lib/kward/cli/plugins.rb +81 -12
  28. data/lib/kward/cli/prompt_interface.rb +25 -5
  29. data/lib/kward/cli/rendering.rb +3 -0
  30. data/lib/kward/cli/runtime_helpers.rb +121 -27
  31. data/lib/kward/cli/sessions.rb +9 -5
  32. data/lib/kward/cli/settings/menus.rb +745 -0
  33. data/lib/kward/cli/settings/model.rb +327 -0
  34. data/lib/kward/cli/settings.rb +6 -1055
  35. data/lib/kward/cli/slash_commands.rb +45 -4
  36. data/lib/kward/cli/tabs.rb +167 -27
  37. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +3 -3
  38. data/lib/kward/cli/worktrees.rb +65 -2
  39. data/lib/kward/cli.rb +26 -24
  40. data/lib/kward/compactor.rb +18 -7
  41. data/lib/kward/config/core.rb +389 -0
  42. data/lib/kward/config/extensions.rb +96 -0
  43. data/lib/kward/config/prompts.rb +313 -0
  44. data/lib/kward/config/settings.rb +250 -0
  45. data/lib/kward/config_files.rb +14 -1008
  46. data/lib/kward/conversation.rb +31 -2
  47. data/lib/kward/image_attachments.rb +1 -1
  48. data/lib/kward/model/client.rb +2 -2
  49. data/lib/kward/model/copilot_models.rb +2 -2
  50. data/lib/kward/model/model_info.rb +20 -3
  51. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  52. data/lib/kward/model/payloads.rb +12 -3
  53. data/lib/kward/model/typesafe_client.rb +78 -0
  54. data/lib/kward/pan/server.rb +10 -7
  55. data/lib/kward/permissions/policy.rb +6 -2
  56. data/lib/kward/plugin_registry.rb +2 -659
  57. data/lib/kward/plugins/actions.rb +453 -0
  58. data/lib/kward/plugins/chat_contract.rb +121 -0
  59. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  60. data/lib/kward/plugins/host.rb +232 -0
  61. data/lib/kward/plugins/registry.rb +1190 -0
  62. data/lib/kward/plugins/resources.rb +206 -0
  63. data/lib/kward/plugins/turn_request.rb +36 -0
  64. data/lib/kward/plugins/ui.rb +219 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +1 -1
  66. data/lib/kward/prompt_interface/composer_state.rb +1 -1
  67. data/lib/kward/prompt_interface/editor/controller.rb +1 -1
  68. data/lib/kward/prompt_interface/editor/runner.rb +1 -1
  69. data/lib/kward/prompt_interface/editor/state.rb +1 -1
  70. data/lib/kward/prompt_interface/layout.rb +1 -1
  71. data/lib/kward/prompt_interface/overlay_renderer.rb +1 -1
  72. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  73. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  74. data/lib/kward/prompt_interface/screen.rb +9 -2
  75. data/lib/kward/prompt_interface.rb +60 -11
  76. data/lib/kward/prompts/commands.rb +2 -1
  77. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  78. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +2 -2
  79. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  80. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  81. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +9 -4
  82. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  83. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  84. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  85. data/lib/kward/rpc/server.rb +91 -12
  86. data/lib/kward/rpc/session_manager.rb +147 -44
  87. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  88. data/lib/kward/rpc/tool_metadata.rb +1 -1
  89. data/lib/kward/sandbox/command_runner.rb +1 -1
  90. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  91. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  92. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  93. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  94. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  95. data/lib/kward/{kwsh.rb → shell/kwsh.rb} +3 -3
  96. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +3 -3
  97. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  98. data/lib/kward/skills/trust_store.rb +1 -1
  99. data/lib/kward/tabs/driver.rb +194 -0
  100. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  101. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  102. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  103. data/lib/kward/tools/base.rb +18 -0
  104. data/lib/kward/tools/context_for_task.rb +15 -6
  105. data/lib/kward/tools/edit_file.rb +9 -6
  106. data/lib/kward/tools/git_commit.rb +13 -7
  107. data/lib/kward/tools/list_directory.rb +4 -4
  108. data/lib/kward/tools/plugin_tool.rb +41 -0
  109. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  110. data/lib/kward/tools/read_file.rb +7 -6
  111. data/lib/kward/tools/registry.rb +96 -13
  112. data/lib/kward/tools/run_shell_command.rb +10 -8
  113. data/lib/kward/tools/search/code.rb +1 -1
  114. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  115. data/lib/kward/tools/tool_call.rb +2 -1
  116. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  117. data/lib/kward/tools/workspace_targets.rb +58 -0
  118. data/lib/kward/tools/write_file.rb +9 -6
  119. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  120. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  121. data/lib/kward/transport/contracts.rb +200 -0
  122. data/lib/kward/transport/gateway.rb +79 -35
  123. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  124. data/lib/kward/transport.rb +1 -200
  125. data/lib/kward/version.rb +1 -1
  126. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  127. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  128. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  129. data/templates/default/fulldoc/html/css/kward.css +125 -0
  130. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  131. data/templates/default/fulldoc/html/setup.rb +1 -1
  132. data/templates/default/layout/html/layout.erb +16 -4
  133. metadata +67 -48
  134. data/lib/kward/tab_driver.rb +0 -90
  135. data/templates/default/fulldoc/html/images/kward_workflow.svg +0 -52
  136. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  137. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  138. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  139. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  140. /data/lib/kward/{markdown_code_block.rb → prompt_interface/editor/markdown_code_block.rb} +0 -0
  141. /data/lib/kward/{scratchpad_languages.rb → prompt_interface/editor/scratchpad_languages.rb} +0 -0
  142. /data/lib/kward/{scratchpad_runner.rb → prompt_interface/editor/scratchpad_runner.rb} +0 -0
  143. /data/lib/kward/{detached_run.rb → pty/detached_run.rb} +0 -0
  144. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  145. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  146. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  147. /data/lib/kward/{kwshrc.rb → shell/kwshrc.rb} +0 -0
  148. /data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +0 -0
  149. /data/lib/kward/{ansi.rb → terminal/ansi.rb} +0 -0
  150. /data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +0 -0
  151. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  152. /data/lib/kward/{terminal_text.rb → terminal/text.rb} +0 -0
  153. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  154. /data/lib/kward/{project_files.rb → workspace/files.rb} +0 -0
  155. /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,4 +1,4 @@
1
- require_relative "../terminal_text"
1
+ require_relative "../terminal/text"
2
2
 
3
3
  # Namespace for the Kward CLI agent runtime.
4
4
  module Kward
@@ -1,6 +1,6 @@
1
1
  require_relative "../text_boundary"
2
2
  require_relative "../text_matcher"
3
- require_relative "../terminal_text"
3
+ require_relative "../terminal/text"
4
4
 
5
5
  # Namespace for the Kward CLI agent runtime.
6
6
  module Kward
@@ -1,5 +1,5 @@
1
1
  require "fileutils"
2
- require_relative "../../scratchpad_languages"
2
+ require_relative "scratchpad_languages"
3
3
  require_relative "runner"
4
4
 
5
5
  # Namespace for the Kward CLI agent runtime.
@@ -1,4 +1,4 @@
1
- require_relative "../../scratchpad_runner"
1
+ require_relative "scratchpad_runner"
2
2
  require_relative "runner_state"
3
3
 
4
4
  # Namespace for the Kward CLI agent runtime.
@@ -1,4 +1,4 @@
1
- require_relative "../../editor_mode"
1
+ require_relative "editor_mode"
2
2
  require_relative "../../text_boundary"
3
3
  require_relative "buffer"
4
4
  require_relative "file_marker"
@@ -19,7 +19,7 @@ module Kward
19
19
  end
20
20
 
21
21
  def transcript_bottom_row(height = screen_height)
22
- [height - @reserved_rows, 1].max
22
+ [height - @reserved_rows - TRANSCRIPT_COMPOSER_GAP_ROWS, 1].max
23
23
  end
24
24
 
25
25
  end
@@ -1,4 +1,4 @@
1
- require_relative "../terminal_text"
1
+ require_relative "../terminal/text"
2
2
 
3
3
  # Namespace for the Kward CLI agent runtime.
4
4
  module Kward
@@ -0,0 +1,82 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ class PromptInterface
4
+ # Terminal implementations of the frontend-neutral plugin UI requests.
5
+ module PluginUIRequests
6
+ def request_plugin_ui(kind, payload, cancellation: nil)
7
+ cancellation&.raise_if_cancelled!
8
+ result = case kind.to_sym
9
+ when :question then ask_user_question(payload.fetch(:questions))
10
+ when :select then request_plugin_select(payload)
11
+ when :confirm then request_plugin_confirmation(payload)
12
+ when :input then request_plugin_input(payload)
13
+ else raise ArgumentError, "unsupported plugin UI request: #{kind}"
14
+ end
15
+ cancellation&.raise_if_cancelled!
16
+ result
17
+ end
18
+
19
+ private
20
+
21
+ def request_plugin_select(payload)
22
+ options = payload.fetch(:options)
23
+ labels = options.map { |option| plugin_option_text(option) }
24
+ selected = with_plugin_ui_modal_state do
25
+ select(payload[:message] || "Choose an option", labels, title: payload.fetch(:title))
26
+ end
27
+ return nil if selected.nil?
28
+
29
+ option = options[labels.index(selected)]
30
+ option && option.fetch(:value)
31
+ end
32
+
33
+ def request_plugin_confirmation(payload)
34
+ yes = { label: "Yes", description: "Continue." }
35
+ no = { label: "No", description: "Cancel." }
36
+ answers = ask_user_question([
37
+ {
38
+ header: payload.fetch(:title),
39
+ question: payload.fetch(:message),
40
+ options: payload[:default] ? [yes, no] : [no, yes]
41
+ }
42
+ ])
43
+ return false unless answers
44
+
45
+ answers.first[:answer].to_s.casecmp?("yes")
46
+ end
47
+
48
+ def request_plugin_input(payload)
49
+ with_plugin_ui_modal_state do
50
+ @mutex.synchronize do
51
+ self.composer_input = payload[:default].to_s
52
+ self.composer_cursor = composer_input.length
53
+ @composer.prefill_input = nil
54
+ @composer.clear_attachments
55
+ @pending_keys.clear
56
+ @busy = false
57
+ @asking = true
58
+ end
59
+ label = payload[:placeholder].to_s.empty? ? payload.fetch(:title) : "#{payload.fetch(:title)} · #{payload[:placeholder]}"
60
+ answer = ask("#{label}>")
61
+ answer.is_a?(String) ? answer : nil
62
+ end
63
+ end
64
+
65
+ def with_plugin_ui_modal_state
66
+ saved_state = @mutex.synchronize do
67
+ state = begin_question_prompt_state
68
+ @question_prompt_active = true
69
+ state
70
+ end
71
+ yield
72
+ ensure
73
+ finish_question_prompt(saved_state) if saved_state
74
+ end
75
+
76
+ def plugin_option_text(option)
77
+ description = option[:description].to_s
78
+ description.empty? ? option.fetch(:label) : "#{option.fetch(:label)} — #{description}"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -108,13 +108,18 @@ module Kward
108
108
  end
109
109
 
110
110
  def refresh_footer_text_locked
111
- @cached_footer_text = @footer.call.to_s.gsub(/\s+/, " ").strip
111
+ @cached_footer_text = call_footer([screen_width - 4, 1].max).to_s.gsub(/\s+/, " ").strip
112
112
  rescue StandardError
113
113
  @cached_footer_text = ""
114
114
  ensure
115
115
  @last_footer_refresh = monotonic_now
116
116
  end
117
117
 
118
+ def call_footer(max_width)
119
+ arity = @footer.respond_to?(:arity) ? @footer.arity : @footer.method(:call).arity
120
+ arity.zero? ? @footer.call : @footer.call(max_width)
121
+ end
122
+
118
123
  def cached_composer_status_text
119
124
  return nil unless @composer_status
120
125
 
@@ -118,10 +118,16 @@ module Kward
118
118
  old_top = [height - old_reserved_rows + 1, 1].max
119
119
  @reserved_rows = new_reserved_rows
120
120
  new_top = composer_top_row(height)
121
- print_output_locked(TerminalSequences.scroll_region(1, transcript_bottom_row(height)))
121
+ new_bottom = transcript_bottom_row(height)
122
+ print_output_locked(TerminalSequences.scroll_region(1, new_bottom))
122
123
  clear_screen_rows_locked(old_top, new_top - 1) if new_top > old_top
124
+ clear_screen_rows_locked(new_bottom + 1, new_top - 1) if old_reserved_rows.positive? && new_bottom < new_top - 1
123
125
  @last_composer_rows = []
124
- redraw_transcript_locked(width: width, height: height) if redraw_transcript && new_reserved_rows < old_reserved_rows
126
+ if redraw_transcript && @stream_state.block && new_reserved_rows > old_reserved_rows
127
+ @transcript_viewport_rows = [@transcript_viewport_rows, new_bottom].min
128
+ end
129
+ should_redraw_transcript = redraw_transcript && (new_reserved_rows < old_reserved_rows || @stream_state.block)
130
+ redraw_transcript_locked(width: width, height: height) if should_redraw_transcript && new_reserved_rows != old_reserved_rows
125
131
  end
126
132
 
127
133
  def handle_resize_locked
@@ -139,6 +145,7 @@ module Kward
139
145
  @reserved_rows = 0
140
146
  @last_width = current_width
141
147
  @last_height = current_height
148
+ @last_footer_refresh = nil if current_width != old_width
142
149
  if interactive_active_locked?
143
150
  @interactive_state[:controller].resize(width: interactive_canvas_width(current_width))
144
151
  end