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,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| %w[workers queue].include?(command[:name]) }
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
@@ -318,7 +318,10 @@ module Kward
318
318
  end
319
319
 
320
320
  def tool_summary_display_text(summary)
321
- summary.to_s.sub("\n", "\n\n")
321
+ text = summary.to_s
322
+ return text if text.start_with?("read_skill:\n")
323
+
324
+ text.sub("\n", "\n\n")
322
325
  end
323
326
 
324
327
  def start_stream_block(label)