kward 0.71.0 → 0.72.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -0
  5. data/doc/agent-tools.md +15 -6
  6. data/doc/authentication.md +22 -1
  7. data/doc/code-search.md +42 -2
  8. data/doc/configuration.md +106 -3
  9. data/doc/context-budgeting.md +136 -0
  10. data/doc/context-tools.md +16 -3
  11. data/doc/editor.md +394 -0
  12. data/doc/extensibility.md +16 -7
  13. data/doc/files.md +100 -0
  14. data/doc/getting-started.md +25 -18
  15. data/doc/git.md +122 -0
  16. data/doc/memory.md +24 -4
  17. data/doc/personas.md +34 -5
  18. data/doc/plugins.md +72 -1
  19. data/doc/releasing.md +37 -9
  20. data/doc/rpc.md +74 -4
  21. data/doc/session-management.md +35 -1
  22. data/doc/shell.md +286 -0
  23. data/doc/tabs.md +122 -0
  24. data/doc/troubleshooting.md +77 -1
  25. data/doc/usage.md +53 -7
  26. data/doc/web-search.md +12 -4
  27. data/doc/workspace-tools.md +51 -12
  28. data/examples/plugins/space_invaders.rb +377 -0
  29. data/lib/kward/agent.rb +1 -1
  30. data/lib/kward/cli/commands.rb +33 -2
  31. data/lib/kward/cli/git.rb +150 -0
  32. data/lib/kward/cli/interactive_turn.rb +73 -9
  33. data/lib/kward/cli/plugins.rb +54 -4
  34. data/lib/kward/cli/prompt_interface.rb +32 -1
  35. data/lib/kward/cli/runtime_helpers.rb +133 -3
  36. data/lib/kward/cli/sessions.rb +2 -2
  37. data/lib/kward/cli/settings.rb +218 -9
  38. data/lib/kward/cli/slash_commands.rb +415 -2
  39. data/lib/kward/cli/tabs.rb +695 -0
  40. data/lib/kward/cli.rb +158 -26
  41. data/lib/kward/config_files.rb +123 -1
  42. data/lib/kward/context_budget_meter.rb +44 -0
  43. data/lib/kward/conversation.rb +12 -4
  44. data/lib/kward/editor_mode.rb +25 -0
  45. data/lib/kward/ekwsh.rb +362 -0
  46. data/lib/kward/plugin_registry.rb +61 -0
  47. data/lib/kward/project_files.rb +52 -0
  48. data/lib/kward/prompt_history.rb +82 -0
  49. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  50. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  51. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  52. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  53. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  54. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  55. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  56. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  57. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  58. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  59. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  60. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  61. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  62. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  63. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  64. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  65. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  66. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  67. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  68. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  69. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  70. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  71. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  72. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  73. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  74. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  75. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  76. data/lib/kward/prompt_interface/key_handler.rb +387 -35
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  78. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +98 -50
  80. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  81. data/lib/kward/prompt_interface/screen.rb +16 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
  83. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  84. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  85. data/lib/kward/prompt_interface.rb +286 -8
  86. data/lib/kward/prompts/commands.rb +5 -0
  87. data/lib/kward/prompts.rb +2 -0
  88. data/lib/kward/rpc/server.rb +42 -3
  89. data/lib/kward/rpc/session_manager.rb +35 -47
  90. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  91. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  92. data/lib/kward/session_store.rb +44 -0
  93. data/lib/kward/session_tree_nodes.rb +136 -0
  94. data/lib/kward/session_tree_renderer.rb +9 -131
  95. data/lib/kward/tab_store.rb +47 -0
  96. data/lib/kward/text_boundary.rb +25 -0
  97. data/lib/kward/tools/context_budget_stats.rb +54 -0
  98. data/lib/kward/tools/context_for_task.rb +202 -0
  99. data/lib/kward/tools/read_file.rb +8 -4
  100. data/lib/kward/tools/registry.rb +62 -16
  101. data/lib/kward/tools/tool_call.rb +10 -0
  102. data/lib/kward/version.rb +1 -1
  103. data/lib/kward/workers/git_guard.rb +68 -0
  104. data/lib/kward/workers/live_view.rb +49 -0
  105. data/lib/kward/workers/manager.rb +288 -0
  106. data/lib/kward/workers/store.rb +72 -0
  107. data/lib/kward/workers/tool_policy.rb +23 -0
  108. data/lib/kward/workers/worker.rb +82 -0
  109. data/lib/kward/workers/write_lock.rb +38 -0
  110. data/lib/kward/workers.rb +7 -0
  111. data/lib/kward/workspace.rb +110 -24
  112. data/templates/default/fulldoc/html/css/kward.css +107 -36
  113. data/templates/default/kward_navigation.rb +12 -1
  114. data/templates/default/layout/html/layout.erb +4 -2
  115. data/templates/default/layout/html/setup.rb +6 -0
  116. metadata +53 -1
