kward 0.83.0 → 0.84.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/api.md +4 -0
  9. data/doc/composer.md +1 -1
  10. data/doc/configuration.md +68 -21
  11. data/doc/editor.md +28 -13
  12. data/doc/files.md +8 -4
  13. data/doc/getting-started.md +3 -0
  14. data/doc/pan.md +19 -15
  15. data/doc/platform-support.md +48 -0
  16. data/doc/security.md +3 -2
  17. data/doc/shell.md +62 -45
  18. data/doc/troubleshooting.md +12 -2
  19. data/doc/usage.md +5 -5
  20. data/kward.gemspec +5 -4
  21. data/lib/kward/agent.rb +6 -3
  22. data/lib/kward/ansi.rb +110 -10
  23. data/lib/kward/cli/auth_commands.rb +34 -13
  24. data/lib/kward/cli/commands.rb +83 -62
  25. data/lib/kward/cli/doctor.rb +39 -17
  26. data/lib/kward/cli/hook_commands.rb +22 -12
  27. data/lib/kward/cli/interactive_turn.rb +48 -7
  28. data/lib/kward/cli/project_skills_commands.rb +8 -4
  29. data/lib/kward/cli/prompt_interface.rb +27 -0
  30. data/lib/kward/cli/rendering.rb +15 -9
  31. data/lib/kward/cli/runtime_helpers.rb +118 -55
  32. data/lib/kward/cli/slash_commands.rb +12 -14
  33. data/lib/kward/cli/tabs.rb +83 -12
  34. data/lib/kward/cli/tool_summaries.rb +14 -0
  35. data/lib/kward/cli.rb +45 -7
  36. data/lib/kward/cli_transcript_formatter.rb +11 -4
  37. data/lib/kward/config_files.rb +82 -68
  38. data/lib/kward/detached_run.rb +44 -0
  39. data/lib/kward/interactive_pty_runner.rb +102 -28
  40. data/lib/kward/{ekwsh.rb → kwsh.rb} +35 -16
  41. data/lib/kward/kwshrc.rb +233 -0
  42. data/lib/kward/markdown_code_block.rb +136 -0
  43. data/lib/kward/model/client.rb +34 -22
  44. data/lib/kward/model/provider_catalog.rb +5 -0
  45. data/lib/kward/model/stream_parser.rb +20 -4
  46. data/lib/kward/pan/index.html.erb +3 -3
  47. data/lib/kward/pan/server.rb +23 -3
  48. data/lib/kward/persistent_shell_session.rb +119 -26
  49. data/lib/kward/project_files.rb +2 -2
  50. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  51. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  52. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  53. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  54. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  55. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  56. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  57. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  58. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  59. data/lib/kward/prompt_interface/editor/state.rb +10 -10
  60. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  61. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  62. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  63. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  64. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  65. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  66. data/lib/kward/prompt_interface/runtime_state.rb +50 -1
  67. data/lib/kward/prompt_interface/screen.rb +2 -2
  68. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  69. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  70. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  71. data/lib/kward/prompt_interface.rb +93 -18
  72. data/lib/kward/prompts/commands.rb +1 -1
  73. data/lib/kward/prompts.rb +1 -1
  74. data/lib/kward/pty_output_sink.rb +47 -0
  75. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  76. data/lib/kward/scratchpad_languages.rb +74 -0
  77. data/lib/kward/scratchpad_runner.rb +155 -29
  78. data/lib/kward/shell_prompt.rb +2 -0
  79. data/lib/kward/terminal_keys.rb +12 -0
  80. data/lib/kward/terminal_text.rb +121 -0
  81. data/lib/kward/text_matcher.rb +18 -0
  82. data/lib/kward/tools/open_editor.rb +41 -0
  83. data/lib/kward/tools/registry.rb +13 -4
  84. data/lib/kward/tools/tool_call.rb +2 -1
  85. data/lib/kward/version.rb +1 -1
  86. data/templates/default/fulldoc/html/css/kward.css +0 -125
  87. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  88. data/templates/default/fulldoc/html/setup.rb +1 -1
  89. data/templates/default/kward_navigation.rb +1 -0
  90. data/templates/default/layout/html/footer.erb +10 -0
  91. data/templates/default/layout/html/headers.erb +23 -0
  92. data/templates/default/layout/html/layout.erb +6 -18
  93. data/templates/default/layout/html/setup.rb +41 -2
  94. metadata +36 -8
  95. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
@@ -47,9 +47,10 @@ module Kward
47
47
  def build_editor_prompt_agent(active_agent, context, registry)
48
48
  active_conversation = active_agent.conversation
49
49
  config = safely_read_config.to_h
