kward 0.82.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -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/agent-tools.md +1 -0
  9. data/doc/api.md +4 -0
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +82 -20
  12. data/doc/editor.md +47 -22
  13. data/doc/files.md +12 -7
  14. data/doc/getting-started.md +3 -0
  15. data/doc/pan.md +19 -15
  16. data/doc/permissions.md +1 -0
  17. data/doc/platform-support.md +48 -0
  18. data/doc/releasing.md +12 -4
  19. data/doc/rpc.md +2 -2
  20. data/doc/sandboxing.md +5 -1
  21. data/doc/security.md +4 -3
  22. data/doc/shell.md +101 -60
  23. data/doc/tabs.md +3 -0
  24. data/doc/troubleshooting.md +12 -2
  25. data/doc/usage.md +7 -6
  26. data/kward.gemspec +5 -4
  27. data/lib/kward/agent.rb +7 -3
  28. data/lib/kward/ansi.rb +110 -10
  29. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  30. data/lib/kward/cli/auth_commands.rb +34 -13
  31. data/lib/kward/cli/commands.rb +83 -62
  32. data/lib/kward/cli/doctor.rb +39 -17
  33. data/lib/kward/cli/hook_commands.rb +22 -12
  34. data/lib/kward/cli/interactive_turn.rb +144 -21
  35. data/lib/kward/cli/project_skills_commands.rb +8 -4
  36. data/lib/kward/cli/prompt_interface.rb +27 -0
  37. data/lib/kward/cli/rendering.rb +15 -9
  38. data/lib/kward/cli/runtime_helpers.rb +260 -55
  39. data/lib/kward/cli/slash_commands.rb +21 -12
  40. data/lib/kward/cli/tabs.rb +91 -13
  41. data/lib/kward/cli/tool_summaries.rb +14 -0
  42. data/lib/kward/cli.rb +55 -7
  43. data/lib/kward/cli_transcript_formatter.rb +11 -4
  44. data/lib/kward/compaction/token_estimator.rb +12 -6
  45. data/lib/kward/config_files.rb +94 -63
  46. data/lib/kward/detached_run.rb +44 -0
  47. data/lib/kward/editor_prompt.rb +46 -0
  48. data/lib/kward/editor_prompt_session.rb +28 -0
  49. data/lib/kward/interactive_pty_runner.rb +102 -28
  50. data/lib/kward/{ekwsh.rb → kwsh.rb} +103 -24
  51. data/lib/kward/kwshrc.rb +233 -0
  52. data/lib/kward/markdown_code_block.rb +136 -0
  53. data/lib/kward/model/client.rb +35 -39
  54. data/lib/kward/model/model_info.rb +3 -2
  55. data/lib/kward/model/payloads.rb +0 -2
  56. data/lib/kward/model/provider_catalog.rb +5 -0
  57. data/lib/kward/model/stream_parser.rb +20 -4
  58. data/lib/kward/pan/index.html.erb +3 -3
  59. data/lib/kward/pan/server.rb +23 -3
  60. data/lib/kward/persistent_shell_session.rb +843 -0
  61. data/lib/kward/project_files.rb +19 -6
  62. data/lib/kward/prompt_interface/composer_renderer.rb +45 -41
  63. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  64. data/lib/kward/prompt_interface/editor/auto_indent.rb +27 -22
  65. data/lib/kward/prompt_interface/editor/controller.rb +99 -33
  66. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  67. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  68. data/lib/kward/prompt_interface/editor/modes/vibe.rb +298 -47
  69. data/lib/kward/prompt_interface/editor/renderer.rb +109 -6
  70. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  71. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/state.rb +21 -10
  73. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  74. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  75. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  76. data/lib/kward/prompt_interface/file_overlay.rb +92 -22
  77. data/lib/kward/prompt_interface/key_handler.rb +75 -0
  78. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  79. data/lib/kward/prompt_interface/project_browser.rb +443 -17
  80. data/lib/kward/prompt_interface/runtime_state.rb +55 -2
  81. data/lib/kward/prompt_interface/screen.rb +2 -2
  82. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  83. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  84. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  85. data/lib/kward/prompt_interface.rb +169 -18
  86. data/lib/kward/prompts/commands.rb +2 -1
  87. data/lib/kward/prompts.rb +1 -1
  88. data/lib/kward/pty_output_sink.rb +47 -0
  89. data/lib/kward/rpc/auth_manager.rb +1 -1
  90. data/lib/kward/rpc/server.rb +2 -1
  91. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  92. data/lib/kward/scratchpad_languages.rb +74 -0
  93. data/lib/kward/scratchpad_runner.rb +155 -29
  94. data/lib/kward/shell_prompt.rb +52 -0
  95. data/lib/kward/shell_prompt_session.rb +58 -0
  96. data/lib/kward/terminal_keys.rb +13 -0
  97. data/lib/kward/terminal_text.rb +121 -0
  98. data/lib/kward/text_matcher.rb +18 -0
  99. data/lib/kward/tools/open_editor.rb +41 -0
  100. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  101. data/lib/kward/tools/registry.rb +75 -8
  102. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  103. data/lib/kward/tools/run_shell_command.rb +24 -6
  104. data/lib/kward/tools/tool_call.rb +2 -1
  105. data/lib/kward/version.rb +1 -1
  106. data/templates/default/fulldoc/html/css/kward.css +0 -125
  107. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  108. data/templates/default/fulldoc/html/setup.rb +1 -1
  109. data/templates/default/kward_navigation.rb +1 -0
  110. data/templates/default/layout/html/footer.erb +10 -0
  111. data/templates/default/layout/html/headers.erb +23 -0
  112. data/templates/default/layout/html/layout.erb +6 -18
  113. data/templates/default/layout/html/setup.rb +41 -2
  114. metadata +44 -8
  115. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