@@ -48,10 +48,12 @@ module Kward
48
48
  #{heading.call("Usage")}
49
49
  #{command.call("kward")} Start an interactive chat
50
50
  #{command.call("kward")} #{option.call('"Explain this project"')} Run a one-shot prompt
51
+ #{command.call("kward --filter")} #{option.call('"Translate"')} Filter stdin with an instruction
51
52
  #{command.call("kward login")} Sign in or save provider credentials
52
53
  #{command.call("kward auth status")} Show saved credential status
53
54
  #{command.call("kward init")} Install starter prompts and PRINCIPLES.md
54
55
  #{command.call("kward doctor")} Check local Kward setup
56
+ #{command.call("kward edit")} #{option.call("<filename>")} Open a file in the integrated editor
55
57
  #{command.call("kward sysprompt")} Inspect the effective system prompt
56
58
  #{command.call("kward openrouter refresh")} Refresh cached OpenRouter models
57
59
  #{command.call("kward pan")} Start Pan mode web UI
@@ -64,6 +66,7 @@ module Kward
64
66
  #{command.call("auth status|logout")} Show or clear saved credentials
65
67
  #{command.call("init")} Install starter prompts and PRINCIPLES.md
66
68
  #{command.call("doctor")} Check local Kward setup
69
+ #{command.call("edit")} #{option.call("<filename>")} Open a file in the integrated editor
67
70
  #{command.call("sysprompt")} [--raw] Inspect the effective system prompt
68
71
  #{command.call("stats tokens")} [range] [options] Export local token telemetry as CSV
69
72
  #{command.call("openrouter refresh|list")} Refresh or list cached OpenRouter models
@@ -72,14 +75,18 @@ module Kward
72
75
 
73
76
  #{heading.call("Options")}
74
77
  #{option.call("--working-directory=PATH")} Run Kward from PATH
78
+ #{option.call("--mode=MODE")} Execution mode: auto, chat, oneshot, filter
79
+ #{option.call("--filter")} Shortcut for --mode filter
75
80
  #{option.call("--help")}, #{option.call("-h")} Show this help
76
81
  #{option.call("--version")}, #{option.call("-v")} Show the installed version
77
82
 
78
83
  #{heading.call("Examples")}
79
84
  #{command.call("kward")}
80
- #{command.call("kward")} #{option.call('"Review this diff"')}
81
- #{command.call("git diff | kward")} #{option.call('"Review this diff"')}
85
+ #{command.call("kward")} #{option.call('"Explain this project"')}
86
+ #{command.call("git diff | kward")} #{option.call('"Summarize the main changes"')}
87
+ #{command.call("echo Hello | kward --filter")} #{option.call('"Translate to German"')}
82
88
  #{command.call("kward login openrouter")}
89
+ #{command.call("kward edit lib/main.rb")}
83
90
  #{command.call("kward openrouter refresh")}
84
91
  #{command.call("kward stats tokens today --bucket hour")}
85
92
 
@@ -119,6 +126,11 @@ module Kward
119
126
  description: "Check local Kward configuration, workspace, auth hints, and writable directories.",
120
127
  examples: ["kward doctor", "kward --working-directory ~/code/project doctor"]
121
128
  },