50
- provider = active_conversation.provider || current_model_provider
51
- model = ConfigFiles.editor_agent_model(config) || active_conversation.model || current_model_id
52
- reasoning = ConfigFiles.editor_agent_reasoning_effort(config) || active_conversation.reasoning_effort || current_reasoning_effort
50
+ configured_provider = ConfigFiles.editor_agent_provider(config)
51
+ provider = configured_provider || active_conversation.provider || current_model_provider
52
+ model = ConfigFiles.editor_agent_model(config) || (configured_provider ? nil : active_conversation.model || current_model_id)
53
+ reasoning = ConfigFiles.editor_agent_reasoning_effort(config) || (configured_provider ? nil : active_conversation.reasoning_effort || current_reasoning_effort)
53
54
  conversation = Conversation.new(
54
55
  system_message: EditorPrompt.system_message,
55
56
  workspace_root: context[:workspace_root] || active_conversation.workspace_root,
@@ -61,6 +62,7 @@ module Kward
61
62
  client: @client,
62
63
  tool_registry: registry,
63
64
  conversation: conversation,
65
+ strict_provider: !configured_provider.nil?,
64
66
  warning_sink: ConfigFiles.warning_sink
65
67
  )
66
68
  end
@@ -76,9 +78,11 @@ module Kward
76
78
  event_queue = Queue.new
77
79
  stream_state = {
78
80
  streamed: false,
81
+ assistant_arrived: false,
79
82
  last_flush: monotonic_now,
80
83
  stream_block_open: false,
81
84
  markdown_streams: {},
85
+ tool_elapsed_ms: {},
82
86
  defer_assistant_streaming: defer_assistant_streaming?(agent)
83
87
  }
84
88
  markdown_chunks = []
@@ -126,19 +130,29 @@ module Kward
126
130
  if busy_replacement_agent? || suppress_transcript
127
131
  discard_interactive_events(event_queue, markdown_chunks, stream_state, force: true)
128
132
  else
129
- drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
133
+ drain_remaining_interactive_events(event_queue, markdown_chunks, stream_state, agent)
130
134
  end
131
135
  completed = !cancelled && !busy_replacement_agent? && !error
132
136
  on_complete&.call(successful: completed, cancelled: cancelled, error: error, answer: answer)
133
137
  raise error if error && !error.is_a?(Cancellation::CancelledError) && !busy_replacement_agent?
134
138
 
135
139
  @prompt.commit_editor_prompt(editor_prompt_session) if editor_prompt_session && completed
136
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless suppress_transcript || cancelled || busy_replacement_agent? || stream_state[:streamed] || answer.to_s.empty?
140
+ @prompt.say("\n#{colored(transcript_marker("Assistant"), :activity, :bold)} #{render_markdown_transcript(answer)}\n") unless suppress_transcript || cancelled || busy_replacement_agent? || stream_state[:streamed] || answer.to_s.empty?
137
141
  persist_memory_state(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript
138
142
  auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript && queued_inputs.empty? && !cancelled
139
143
  queued_inputs
140
144
  ensure
141
145
  @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
146
+ if @prompt.respond_to?(:show_completion) && !busy_replacement_agent?
147
+ completion = if cancelled
148
+ :cancelled
149
+ elsif error
150
+ :failed
151
+ elsif completed
152
+ :success
153
+ end
154
+ @prompt.show_completion(completion) if completion
155
+ end
142
156
  if editor_prompt_session && @prompt.respond_to?(:resume_editor_for_agent)
143
157
  status = if cancelled
144
158
  "Agent request cancelled"
@@ -174,6 +188,14 @@ module Kward
174
188
  flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: force)
175
189
  end
176
190
 
191
+ def drain_remaining_interactive_events(event_queue, markdown_chunks, stream_state, agent)
192
+ until event_queue.empty?
193
+ drain_interactive_events(event_queue, markdown_chunks, stream_state, agent)
194
+ sleep STREAM_RENDER_INTERVAL unless event_queue.empty?
195
+ end
196
+ drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
197
+ end
198
+
177
199
  def discard_interactive_events(event_queue, markdown_chunks, stream_state, force: false)
178
200
  drained = 0
179
201
  loop do
@@ -193,12 +215,19 @@ module Kward
193
215
  case event
194
216
  when Events::ReasoningDelta
195
217
  stream_state[:streamed] = true
218
+ update_busy_activity("reasoning")
196
219
  append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
197
220
  when Events::ReasoningBoundary
198
221
  stream_state[:streamed] = true
199
222
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
223
+ update_busy_activity("thinking")
200
224
  when Events::AssistantDelta
201
225
  stream_state[:streamed] = true
226
+ unless stream_state[:assistant_arrived]
227
+ @prompt.show_response_arrival if @prompt.respond_to?(:show_response_arrival)
228
+ stream_state[:assistant_arrived] = true
229
+ end
230
+ update_busy_activity("responding")
202
231
  append_markdown_delta(markdown_chunks, "Assistant", event.delta)
