kward 0.71.0 → 0.73.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +30 -0
  3. data/CHANGELOG.md +93 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +4 -0
  6. data/doc/agent-tools.md +15 -6
  7. data/doc/authentication.md +22 -1
  8. data/doc/code-search.md +42 -2
  9. data/doc/configuration.md +106 -3
  10. data/doc/context-budgeting.md +136 -0
  11. data/doc/context-tools.md +16 -3
  12. data/doc/editor.md +415 -0
  13. data/doc/extensibility.md +16 -7
  14. data/doc/files.md +100 -0
  15. data/doc/getting-started.md +25 -18
  16. data/doc/git.md +123 -0
  17. data/doc/memory.md +24 -4
  18. data/doc/personas.md +34 -5
  19. data/doc/plugins.md +72 -1
  20. data/doc/releasing.md +37 -9
  21. data/doc/rpc.md +75 -5
  22. data/doc/session-management.md +35 -1
  23. data/doc/shell.md +332 -0
  24. data/doc/tabs.md +122 -0
  25. data/doc/troubleshooting.md +77 -1
  26. data/doc/usage.md +79 -7
  27. data/doc/web-search.md +12 -4
  28. data/doc/workspace-tools.md +51 -12
  29. data/examples/plugins/space_invaders.rb +377 -0
  30. data/lib/kward/agent.rb +1 -1
  31. data/lib/kward/ansi.rb +62 -23
  32. data/lib/kward/cli/commands.rb +33 -2
  33. data/lib/kward/cli/git.rb +150 -0
  34. data/lib/kward/cli/interactive_turn.rb +73 -9
  35. data/lib/kward/cli/plugins.rb +54 -4
  36. data/lib/kward/cli/prompt_interface.rb +32 -1
  37. data/lib/kward/cli/rendering.rb +4 -1
  38. data/lib/kward/cli/runtime_helpers.rb +268 -4
  39. data/lib/kward/cli/sessions.rb +2 -2
  40. data/lib/kward/cli/settings.rb +217 -9
  41. data/lib/kward/cli/slash_commands.rb +628 -2
  42. data/lib/kward/cli/tabs.rb +725 -0
  43. data/lib/kward/cli/tool_summaries.rb +6 -0
  44. data/lib/kward/cli.rb +150 -26
  45. data/lib/kward/clipboard.rb +2 -3
  46. data/lib/kward/compactor.rb +7 -19
  47. data/lib/kward/config_files.rb +145 -1
  48. data/lib/kward/context_budget_meter.rb +44 -0
  49. data/lib/kward/conversation.rb +12 -4
  50. data/lib/kward/editor_mode.rb +25 -0
  51. data/lib/kward/ekwsh.rb +559 -0
  52. data/lib/kward/image_attachments.rb +3 -1
  53. data/lib/kward/interactive_pty_runner.rb +151 -0
  54. data/lib/kward/local_command_runner.rb +155 -0
  55. data/lib/kward/local_pty_command_runner.rb +171 -0
  56. data/lib/kward/model/context_usage.rb +2 -2
  57. data/lib/kward/model/payloads.rb +2 -5
  58. data/lib/kward/plugin_registry.rb +61 -0
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +84 -0
  61. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  62. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  63. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  64. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  65. data/lib/kward/prompt_interface/editor/auto_indent.rb +510 -0
  66. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  67. data/lib/kward/prompt_interface/editor/controller.rb +1218 -0
  68. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  69. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  70. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  71. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  73. data/lib/kward/prompt_interface/editor/modes/modern.rb +354 -0
  74. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1812 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +244 -0
  77. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  78. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  79. data/lib/kward/prompt_interface/editor/state.rb +1271 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +422 -0
  82. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  83. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  84. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  85. data/lib/kward/prompt_interface/git_prompt.rb +288 -0
  86. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  87. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  88. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  89. data/lib/kward/prompt_interface/key_handler.rb +451 -57
  90. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  92. data/lib/kward/prompt_interface/question_prompt.rb +99 -56
  93. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  94. data/lib/kward/prompt_interface/screen.rb +19 -3
  95. data/lib/kward/prompt_interface/selection_prompt.rb +10 -19
  96. data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
  97. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  98. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  99. data/lib/kward/prompt_interface.rb +366 -222
  100. data/lib/kward/prompts/commands.rb +9 -0
  101. data/lib/kward/prompts.rb +2 -0
  102. data/lib/kward/rpc/memory_methods.rb +83 -0
  103. data/lib/kward/rpc/server.rb +169 -83
  104. data/lib/kward/rpc/session_manager.rb +45 -121
  105. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  106. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  107. data/lib/kward/rpc/tool_metadata.rb +11 -0
  108. data/lib/kward/rpc/transcript_normalizer.rb +4 -39
  109. data/lib/kward/scratchpad_runner.rb +56 -0
  110. data/lib/kward/session_diff.rb +20 -3
  111. data/lib/kward/session_naming.rb +11 -0
  112. data/lib/kward/session_store.rb +44 -0
  113. data/lib/kward/session_tree_nodes.rb +136 -0
  114. data/lib/kward/session_tree_renderer.rb +9 -131
  115. data/lib/kward/tab_store.rb +47 -0
  116. data/lib/kward/terminal_keys.rb +84 -0
  117. data/lib/kward/terminal_sequences.rb +42 -0
  118. data/lib/kward/text_boundary.rb +25 -0
  119. data/lib/kward/tools/context_budget_stats.rb +54 -0
  120. data/lib/kward/tools/context_for_task.rb +204 -0
  121. data/lib/kward/tools/read_file.rb +8 -4
  122. data/lib/kward/tools/registry.rb +62 -16
  123. data/lib/kward/tools/tool_call.rb +10 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +93 -0
  126. data/lib/kward/workers/job.rb +99 -0
  127. data/lib/kward/workers/live_view.rb +49 -0
  128. data/lib/kward/workers/manager.rb +288 -0
  129. data/lib/kward/workers/queue_runner.rb +166 -0
  130. data/lib/kward/workers/queue_store.rb +112 -0
  131. data/lib/kward/workers/store.rb +72 -0
  132. data/lib/kward/workers/tool_policy.rb +23 -0
  133. data/lib/kward/workers/worker.rb +82 -0
  134. data/lib/kward/workers/write_lock.rb +38 -0
  135. data/lib/kward/workers.rb +10 -0
  136. data/lib/kward/workspace.rb +125 -87
  137. data/templates/default/fulldoc/html/css/kward.css +140 -36
  138. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  139. data/templates/default/fulldoc/html/setup.rb +1 -0
  140. data/templates/default/kward_navigation.rb +12 -1
  141. data/templates/default/layout/html/layout.erb +23 -34
  142. data/templates/default/layout/html/setup.rb +6 -0
  143. metadata +67 -1