129
+ "edit" => {
130
+ usage: "kward edit <filename>",
131
+ description: "Open a file in the integrated editor.",
132
+ examples: ["kward edit lib/main.rb", "kward edit ~/notes/todo.md"]
133
+ },
122
134
  "sysprompt" => {
123
135
  usage: "kward sysprompt [--raw]",
124
136
  description: "Inspect the effective system prompt for a new conversation in the current workspace.",
@@ -200,6 +212,17 @@ module Kward
200
212
  @prompt_delimited = true
201
213
  remaining.concat(arguments[(index + 1)..] || [])
202
214
  break
215
+ when "--experimental-workers"
216
+ @experimental_workers = true
217
+ when "--filter"
218
+ @requested_mode = "filter"
219
+ when "--mode"
220
+ index += 1
221
+ raise ArgumentError, "Missing value for --mode" if index >= arguments.length
222
+
223
+ @requested_mode = normalized_execution_mode(arguments[index])
224
+ when /\A--mode=(.*)\z/
225
+ @requested_mode = normalized_execution_mode(Regexp.last_match(1))
203
226
  when "--working-directory"
204
227
  index += 1
205
228
  raise ArgumentError, "Missing value for --working-directory" if index >= arguments.length
@@ -215,6 +238,14 @@ module Kward
215
238
  remaining
216
239
  end
217
240
 
241
+ def normalized_execution_mode(value)
242
+ mode = value.to_s.strip.downcase
243
+ modes = ["auto", "chat", "oneshot", "filter"]
244
+ raise ArgumentError, "Unknown mode: #{value}. Expected one of: #{modes.join(", ")}" unless modes.include?(mode)
245
+
246
+ mode
247
+ end
248
+
218
249
  def expanded_working_directory(path)
219
250
  value = path.to_s.strip
220
251
  raise ArgumentError, "Missing value for --working-directory" if value.empty?
@@ -0,0 +1,150 @@
1
+ require "open3"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
6
+ class CLI
7
+ # Interactive Git status and commit helpers.
8
+ module GitCommands
9
+ private
10
+
11
+ def handle_git_command(agent)
12
+ unless @prompt.respond_to?(:git_commit_message)
13
+ runtime_output("/git is available in the interactive overlay only.")
14
+ return
15
+ end
16
+
17
+ root = interactive_workspace_root(agent)
18
+ git_root = git_repository_root(root)
19
+ if git_root.empty?
20
+ runtime_output("Not a Git repository: #{root}")
21
+ return
22
+ end
23
+
24
+ status = git_status_lines(git_root)
25
+ message = @prompt.git_commit_message(status) do |action|
26
+ result = handle_git_prompt_action(git_root, status, action)
27
+ status = result.is_a?(Hash) && result.key?(:status_lines) ? result[:status_lines] : result
28
+ result
29
+ end
30
+ return if message.nil?
31
+
32
+ result = run_busy_local_command_and_requeue(activity: "committing") do
33
+ git_commit(git_root, message)
34
+ end
35
+ print_git_commit_result(result)
36
+ end
37
+
38
+ def git_repository_root(root)
39
+ output, status = Open3.capture2e("git", "rev-parse", "--show-toplevel", chdir: root.to_s)
40
+ return "" unless status.success?
41
+
42
+ output.lines.first.to_s.strip
43
+ rescue StandardError
44
+ ""
45
+ end
46
+
47
+ def git_status_lines(root)
48
+ output, status = Open3.capture2e("git", "status", "--short", "--untracked-files=normal", chdir: root.to_s)
49
+ return ["Unable to read Git status: #{output.strip}"] unless status.success?
50
+
51
+ output.lines.map(&:chomp)
52
+ rescue StandardError => e
53
+ ["Unable to read Git status: #{e.message}"]
54
+ end
55
+
56
+ def handle_git_prompt_action(root, current_status, action)
57
+ case action[:action]
58
+ when :toggle_stage
59
+ toggle_git_stage(root, current_status[action[:index].to_i])
60
+ git_status_lines(root)
61
+ when :open_diff
62
+ status_line = current_status[action[:index].to_i]
63
+ { status_lines: git_status_lines(root), diff: git_diff_view(root, status_line) }
64
+ else
65
+ git_status_lines(root)
66
+ end
67
+ end
68
+
69
+ def git_diff_view(root, status_line)
70
+ entry = parse_git_status_line(status_line)
71
+ return { path: "Git diff", content: "Unable to read Git status entry.\n" } if entry.nil?
72
+
73
+ output = entry[:untracked] ? git_untracked_file_diff(root, entry[:path]) : git_tracked_file_diff(root, entry[:path])
74
+ { path: entry[:path], content: output.empty? ? "No diff for #{entry[:path]}\n" : output }
75
+ end
76
+
77
+ def git_tracked_file_diff(root, path)
78
+ output, status = Open3.capture2e("git", "diff", "HEAD", "--", path, chdir: root.to_s)
79
+ status.success? ? output : "Unable to read diff for #{path}:\n#{output}"
80
+ rescue StandardError => e
81
+ "Unable to read diff for #{path}: #{e.message}\n"
82
+ end
83
+
84
+ def git_untracked_file_diff(root, path)
85
+ full_path = File.expand_path(path, root.to_s)
86
+ content = File.file?(full_path) ? File.read(full_path) : ""
87
+ lines = ["diff --git a/#{path} b/#{path}", "new file mode 100644", "--- /dev/null", "+++ b/#{path}", "@@ -0,0 +1,#{content.lines.length} @@"]
88
+ lines.concat(content.lines(chomp: true).map { |line| "+#{line}" })
89
+ lines << "\" if !content.empty? && !content.end_with?("\n")
90
+ lines.join("\n") + "\n"
91
+ rescue StandardError => e
92
+ "Unable to read diff for #{path}: #{e.message}\n"
93
+ end
94
+
95
+ def toggle_git_stage(root, status_line)
96
+ entry = parse_git_status_line(status_line)
97
+ return if entry.nil?
98
+
99
+ command = entry[:staged] ? ["restore", "--staged", "--", entry[:path]] : ["add", "--", entry[:path]]
100
+ Open3.capture2e("git", *command, chdir: root.to_s)
101
+ rescue StandardError
102
+ nil
103
+ end
104
+
105
+ def parse_git_status_line(line)
106
+ text = line.to_s
107
+ return nil if text.length < 4
108
+
109
+ status = text[0, 2]
110
+ path = text[3..].to_s
111
+ path = path.split(" -> ", 2).last if status.include?("R") || status.include?("C")
112
+ return nil if path.empty?
113
+
114
+ { path: path, staged: status[0] != " " && status[0] != "?", untracked: status == "??" }
115
+ end
116
+
117
+ def git_commit(root, message)
118
+ return git_commit_staged(root, message) if git_staged_changes?(root)
119
+
120
+ add_output, add_status = Open3.capture2e("git", "add", "--all", chdir: root.to_s)
121
+ return { success: false, output: add_output } unless add_status.success?
122
+
123
+ git_commit_staged(root, message)
124
+ rescue StandardError => e
125
+ { success: false, output: e.message }
126
+ end
127
+
128
+ def git_staged_changes?(root)
129
+ _output, status = Open3.capture2e("git", "diff", "--cached", "--quiet", chdir: root.to_s)
130
+ !status.success?
131
+ rescue StandardError
132
+ false
133
+ end
134
+
135
+ def git_commit_staged(root, message)
136
+ commit_output, commit_status = Open3.capture2e("git", "commit", "-m", message.to_s, chdir: root.to_s)
137
+ { success: commit_status.success?, output: commit_output }
138
+ rescue StandardError => e
139
+ { success: false, output: e.message }
140
+ end
141
+
142
+ def print_git_commit_result(result)
143
+ output = result[:output].to_s.strip
144
+ output = result[:success] ? "Commit created." : "Git commit failed." if output.empty?
145
+ status = result[:success] ? "Git commit succeeded" : "Git commit failed"
146
+ runtime_output("#{status}\n#{output}")
147
+ end
148
+ end
149
+ end
150
+ end
@@ -7,6 +7,7 @@ module Kward
7
7
  private
8
8
 
9
9
  def run_interactive_turn(agent, input, display_input: nil)
10
+ stop_live_worker_view if respond_to?(:stop_live_worker_view, true)
10
11
  prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
11
12
  print_user_transcript(input, display_input: display_input) if prompt_interface?
12
13
  return run_blocking_interactive_turn(agent, input, display_input: display_input) unless prompt_interface?
@@ -42,7 +43,7 @@ module Kward
42
43
 
43
44
  while worker.alive?
44
45
  begin
45
- poll_result = collect_busy_input(queued_inputs, steering)
46
+ poll_result = collect_busy_input(queued_inputs, steering, agent)
46
47
  sleep 0.01
47
48
  rescue Interrupt
48
49
  poll_result = PromptInterface::CANCEL_INPUT
@@ -52,7 +53,11 @@ module Kward
52
53
  cancellation.cancel!
53
54
  worker.raise(Cancellation::CancelledError, "cancelled") if worker.alive?
54
55
  end
55
- drain_interactive_events(event_queue, markdown_chunks, stream_state, agent)
56
+ if busy_replacement_agent?
57
+ discard_interactive_events(event_queue, markdown_chunks, stream_state)
58
+ else
59
+ drain_interactive_events(event_queue, markdown_chunks, stream_state, agent)
60
+ end
56
61
  end
57
62
  begin
58
63
  worker.join
@@ -60,10 +65,14 @@ module Kward
60
65
  error ||= e
61
66
  end
62
67
  drain_busy_input(queued_inputs, nil) unless cancelled
63
- drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
64
- raise error if error && !error.is_a?(Cancellation::CancelledError)
68
+ if busy_replacement_agent?
69
+ discard_interactive_events(event_queue, markdown_chunks, stream_state, force: true)
70
+ else
71
+ drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
72
+ end
73
+ raise error if error && !error.is_a?(Cancellation::CancelledError) && !busy_replacement_agent?
65
74
 
66
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless cancelled || stream_state[:streamed] || answer.to_s.empty?
75
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless cancelled || busy_replacement_agent? || stream_state[:streamed] || answer.to_s.empty?
67
76
  persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
68
77
  auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && queued_inputs.empty? && !cancelled
69
78
  queued_inputs
@@ -87,6 +96,21 @@ module Kward
87
96
  flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: force)