203
232
  when Events::Steering
204
233
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
@@ -208,18 +237,30 @@ module Kward
208
237
  when Events::Retry
209
238
  stream_state[:streamed] = true
210
239
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
240
+ update_busy_activity("retrying")
211
241
  print_retry(event)
212
242
  when Events::ToolCall
213
243
  stream_state[:streamed] = true
214
244
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
245
+ update_busy_activity(tool_activity(event.tool_call))
246
+ when Events::ToolUpdate
247
+ key = tool_call_id(event.tool_call) || event.tool_call.object_id
248
+ stream_state[:tool_elapsed_ms][key] = event.elapsed_ms
215
249
  when Events::ToolResult
216
250
  stream_state[:streamed] = true
217
251
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
218
252
  update_session_diff(event.content, tool_call: event.tool_call)
219
- print_tool_result(event.tool_call, event.content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
253
+ key = tool_call_id(event.tool_call) || event.tool_call.object_id
254
+ elapsed_ms = stream_state[:tool_elapsed_ms].delete(key)
255
+ print_tool_result(event.tool_call, event.content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT, elapsed_ms: elapsed_ms)
256
+ update_busy_activity("thinking")
220
257
  end
221
258
  end
222
259
 
260
+ def update_busy_activity(activity)
261
+ @prompt.update_busy_activity(activity) if @prompt.respond_to?(:update_busy_activity)
262
+ end
263
+
223
264
  def flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: false)
224
265
  if force