@@ -0,0 +1,151 @@
1
+ require "io/console"
2
+ require "pty"
3
+ require "timeout"
4
+ require_relative "local_pty_command_runner"
5
+
6
+ # Namespace for the Kward CLI agent runtime.
7
+ module Kward
8
+ # Runs a command in a PTY while forwarding caller-owned input and output IOs.
9
+ # This is intentionally low level: UI orchestration decides when terminal
10
+ # ownership is handed to the child process and how the result is presented.
11
+ class InteractivePtyRunner
12
+ Result = Struct.new(:exit_status, keyword_init: true)
13
+
14
+ READ_SIZE = 4096
15
+
16
+ def initialize(window_size_provider: nil)
17
+ @window_size_provider = window_size_provider
18
+ @window_size = nil
19
+ end
20
+
21
+ def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, output: $stdout)
22
+ pid = nil
23
+ status = nil
24
+
25
+ PTY.spawn(env.to_h, *command, chdir: cwd.to_s) do |reader, writer, child_pid|
26
+ pid = child_pid
27
+ update_window_size(reader, pid)
28
+ with_raw_input(input) do
29
+ drain_initial_input(input, writer)
30
+ loop do
31
+ update_window_size(reader, pid)
32
+ readable = IO.select([reader, input], nil, nil, 0.02)&.first || []
33
+ forward_pty_output(reader, output) if readable.include?(reader)
34
+ forward_input(input, writer) if readable.include?(input)
35
+ if (finished_status = finished_status(pid))
36
+ status = finished_status
37
+ break
38
+ end
39
+ rescue Errno::EIO, IOError
40
+ break
41
+ end
42
+ end
43
+ status ||= wait_for_status(pid)
44
+ ensure
45
+ writer&.close unless writer&.closed?
46
+ end
47
+
48
+ Result.new(exit_status: exit_status(status))
49
+ end
50
+
51
+ private
52
+
53
+ def with_raw_input(input)
54
+ return yield unless input.respond_to?(:raw)
55
+
56
+ input.raw { yield }
57
+ rescue Errno::ENOTTY
58
+ yield
59
+ end
60
+
61
+ def drain_initial_input(input, writer)
62
+ loop do
63
+ chunk = input.read_nonblock(READ_SIZE, exception: false)
64
+ break if chunk.nil? || chunk == :wait_readable
65
+
66
+ writer.write(chunk)
67
+ end
68
+ writer.flush
69
+ rescue Errno::EIO, Errno::EPIPE, IOError
70
+ nil
71
+ end
72
+
73
+ def forward_pty_output(reader, output)
74
+ chunk = reader.read_nonblock(READ_SIZE, exception: false)
75
+ return if chunk.nil? || chunk == :wait_readable
76
+
77
+ output.write(chunk)
78
+ output.flush if output.respond_to?(:flush)
79
+ end
80
+
81
+ def forward_input(input, writer)
82
+ chunk = input.read_nonblock(READ_SIZE, exception: false)
83
+ return if chunk.nil? || chunk == :wait_readable
84
+
85
+ writer.write(chunk)
86
+ writer.flush
87
+ rescue Errno::EIO, Errno::EPIPE, IOError
88
+ nil
89
+ end
90
+
91
+ def update_window_size(reader, pid)
92
+ window_size = terminal_window_size
93
+ return if window_size == @window_size
94
+
95
+ @window_size = window_size
96
+ reader.winsize = window_size
97
+ signal_process("WINCH", -pid) || signal_process("WINCH", pid)
98
+ rescue StandardError
99
+ nil
100
+ end
101
+
102
+ def terminal_window_size
103
+ rows, columns = @window_size_provider ? @window_size_provider.call : IO.console&.winsize
104
+ rows = LocalPtyCommandRunner::DEFAULT_ROWS unless rows.to_i.positive?
105
+ columns = LocalPtyCommandRunner::DEFAULT_COLUMNS unless columns.to_i.positive?
106
+ [rows, columns]
107
+ rescue StandardError
108
+ [LocalPtyCommandRunner::DEFAULT_ROWS, LocalPtyCommandRunner::DEFAULT_COLUMNS]
109
+ end
110
+
111
+ def finished_status(pid)
112
+ return unless pid
113
+
114
+ finished_pid, status = Process.wait2(pid, Process::WNOHANG)
115
+ status if finished_pid
116
+ rescue Errno::ECHILD
117
+ nil
118
+ end
119
+
120
+ def wait_for_status(pid)
121
+ return unless pid
122
+
123
+ _, status = Process.wait2(pid)
124
+ status
125
+ rescue Errno::ECHILD
126
+ nil
127
+ end
128
+
129
+ def exit_status(status)
130
+ return 1 unless status
131
+ return status.exitstatus if status.exited?
132
+ return 128 + status.termsig if status.signaled?
133
+
134
+ 1
135
+ end
136
+
137
+ def process_running?(pid)
138
+ Process.kill(0, pid)
139
+ true
140
+ rescue Errno::ESRCH
141
+ false
142
+ end
143
+
144
+ def signal_process(signal, pid)
145
+ Process.kill(signal, pid)
146
+ true
147
+ rescue Errno::ESRCH, Errno::EINVAL, Errno::EPERM
148
+ false
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,155 @@
1
+ require "open3"
2
+ require "thread"
3
+ require "timeout"
4
+ require_relative "cancellation"
5
+
6
+ # Namespace for the Kward CLI agent runtime.
7
+ module Kward
8
+ # Low-level local process runner with bounded capture, timeout, cancellation,
9
+ # and optional output streaming. Callers own command semantics and formatting.
10
+ class LocalCommandRunner
11
+ Result = Struct.new(:stdout, :stderr, :exit_status, :timed_out, :truncated, keyword_init: true)
12
+
13
+ READ_SIZE = 4096
14
+
15
+ def initialize(timeout_seconds:, max_output_bytes:, terminate_on_output_limit: false)
16
+ @timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i : 30
17
+ @max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i : 128 * 1024
18
+ @terminate_on_output_limit = terminate_on_output_limit
19
+ end
20
+
21
+ def run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, &block)
22
+ cancellation&.raise_if_cancelled!
23
+ stdout_buffer = +""
24
+ stderr_buffer = +""
25
+ captured_bytes = 0
26
+ truncated = false
27
+ timed_out = false
28
+ queue = Queue.new
29
+
30
+ Open3.popen3(env.to_h, *command, chdir: cwd.to_s, pgroup: true) do |stdin, stdout, stderr, wait_thread|
31
+ stdin.close
32
+ readers = [
33
+ read_stream(stdout, :stdout, queue),
34
+ read_stream(stderr, :stderr, queue)
35
+ ]
36
+ cancellation&.on_cancel { terminate_process_group(wait_thread.pid) }
37
+
38
+ status = wait_for_process(wait_thread, readers, queue, cancellation: cancellation) do |stream, chunk|
39
+ captured_bytes, truncated, captured_chunk = capture_chunk(
40
+ stream,
41
+ chunk,
42
+ stdout_buffer,
43
+ stderr_buffer,
44
+ captured_bytes,
45
+ truncated
46
+ )
47
+ block&.call(stream, captured_chunk) unless captured_chunk.empty?
48
+ terminate_process_group(wait_thread.pid) if truncated && @terminate_on_output_limit
49
+ end
50
+
51
+ join_readers(readers)
52
+ drain_queue(queue) do |stream, chunk|
53
+ captured_bytes, truncated, captured_chunk = capture_chunk(
54
+ stream,
55
+ chunk,
56
+ stdout_buffer,
57
+ stderr_buffer,
58
+ captured_bytes,
59
+ truncated
60
+ )
61
+ block&.call(stream, captured_chunk) unless captured_chunk.empty?
62
+ end
63
+
64
+ Result.new(stdout: stdout_buffer, stderr: stderr_buffer, exit_status: status.exitstatus || 1, timed_out: false, truncated: truncated)
65
+ rescue Timeout::Error
66
+ timed_out = true
67
+ terminate_process_group(wait_thread.pid)
68
+ join_readers(readers)
69
+ Result.new(stdout: stdout_buffer, stderr: stderr_buffer, exit_status: nil, timed_out: timed_out, truncated: truncated)
70
+ ensure
71
+ readers&.each { |reader| reader.kill if reader&.alive? }
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def read_stream(io, stream, queue)
78
+ Thread.new do
79
+ loop do
80
+ queue << [stream, io.readpartial(READ_SIZE)]
81
+ rescue EOFError
82
+ break
83
+ end
84
+ end
85
+ end
86
+
87
+ def wait_for_process(wait_thread, readers, queue, cancellation:)
88
+ deadline = Time.now + @timeout_seconds
89
+ loop do
90
+ cancellation&.raise_if_cancelled!
91
+ drain_queue(queue) { |stream, chunk| yield(stream, chunk) }
92
+ if wait_thread.join(0.02)
93
+ cancellation&.raise_if_cancelled!
94
+ return wait_thread.value
95
+ end
96
+ raise Timeout::Error if Time.now >= deadline
97
+ end
98
+ rescue Cancellation::CancelledError
99
+ terminate_process_group(wait_thread.pid)
100
+ join_readers(readers)
101
+ raise
102
+ end
103
+
104
+ def drain_queue(queue)
105
+ loop do
106
+ stream, chunk = queue.pop(true)
107
+ yield(stream, chunk)
108
+ rescue ThreadError
109
+ break
110
+ end
111
+ end
112
+
113
+ def capture_chunk(stream, chunk, stdout_buffer, stderr_buffer, captured_bytes, truncated)
114
+ return [captured_bytes, truncated, ""] if truncated
115
+
116
+ remaining = @max_output_bytes - captured_bytes
117
+ if chunk.bytesize > remaining
118
+ truncated = true
119
+ chunk = remaining.positive? ? chunk.byteslice(0, remaining).to_s : ""
120
+ end
121
+
122
+ stream == :stderr ? stderr_buffer << chunk : stdout_buffer << chunk
123
+ [captured_bytes + chunk.bytesize, truncated, chunk]
124
+ end
125
+
126
+ def join_readers(readers)
127
+ readers.to_a.each { |reader| reader.join(0.1) }
128
+ end
129
+
130
+ def terminate_process_group(pid)
131
+ signal_process("TERM", -pid) || signal_process("TERM", pid)
132
+ deadline = Time.now + 0.2
133
+ while Time.now < deadline
134
+ return unless process_running?(pid)
135
+
136
+ sleep 0.02
137
+ end
138
+ signal_process("KILL", -pid) || signal_process("KILL", pid)
139
+ end
140
+
141
+ def process_running?(pid)
142
+ Process.kill(0, pid)
143
+ true
144
+ rescue Errno::ESRCH
145
+ false
146
+ end
147
+
148
+ def signal_process(signal, pid)
149
+ Process.kill(signal, pid)
150
+ true
151
+ rescue Errno::ESRCH, Errno::EINVAL, Errno::EPERM
152
+ false
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,171 @@
1
+ require "io/console"
2
+ require "pty"
3
+ require "timeout"
4
+ require_relative "cancellation"
5
+ require_relative "local_command_runner"
6
+
7
+ # Namespace for the Kward CLI agent runtime.
8
+ module Kward
9
+ # Low-level pseudo-terminal command runner with bounded capture, timeout,
10
+ # cancellation, and optional output streaming. This gives child processes a
11
+ # TTY without trying to emulate an interactive terminal.
12
+ class LocalPtyCommandRunner
13
+ Result = LocalCommandRunner::Result
14
+
15
+ READ_SIZE = 4096
16
+ DEFAULT_ROWS = 24
17
+ DEFAULT_COLUMNS = 80
18
+
19
+ def initialize(timeout_seconds:, max_output_bytes:, terminate_on_output_limit: false, window_size_provider: nil)
20
+ @timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i : 30
21
+ @max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i : 128 * 1024
22
+ @terminate_on_output_limit = terminate_on_output_limit
23
+ @window_size_provider = window_size_provider
24
+ @window_size = nil
25
+ end
26
+
27
+ def run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, &block)
28
+ cancellation&.raise_if_cancelled!
29
+ output = +""
30
+ captured_bytes = 0
31
+ truncated = false
32
+ timed_out = false
33
+ cancelled = false
34
+ pid = nil
35
+ status = nil
36
+
37
+ PTY.spawn(env.to_h, *command, chdir: cwd.to_s) do |reader, _writer, child_pid|
38
+ pid = child_pid
39
+ update_window_size(reader, pid)
40
+ cancellation&.on_cancel do
41
+ cancelled = true
42
+ terminate_process_group(pid)
43
+ end
44
+
45
+ begin
46
+ deadline = Time.now + @timeout_seconds
47
+ loop do
48
+ cancellation&.raise_if_cancelled!
49
+ raise Timeout::Error if Time.now >= deadline
50
+
51
+ update_window_size(reader, pid)
52
+ readable, = IO.select([reader], nil, nil, 0.02)
53
+ next unless readable
54
+
55
+ chunk = read_chunk(reader)
56
+ break if chunk.nil?
57
+
58
+ chunk = normalize_line_endings(chunk)
59
+ captured_bytes, truncated, captured_chunk = capture_chunk(chunk, output, captured_bytes, truncated)
60
+ block&.call(:stdout, captured_chunk) unless captured_chunk.empty?
61
+ terminate_process_group(pid) if truncated && @terminate_on_output_limit
62
+ end
63
+ rescue Errno::EIO
64
+ nil
65
+ end
66
+
67
+ status = wait_for_status(pid)
68
+ end
69
+
70
+ cancellation&.raise_if_cancelled! if cancelled
71
+ Result.new(stdout: output, stderr: "", exit_status: exit_status(status), timed_out: false, truncated: truncated)
72
+ rescue Timeout::Error
73
+ timed_out = true
74
+ terminate_process_group(pid) if pid
75
+ wait_for_status(pid) if pid
76
+ Result.new(stdout: output, stderr: "", exit_status: nil, timed_out: timed_out, truncated: truncated)
77
+ rescue Cancellation::CancelledError
78
+ terminate_process_group(pid) if pid
79
+ wait_for_status(pid) if pid
80
+ raise
81
+ end
82
+
83
+ private
84
+
85
+ def update_window_size(reader, pid)
86
+ window_size = terminal_window_size
87
+ return if window_size == @window_size
88
+
89
+ @window_size = window_size
90
+ reader.winsize = window_size
91
+ signal_process("WINCH", -pid) || signal_process("WINCH", pid)
92
+ rescue StandardError
93
+ nil
94
+ end
95
+
96
+ def terminal_window_size
97
+ rows, columns = @window_size_provider ? @window_size_provider.call : IO.console&.winsize
98
+ rows = DEFAULT_ROWS unless rows.to_i.positive?
99
+ columns = DEFAULT_COLUMNS unless columns.to_i.positive?
100
+ [rows, columns]
101
+ rescue StandardError
102
+ [DEFAULT_ROWS, DEFAULT_COLUMNS]
103
+ end
104
+
105
+ def read_chunk(reader)
106
+ reader.read_nonblock(READ_SIZE, exception: false).tap do |chunk|
107
+ return nil if chunk.nil?
108
+ return nil if chunk == :wait_readable
109
+ end
110
+ end
111
+
112
+ def normalize_line_endings(chunk)
113
+ chunk.gsub("\r\r\n", "\n").gsub("\r\n", "\n")
114
+ end
115
+
116
+ def capture_chunk(chunk, output, captured_bytes, truncated)
117
+ return [captured_bytes, truncated, ""] if truncated
118
+
119
+ remaining = @max_output_bytes - captured_bytes
120
+ if chunk.bytesize > remaining
121
+ truncated = true
122
+ chunk = remaining.positive? ? chunk.byteslice(0, remaining).to_s : ""
123
+ end
124
+
125
+ output << chunk
126
+ [captured_bytes + chunk.bytesize, truncated, chunk]
127
+ end
128
+
129
+ def wait_for_status(pid)
130
+ return unless pid
131
+
132
+ _, status = Process.wait2(pid)
133
+ status
134
+ rescue Errno::ECHILD
135
+ nil
136
+ end
137
+
138
+ def exit_status(status)
139
+ return 1 unless status
140
+ return status.exitstatus if status.exited?
141
+ return 128 + status.termsig if status.signaled?
142
+
143
+ 1
144
+ end
145
+
146
+ def terminate_process_group(pid)
147
+ signal_process("TERM", -pid) || signal_process("TERM", pid)
148
+ deadline = Time.now + 0.2
149
+ while Time.now < deadline
150
+ return unless process_running?(pid)
151
+
152
+ sleep 0.02
153
+ end
154
+ signal_process("KILL", -pid) || signal_process("KILL", pid)
155
+ end
156
+
157
+ def process_running?(pid)
158
+ Process.kill(0, pid)
159
+ true
160
+ rescue Errno::ESRCH
161
+ false
162
+ end
163
+
164
+ def signal_process(signal, pid)
165
+ Process.kill(signal, pid)
166
+ true
167
+ rescue Errno::ESRCH, Errno::EINVAL, Errno::EPERM
168
+ false
169
+ end
170
+ end
171
+ end
@@ -12,7 +12,7 @@ module Kward
12
12
  def call(provider:, model:, context_window:, context_parts:)