88
97
  end
89
98
 
99
+ def discard_interactive_events(event_queue, markdown_chunks, stream_state, force: false)
100
+ drained = 0
101
+ loop do
102
+ break if !force && drained >= INTERACTIVE_EVENT_DRAIN_LIMIT
103
+
104
+ event_queue.pop(true)
105
+ drained += 1
106
+ rescue ThreadError
107
+ break
108
+ end
109
+ markdown_chunks.clear
110
+ finish_stream_block if stream_state[:stream_block_open]
111
+ stream_state[:stream_block_open] = false
112
+ end
113
+
90
114
  def handle_interactive_event(event, markdown_chunks, stream_state)
91
115
  case event
92
116
  when Events::ReasoningDelta
@@ -154,13 +178,18 @@ module Kward
154
178
  collect_busy_input(queued_inputs, nil)
155
179
  end
156
180
 
157
- def collect_busy_input(queued_inputs, steering)
181
+ def collect_busy_input(queued_inputs, steering, agent = nil)
158
182
  return nil if @prompt.respond_to?(:modal_active?) && @prompt.modal_active?
159
183
 
160
184
  poll_result = @prompt.poll_input
161
185
  case poll_result
162
186
  when String
163
- if steering && !poll_result.strip.empty?
187
+ return poll_result if handle_busy_worker_input(poll_result, agent, queued_inputs)
188
+
189
+ if slash_command_input?(poll_result)
190
+ queued_inputs << poll_result
191
+ @prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
192
+ elsif steering && !poll_result.strip.empty?
164
193
  begin