@@ -6,9 +6,70 @@ module Kward
6
6
  module InteractiveTurn
7
7
  private
8
8
 
9
- def run_interactive_turn(agent, input, display_input: nil)
10
- prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
11
- return run_blocking_interactive_turn(agent, input, display_input: display_input) unless prompt_interface?
9
+ def run_editor_prompt_turn(action, agent)
10
+ unless agent.respond_to?(:tool_registry) && agent.tool_registry.respond_to?(:for_editor_prompt)
11
+ runtime_output("Editor prompts are unavailable in this tab.")
12
+ return []
13
+ end
14
+ unless @prompt.respond_to?(:editor_prompt_context)
15
+ runtime_output("Editor prompts are unavailable in this prompt.")
16
+ return []
17
+ end
18
+
19
+ context = @prompt.editor_prompt_context
20
+ unless context
21
+ runtime_output("Open a Modern or Vibe editor buffer before prompting the agent.")
22
+ return []
23
+ end
24
+
25
+ unless @prompt.suspend_editor_for_agent
26
+ runtime_output("The editor is busy.")
27
+ return []
28
+ end
29
+
30
+ session = EditorPromptSession.new(context)
31
+ registry = agent.tool_registry.for_editor_prompt(session)
32
+ editor_agent = build_editor_prompt_agent(agent, context, registry)
33
+ instruction = action[:instruction] || action["instruction"]
34
+ run_interactive_turn(
35
+ editor_agent,
36
+ EditorPrompt.input(instruction, context),
37
+ tool_registry: registry,
38
+ editor_prompt_session: session,
39
+ suppress_transcript: true
40
+ )
41
+ rescue StandardError => e
42
+ @prompt.resume_editor_for_agent(status: "Agent request failed") if @prompt.respond_to?(:resume_editor_for_agent)
43
+ runtime_output("Error: #{e.message}")
44
+ []
45
+ end
46
+
47
+ def build_editor_prompt_agent(active_agent, context, registry)
48
+ active_conversation = active_agent.conversation
49
+ config = safely_read_config.to_h
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)
54
+ conversation = Conversation.new(
55
+ system_message: EditorPrompt.system_message,
56
+ workspace_root: context[:workspace_root] || active_conversation.workspace_root,
57
+ provider: provider,
58
+ model: model,
59
+ reasoning_effort: reasoning
60
+ )
61
+ Agent.new(
62
+ client: @client,
63
+ tool_registry: registry,
64
+ conversation: conversation,
65
+ strict_provider: !configured_provider.nil?,
66
+ warning_sink: ConfigFiles.warning_sink
67
+ )
68
+ end
69
+
70
+ def run_interactive_turn(agent, input, display_input: nil, tool_registry: nil, editor_prompt_session: nil, suppress_transcript: false, busy_label: "You>", on_complete: nil)
71
+ prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation) && !suppress_transcript
72
+ return run_blocking_interactive_turn(agent, input, display_input: display_input, tool_registry: tool_registry, suppress_transcript: suppress_transcript, on_complete: on_complete) unless prompt_interface?
12
73
 