13
13
  return nil unless context_window
14
14
 
15
- parts = redact_image_data(stringify_keys(context_parts || {}))
15
+ parts = redact_image_data(stringify_top_level_keys(context_parts || {}))
16
16
  return nil unless contains_session_content?(parts)
17
17
 
18
18
  payload = prompt_payload(parts)
@@ -71,7 +71,7 @@ module Kward
71
71
  ["data", "image_url"].include?(key.to_s)
72
72
  end
73
73
 
74
- def stringify_keys(value)
74
+ def stringify_top_level_keys(value)
75
75
  return value unless value.is_a?(Hash)
76
76
 
77
77
  value.each_with_object({}) { |(key, item), result| result[key.to_s] = item }
@@ -1,6 +1,7 @@
1
1
  require "json"
2
2
  require_relative "../image_attachments"
3
3
  require_relative "../message_access"
4
+ require_relative "../tools/tool_call"
4
5
  require_relative "model_info"
5
6
 
6
7
  # Namespace for the Kward CLI agent runtime.
@@ -255,11 +256,7 @@ module Kward
255
256
  end
256
257
 
257
258
  def parse_tool_arguments(arguments)
258
- return arguments if arguments.is_a?(Hash)
259
-
260
- JSON.parse(arguments.to_s.empty? ? "{}" : arguments.to_s)
261
- rescue JSON::ParserError
262
- {}
259
+ ToolCall.parse_arguments(arguments)
263
260
  end