165
194
  steering.submit(poll_result)
166
195
  @prompt.set_steered_count(1) if @prompt.respond_to?(:set_steered_count)
@@ -183,16 +212,51 @@ module Kward
183
212
  drain_busy_input(queued_inputs, nil)
184
213
  end
185
214
 
186
- def drain_busy_input(queued_inputs, steering)
215
+ def drain_busy_input(queued_inputs, steering, agent = nil)
187
216
  deadline = Time.now + 0.15
188
217
  loop do
189
- poll_result = collect_busy_input(queued_inputs, steering)
218
+ poll_result = collect_busy_input(queued_inputs, steering, agent)
190
219
  break if Time.now > deadline && poll_result.nil?
191
220
 
192
221
  sleep 0.01
193
222
  end
194
223
  end
195
224
 
225
+ def slash_command_input?(input)
226
+ input.to_s.strip.start_with?("/")
227
+ end
228
+
229
+ def handle_busy_worker_input(input, agent, queued_inputs)
230
+ return false unless agent
231
+
232
+ command = input.to_s.strip
233
+ return false unless command == "/workers" || command.start_with?("/workers ")
234
+
235
+ _handled, replacement_agent = handle_local_slash_command(command, agent, @session_store)
236
+ @busy_replacement_agent = replacement_agent if replacement_agent?(replacement_agent)
237
+ restore_busy_input_prompt
238
+ true
239
+ rescue StandardError => e
240
+ runtime_output("Error: #{e.message}")
241
+ restore_busy_input_prompt
242
+ true
243
+ end
244
+
245
+ def replacement_agent?(object)
246
+ object.respond_to?(:conversation) && object.respond_to?(:ask)
247
+ end
248
+
249
+ def busy_replacement_agent?
250
+ replacement_agent?(@busy_replacement_agent)
251
+ end
252
+
253
+ def restore_busy_input_prompt
254
+ return unless @prompt.respond_to?(:begin_busy_input)
255
+ return if @prompt.respond_to?(:modal_active?) && @prompt.modal_active?
256
+
257
+ @prompt.begin_busy_input("You>")
258
+ end
259
+
196
260
  def steering_supported?