13
74
  queued_inputs = []
14
75
  cancellation = Cancellation.new
@@ -17,19 +78,21 @@ module Kward
17
78
  event_queue = Queue.new
18
79
  stream_state = {
19
80
  streamed: false,
81
+ assistant_arrived: false,
20
82
  last_flush: monotonic_now,
21
83
  stream_block_open: false,
22
84
  markdown_streams: {},
85
+ tool_elapsed_ms: {},
23
86
  defer_assistant_streaming: defer_assistant_streaming?(agent)
24
87
  }
25
88
  markdown_chunks = []
26
89
  answer = nil
27
90
  error = nil
28
- @prompt.begin_busy_input("You>") if @prompt.respond_to?(:begin_busy_input)
29
- print_user_transcript(input, display_input: display_input)
91
+ @prompt.begin_busy_input(busy_label) if @prompt.respond_to?(:begin_busy_input)
92
+ print_user_transcript(input, display_input: display_input) unless suppress_transcript
30
93
 
31
94
  worker = Thread.new do
32
- options = agent_display_options(display_input)
95
+ options = agent_display_options(display_input, tool_registry: tool_registry)
33
96
  options[:cancellation] = cancellation
34
97
  options[:steering] = steering if steering
35
98
  answer = agent.ask(input, **options) do |event|
@@ -52,7 +115,7 @@ module Kward
52
115
  cancellation.cancel!
53
116
  worker.raise(Cancellation::CancelledError, "cancelled") if worker.alive?
54
117
  end
55
- if busy_replacement_agent?
118
+ if busy_replacement_agent? || suppress_transcript
56
119
  discard_interactive_events(event_queue, markdown_chunks, stream_state)
57
120
  else
58
121
  drain_interactive_events(event_queue, markdown_chunks, stream_state, agent)
@@ -64,19 +127,49 @@ module Kward
64
127
  error ||= e
65
128
  end
66
129
  drain_busy_input(queued_inputs, nil) unless cancelled
67
- if busy_replacement_agent?
130
+ if busy_replacement_agent? || suppress_transcript
68
131
  discard_interactive_events(event_queue, markdown_chunks, stream_state, force: true)
69
132
  else
70
- drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
133
+ drain_remaining_interactive_events(event_queue, markdown_chunks, stream_state, agent)
71
134
  end
135
+ completed = !cancelled && !busy_replacement_agent? && !error
136
+ on_complete&.call(successful: completed, cancelled: cancelled, error: error, answer: answer)
72
137
  raise error if error && !error.is_a?(Cancellation::CancelledError) && !busy_replacement_agent?
73
138
 
74
- @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?
75
- persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
76
- auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && queued_inputs.empty? && !cancelled
139
+ @prompt.commit_editor_prompt(editor_prompt_session) if editor_prompt_session && completed
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?
141
+ persist_memory_state(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript
142
+ auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript && queued_inputs.empty? && !cancelled
77
143
  queued_inputs
78
144
  ensure
79
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
156
+ if editor_prompt_session && @prompt.respond_to?(:resume_editor_for_agent)
157
+ status = if cancelled
158
+ "Agent request cancelled"
159
+ elsif error
160
+ "Agent request failed"
161
+ elsif editor_prompt_session && !editor_prompt_session.changed? && !answer.to_s.strip.empty?
162
+ editor_prompt_answer_status(answer)
163
+ end
164
+ @prompt.resume_editor_for_agent(status: status)
165
+ end
166
+ end
167
+
168
+ def editor_prompt_answer_status(answer)
169
+ text = answer.to_s.gsub(/\s+/, " ").strip
170
+ text = text[0, 120].to_s
171
+ text += "…" if answer.to_s.gsub(/\s+/, " ").strip.length > text.length
172
+ "Agent: #{text}"
80
173
  end
81
174
 
82
175
  def drain_interactive_events(event_queue, markdown_chunks, stream_state, agent = nil, force: false)
@@ -95,6 +188,14 @@ module Kward
95
188
  flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: force)