264
261
 
265
262
  def anthropic_tool_schema(tool)
@@ -23,6 +23,15 @@ module Kward
23
23
  end
24
24
  end
25
25
 
26
+ # Registered interactive command that takes over the composer region with a
27
+ # Kward-driven render and input loop. Like a slash command but with canvas
28
+ # rendering capabilities for games, dashboards, viewers, and similar uses.
29
+ InteractiveCommand = Struct.new(:name, :description, :argument_hint, :rows, :fps, :path, :handler, keyword_init: true) do
30
+ def entry
31
+ { name: name, description: description, argument_hint: argument_hint }
32
+ end
33
+ end
34
+
26
35
  # Read-only event passed to plugin transcript observers.
27
36
  TranscriptEvent = Struct.new(:type, :payload, keyword_init: true) do
28
37
  def to_h
@@ -167,6 +176,24 @@ module Kward
167
176
  def prompt_context(&block)
168
177
  @registry.register_prompt_context(path: @path, &block)
169
178
  end
179
+
180
+ # Registers an interactive command that takes over the composer region with
181
+ # a Kward-driven render and input loop. The handler receives an
182
+ # interactive controller object with a canvas API for drawing colored
183
+ # cells and reading keys. Useful for games, dashboards, and viewers.
184
+ #
185
+ # @param name [String, #to_s] command name without the leading slash
186
+ # @param rows [Integer] fixed canvas height in terminal rows
187
+ # @param fps [Numeric] frame rate for tick callbacks (1-120, default 30)
188
+ # @param description [String] short text shown in command listings
189
+ # @param argument_hint [String] optional usage hint for arguments
190
+ # @yieldparam ui [Object] interactive controller with canvas and key API
191
+ # @yieldparam ctx [Context] plugin execution context
192
+ # @return [void]
193
+ # @api public
194
+ def interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", &block)
195
+ @registry.register_interactive_command(name, rows: rows, fps: fps, description: description, argument_hint: argument_hint, path: @path, &block)
196
+ end
170
197
  end