197
261
  @client.respond_to?(:supports_in_flight_steer?) && @client.supports_in_flight_steer?
198
262
  end
@@ -7,7 +7,7 @@ module Kward
7
7
  private
8
8
 
9
9
  def prompt_templates
10
- @prompt_templates ||= ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES)
10
+ @prompt_templates ||= ConfigFiles.prompt_templates(reserved_commands: builtin_slash_command_names)
11
11
  end
12
12
 
13
13
  def plugin_registry
@@ -22,6 +22,14 @@ module Kward
22
22
  plugin_registry.command_for(command)
23
23
  end
24
24
 
25
+ def interactive_commands
26
+ plugin_registry.interactive_commands
27
+ end
28
+
29
+ def interactive_command_for(command)
30
+ plugin_registry.interactive_command_for(command)
31
+ end
32
+
25
33
  def reload_plugins(conversation)
26
34
  @plugin_registry = PluginRegistry.load(reserved_commands: reserved_slash_command_names)
27
35
  conversation.plugin_registry = @plugin_registry if conversation.respond_to?(:plugin_registry=)
@@ -30,7 +38,34 @@ module Kward
30
38
  end
31
39
 
32
40
  def reserved_slash_command_names
33
- BUILTIN_SLASH_COMMAND_NAMES + prompt_templates.map(&:command)
41
+ builtin_slash_command_names + prompt_templates.map(&:command)
42
+ end
43
+
44
+ def run_interactive_command(name, argument, agent)
45
+ command = interactive_command_for(name)
46
+ return [false, nil] unless command
47
+ return [false, nil] unless prompt_interface? && @prompt.respond_to?(:start_interactive)
48
+
49
+ context = plugin_context(agent.conversation, argument)
50
+ controller = @prompt.start_interactive(title: "/#{name}", rows: command.rows, fps: command.fps)
51
+ command.handler.call(controller, context)
52
+ run_interactive_loop
53
+ [true, nil]
54
+ rescue StandardError => e
55
+ @prompt.finish_interactive if @prompt.respond_to?(:finish_interactive)
56
+ runtime_output("Interactive command /#{name} error: #{e.message}")
57
+ [true, nil]
58
+ end
59
+
60
+ def run_interactive_loop
61
+ loop do
62
+ result = @prompt.poll_input
63
+ if result == :interactive_exited || @prompt.interactive_exited?
64
+ @prompt.finish_interactive
65
+ break
66
+ end
67
+ sleep 0.01
68
+ end
34
69
  end
35
70
 
36
71
  def slash_command_entries
@@ -42,15 +77,30 @@ module Kward
42
77
  }
43
78
  end
44
79
  plugin_entries = plugin_commands.map(&:entry)
45
- BUILTIN_SLASH_COMMANDS + prompt_entries + plugin_entries
80
+ interactive_entries = interactive_commands.map(&:entry)
81
+ builtin_slash_commands + prompt_entries + plugin_entries + interactive_entries
46
82
  end
47
83
 
48
84
  def prompt_template_for(command)
49
85
  prompt_templates.find { |template| template.command == command }