96
189
  end
97
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
+
98
199
  def discard_interactive_events(event_queue, markdown_chunks, stream_state, force: false)
99
200
  drained = 0
100
201
  loop do
@@ -114,12 +215,19 @@ module Kward
114
215
  case event
115
216
  when Events::ReasoningDelta
116
217
  stream_state[:streamed] = true
218
+ update_busy_activity("reasoning")
117
219
  append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
118
220
  when Events::ReasoningBoundary
119
221
  stream_state[:streamed] = true
120
222
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
223
+ update_busy_activity("thinking")
121
224
  when Events::AssistantDelta
122
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")
123
231
  append_markdown_delta(markdown_chunks, "Assistant", event.delta)
124
232
  when Events::Steering
125
233
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
@@ -129,18 +237,30 @@ module Kward
129
237
  when Events::Retry
130
238
  stream_state[:streamed] = true
131
239
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
240
+ update_busy_activity("retrying")
132
241
  print_retry(event)
133
242
  when Events::ToolCall
134
243
  stream_state[:streamed] = true
135
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
136
249
  when Events::ToolResult
137
250
  stream_state[:streamed] = true
138
251
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
139
252
  update_session_diff(event.content, tool_call: event.tool_call)
140
- 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")
141
257
  end
142
258
  end
143
259
 
260
+ def update_busy_activity(activity)
261
+ @prompt.update_busy_activity(activity) if @prompt.respond_to?(:update_busy_activity)
262
+ end
263
+
144
264
  def flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: false)
145
265
  if force
146
266
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
@@ -261,21 +381,24 @@ module Kward
261
381
  ModelInfo.reasoning_supported?(current_model_provider, model)
262
382
  end
263
383
 
264
- def run_blocking_interactive_turn(agent, input, display_input: nil)
384
+ def run_blocking_interactive_turn(agent, input, display_input: nil, tool_registry: nil, suppress_transcript: false, on_complete: nil)
265
385
  streamed = false
266
386
  markdown_chunks = []
267
- answer = agent.ask(input, **agent_display_options(display_input)) do |event|
268
- streamed = true if render_blocking_turn_event(event, markdown_chunks, tool_line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
387
+ answer = agent.ask(input, **agent_display_options(display_input, tool_registry: tool_registry)) do |event|
388
+ streamed = true if !suppress_transcript && render_blocking_turn_event(event, markdown_chunks, tool_line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
269
389
  end
270
390
  flush_markdown_deltas(markdown_chunks) if streamed
271
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless streamed || answer.to_s.empty?
272
- persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
273
- auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation)
391
+ @prompt.say("\n#{colored(transcript_marker("Assistant"), :activity, :bold)} #{render_markdown_transcript(answer)}\n") unless suppress_transcript || streamed || answer.to_s.empty?
392
+ persist_memory_state(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript
393
+ auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript
394
+ on_complete&.call(successful: true, cancelled: false, error: nil, answer: answer)
274
395
  []
275
396
  end
276
397
 
277
- def agent_display_options(display_input)
278
- display_input.nil? ? {} : { display_input: display_input }
398
+ def agent_display_options(display_input, tool_registry: nil)
399
+ options = display_input.nil? ? {} : { display_input: display_input }
400
+ options[:tool_registry] = tool_registry if tool_registry
401
+ options
279
402
  end
280
403
 
281
404
  end
@@ -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