171
198
 
172
199
  # Mutable singleton guard used while loading trusted plugin files.
@@ -207,6 +234,7 @@ module Kward
207
234
  def initialize(reserved_commands: [])
208
235
  @reserved_commands = reserved_commands.map(&:to_s)
209
236
  @commands = {}
237
+ @interactive_commands = {}
210
238
  @footer = nil
211
239
  @footer_path = nil
212
240
  @transcript_event_handlers = []
@@ -228,6 +256,14 @@ module Kward
228
256
  @commands[name.to_s]
229
257
  end
230
258
 
259
+ def interactive_commands
260
+ @interactive_commands.values
261
+ end
262
+
263
+ def interactive_command_for(name)
264
+ @interactive_commands[name.to_s]
265
+ end
266
+
231
267
  def footer_renderer
232
268
  @footer
233
269
  end
@@ -306,6 +342,31 @@ module Kward
306
342
  )
307
343
  end
308
344
 
345
+ def register_interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", path: nil, &handler)
346
+ name = name.to_s
347
+ raise "Interactive command name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
348
+ raise "Interactive command /#{name} requires a handler" unless handler
349
+
350
+ if @reserved_commands.include?(name) || @commands.key?(name)
351
+ warn "Warning: skipping Kward interactive command /#{name}: reserved command"
352
+ return nil
353
+ end
354
+ if @interactive_commands.key?(name)
355
+ warn "Warning: skipping duplicate Kward interactive command /#{name}: #{path}"
356
+ return nil
357
+ end
358
+
359
+ @interactive_commands[name] = InteractiveCommand.new(
360
+ name: name,
361
+ description: description.to_s,
362
+ argument_hint: argument_hint.to_s,
363
+ rows: [[rows.to_i, 1].max, 1].max,
364
+ fps: [[fps.to_f, 1].max, 120].min,
365
+ path: path,
366
+ handler: handler
367
+ )
368
+ end
369
+
309
370
  def register_footer(path: nil, &renderer)