225
266
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
@@ -347,7 +388,7 @@ module Kward
347
388
  streamed = true if !suppress_transcript && render_blocking_turn_event(event, markdown_chunks, tool_line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
348
389
  end
349
390
  flush_markdown_deltas(markdown_chunks) if streamed
350
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless suppress_transcript || streamed || answer.to_s.empty?
391
+ @prompt.say("\n#{colored(transcript_marker("Assistant"), :activity, :bold)} #{render_markdown_transcript(answer)}\n") unless suppress_transcript || streamed || answer.to_s.empty?
351
392
  persist_memory_state(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript
352
393
  auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript
353
394
  on_complete&.call(successful: true, cancelled: false, error: nil, answer: answer)
@@ -7,20 +7,20 @@ module Kward
7
7
  argument = Array(arguments).join(" ").strip.downcase
8
8
  _workspace_root, candidates, coordinator = project_skill_trust_coordinator
9
9
  if candidates.empty? && argument != "untrust"
10
- @prompt.say("No project skills found in the current workspace.")
10
+ project_skills_cli_output("No project skills found in the current workspace.")
11
11
  return
12
12
  end
13
13
 
14
14
  case argument
15
15
  when "", "status"
16
16
  lines = candidates.empty? ? ["No project skills found in the current workspace."] : candidates.map { |candidate| "#{relative_workspace_path(candidate.path)}: #{coordinator.decision(candidate) || "needs review"}" }
17
- @prompt.say(lines.join("\n"))
17
+ project_skills_cli_output(lines.join("\n"))
18
18
  when "trust"
19
19
  coordinator.record!(candidates, "allow")
20
- @prompt.say("Project skills trusted for the current skill snapshots.")
20
+ project_skills_cli_output("Project skills trusted for the current skill snapshots.")
21
21
  when "untrust"
22
22
  coordinator.remove_workspace!
23
- @prompt.say("Project skill trust removed for this workspace.")
23
+ project_skills_cli_output("Project skill trust removed for this workspace.")
24
24
  when "review"
25
25
  review_project_skills(candidates)
26
26
  else
@@ -43,6 +43,10 @@ module Kward
43
43
 
44
44
  private
45
45
 
46
+ def project_skills_cli_output(message)
47
+ @prompt.say("#{colored("Project skills", :green, :bold)}\n\n#{message}")
48
+ end
49
+
46
50
  def project_skill_trust_coordinator
47
51
  workspace_root = current_workspace_root
48
52
  candidates = ConfigFiles.project_skill_candidates(workspace_root: workspace_root)
@@ -45,6 +45,7 @@ module Kward
45
45
  editor_line_numbers_source: -> { ConfigFiles.editor_line_numbers },
46
46
  diff_view: ConfigFiles.diff_view,
47
47
  diff_view_source: -> { ConfigFiles.diff_view },
48
+ editor_runners_source: -> { ConfigFiles.editor_runners },
48
49
  redraw_handler: method(:redraw_interactive_prompt)
49
50
  )
50
51
  if @prompt.method(:start).parameters.any? { |kind, name| [:key, :keyreq].include?(kind) && name == :render }
@@ -99,6 +100,13 @@ module Kward
99
100
  @prompt.respond_to?(:start_stream_block) && @prompt.respond_to?(:write_delta)
100
101
  end
101
102
 
103
+ def open_editor_for_agent(path, base_dir:, allow_new:)
104
+ return false unless prompt_interface?
105
+ return false unless @prompt.respond_to?(:edit_file)
106
+
107
+ @prompt.edit_file(path, base_dir: base_dir, allow_new: allow_new)
108
+ end
109
+
102
110
  def emit_warning(message)
103
111
  sink = ConfigFiles.warning_sink
104
112
  sink ? sink.call(message) : warn(message)
@@ -129,6 +137,7 @@ module Kward
129
137
  [
130
138
  startup_status_line(refresh_update_check: refresh_update_check),
131
139
  *startup_update_notice_lines,
140
+ *startup_authentication_notice_lines,
132
141
  "",
133
142
  startup_info_line("Workspace", startup_workspace_label),
134
143
  startup_info_line("Branch", startup_branch_value),
@@ -198,6 +207,24 @@ module Kward
198
207
  @startup_update_notice = @startup_update_check.notice(refresh: refresh)
199
208
  end
200
209
 
210
+ def startup_authentication_notice_lines
211
+ return [] unless startup_authentication_required?
212
+
213
+ [
214
+ ANSI.colorize(" No model provider is connected.", :yellow, enabled: @color_enabled),
215
+ " Run /login to sign in, or /model to configure a local server."
216
+ ]
217
+ end
218
+
219
+ def startup_authentication_required?
220
+ return false unless @client.is_a?(Client)
221
+ return false if @client.current_provider == "Local"
222
+
223
+ auth_credentials.none? { |credential| credential.fetch(:configured) }
224
+ rescue StandardError
225
+ false
226
+ end
227
+
201
228
  def startup_info_line(label, value)
202
229
  "#{ANSI.colorize(label.ljust(12), :gray, enabled: @color_enabled)}#{ANSI.colorize(value, :cyan, enabled: @color_enabled)}"
203
230
  end
@@ -223,7 +223,7 @@ module Kward
223
223
  # Writes the user transcript output for the terminal CLI flow.
224
224
  def print_user_transcript(input, display_input: nil, attachment_references: nil, image_parts: nil)
225
225
  visible_input = display_input.nil? ? input : display_input
226
- write_prompt_transcript("\n#{colored("You>", :blue, :bold)} #{visible_input}\n")
226
+ write_prompt_transcript("\n#{colored("You>", :blue, :bold)} #{visible_input}\n")
227
227
  print_attachment_badges(input, references: attachment_references)
228
228
  print_pasted_images(input, image_parts: image_parts)
229
229
  end
@@ -347,8 +347,9 @@ module Kward
347
347
  end
348
348
 
349
349
  # Writes the tool result output for the terminal CLI flow.
350
- def print_tool_result(tool_call, content, line_limit: nil)
350
+ def print_tool_result(tool_call, content, line_limit: nil, elapsed_ms: nil)
351
351
  summary = tool_result_summary(tool_call, content)
352
+ summary = tool_summary_with_duration(summary, elapsed_ms)
352
353
  summary = limit_tool_output_lines(summary, line_limit) if line_limit
353
354
  display_summary = tool_summary_display_text(summary)
354
355
  if prompt_interface?
@@ -380,7 +381,7 @@ module Kward
380
381
  return if @stream_block == label
381
382
 
382
383
  puts if @stream_block
383
- print "\n#{colored("#{transcript_label(label)}>", *label_styles(label))} "
384
+ print "\n#{colored(transcript_marker(label), *label_styles(label))} "
384
385
  @stream_block = label
385
386
  end
386
387
 
@@ -408,20 +409,25 @@ module Kward
408
409
  end
409
410
  end
410
411
 
412
+ def transcript_marker(label)
413
+ marker = "#{transcript_label(label)}>"
414
+ label == "Assistant" ? "✦ #{marker}" : marker
415
+ end
416
+
411
417
  def label_styles(label)
412
418
  case label
413
419
  when "Reasoning", "Compaction summary"
414
- [:gray, :bold]
420
+ [:metadata, :bold]
415
421
  when "Assistant", "Kward"
416
- [:green, :bold]
422
+ [:activity, :bold]
417
423
  when "Tool", "Tool output"
418
- [:cyan, :bold]
424
+ [:tool, :bold]
419
425
  when "Tool failed"
420
- [:red, :bold]
426
+ [:failure, :bold]
421
427
  when "Retry"
422
- [:yellow, :bold]
428
+ [:caution, :bold]
423
429
  else
424
- [:gray, :bold]
430
+ [:metadata, :bold]
425
431
  end
426
432
  end
427
433