50
86
  end
51
87
 
88
+ def builtin_slash_commands
89
+ return BUILTIN_SLASH_COMMANDS if experimental_workers_enabled?
90
+
91
+ BUILTIN_SLASH_COMMANDS.reject { |command| command[:name] == "workers" }
92
+ end
93
+
94
+ def builtin_slash_command_names
95
+ builtin_slash_commands.map { |command| command[:name] }
96
+ end
97
+
98
+ def experimental_workers_enabled?
99
+ @experimental_workers == true
100
+ end
101
+
52
102
  def expand_prompt_template(input)
53
- PromptCommands.expand(input, templates: prompt_templates, reserved_commands: BUILTIN_SLASH_COMMAND_NAMES)
103
+ PromptCommands.expand(input, templates: prompt_templates, reserved_commands: builtin_slash_command_names)
54
104
  end
55
105
 
56
106
  def run_plugin_command(name, argument, agent)
@@ -23,7 +23,21 @@ module Kward
23
23
  busy_help: ConfigFiles.composer_busy_help?,
24
24
  attachment_badges: method(:composer_attachment_badges),
25
25
  attachment_parser: method(:composer_attachment_parser),
26
- banner_message: Kward::PromptInterface::BANNER_MESSAGE
26
+ banner_message: Kward::PromptInterface::BANNER_MESSAGE,
27
+ tab_keybindings: ConfigFiles.composer_tab_keybindings,
28
+ prompt_history: PromptHistory.new(cwd: current_workspace_root),
29
+ editor_mode: ConfigFiles.editor_mode,
30
+ editor_mode_source: -> { ConfigFiles.editor_mode },
31
+ editor_auto_indent: ConfigFiles.editor_auto_indent?,
32
+ editor_auto_indent_source: -> { ConfigFiles.editor_auto_indent? },
33
+ editor_auto_close_pairs: ConfigFiles.editor_auto_close_pairs?,
34
+ editor_auto_close_pairs_source: -> { ConfigFiles.editor_auto_close_pairs? },
35
+ editor_soft_wrap: ConfigFiles.editor_soft_wrap?,
36
+ editor_soft_wrap_source: -> { ConfigFiles.editor_soft_wrap? },
37
+ editor_bar_cursor: ConfigFiles.editor_bar_cursor?,
38
+ editor_bar_cursor_source: -> { ConfigFiles.editor_bar_cursor? },
39
+ editor_line_numbers: ConfigFiles.editor_line_numbers,
40
+ editor_line_numbers_source: -> { ConfigFiles.editor_line_numbers }
27
41
  )
28
42
  if @prompt.method(:start).parameters.any? { |kind, name| [:key, :keyreq].include?(kind) && name == :render }
29
43
  @prompt.start(render: false)
@@ -149,6 +163,8 @@ module Kward
149
163
  reasoning = "n/a" unless ModelInfo.reasoning_supported?(provider, model) && !reasoning.to_s.empty?
150
164
  text = "#{provider} #{model} · #{reasoning}"
151
165
  parts = []
166
+ git = composer_git_branch_text
167
+ parts << git if git
152
168
  diff = composer_session_diff_text
153
169
  parts << diff if diff
154
170
  usage = composer_context_usage(provider, model)
@@ -165,6 +181,21 @@ module Kward
165
181
  "#{additions}|#{deletions}"
166
182
  end
167
183
 
184
+ def composer_git_branch_text
185
+ git_root = startup_git_root(current_workspace_root)
186
+ return nil if git_root.to_s.empty?
187
+
188
+ branch = startup_git_output(%w[git branch --show-current], root: git_root)
189
+ branch = startup_git_output(%w[git rev-parse --short HEAD], root: git_root) if branch.empty?
190
+ branch = "unknown" if branch.empty?
191
+ color = composer_git_dirty?(git_root) ? :yellow : nil
192
+ ANSI.colorize(branch, color, enabled: @color_enabled)
193
+ end
194
+
195
+ def composer_git_dirty?(git_root)
196
+ !startup_git_output(%w[git status --porcelain --untracked-files=normal], root: git_root).empty?
197
+ end
198
+
168
199
  def composer_context_percent_text(percent)
169
200
  value = percent.round
170
201
  color = if value >= 85