310
371
  raise "Plugin footer requires a renderer" unless renderer
311
372
 
@@ -0,0 +1,52 @@
1
+ require "find"
2
+ require "open3"
3
+ require "pathname"
4
+
5
+ # Namespace for the Kward CLI agent runtime.
6
+ module Kward
7
+ # Discovers project files for prompt UI features.
8
+ module ProjectFiles
9
+ module_function
10
+
11
+ def list(root: Dir.pwd)
12
+ paths = git_paths(root)
13
+ paths = scanned_paths(root) if paths.empty?
14
+ paths.reject { |path| path.empty? || path.end_with?("/") }.uniq.sort
15
+ end
16
+
17
+ def git_paths(root)
18
+ output, status = Open3.capture2("git", "ls-files", "--cached", "--others", "--exclude-standard", chdir: root)
19
+ return [] unless status.success?
20
+
21
+ output.lines.map(&:chomp).reject(&:empty?)
22
+ rescue StandardError
23
+ []
24
+ end
25
+
26
+ def scanned_paths(root)
27
+ root_path = Pathname.new(root)
28
+ paths = []
29
+ Find.find(root_path.to_s) do |path|
30
+ relative = Pathname.new(path).relative_path_from(root_path).to_s
31
+ if File.directory?(path)
32
+ Find.prune if ignored_directory?(relative)
33
+ next
34
+ end
35
+
36
+ paths << relative unless ignored_file?(relative)
37
+ end
38
+ paths
39
+ rescue StandardError
40
+ []
41
+ end
42
+
43
+ def ignored_directory?(relative)
44
+ ignored_directories = %w[.git .yardoc _yardoc node_modules rdoc tmp vendor/bundle]
45
+ ignored_directories.include?(relative) || relative.start_with?(".git/")
46
+ end
47
+
48
+ def ignored_file?(relative)
49
+ relative.start_with?(".git/")
50
+ end
51
+ end
52
+ end