kward 0.76.0 → 0.78.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +8 -1
  3. data/CHANGELOG.md +62 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +13 -4
  6. data/doc/agent-tools.md +0 -3
  7. data/doc/api.md +4 -0
  8. data/doc/code-search.md +9 -6
  9. data/doc/composer.md +219 -0
  10. data/doc/configuration.md +114 -27
  11. data/doc/editor.md +18 -2
  12. data/doc/files.md +1 -1
  13. data/doc/getting-started.md +4 -0
  14. data/doc/git.md +2 -2
  15. data/doc/lifecycle-hooks.md +2 -14
  16. data/doc/pan.md +167 -0
  17. data/doc/permissions.md +179 -0
  18. data/doc/plugins.md +58 -0
  19. data/doc/rpc.md +61 -22
  20. data/doc/security.md +184 -0
  21. data/doc/tabs.md +4 -3
  22. data/doc/usage.md +15 -32
  23. data/doc/web-search.md +4 -2
  24. data/lib/kward/ansi.rb +2 -2
  25. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  26. data/lib/kward/auth/github_oauth.rb +3 -3
  27. data/lib/kward/auth/oauth_helpers.rb +4 -2
  28. data/lib/kward/auth/openai_oauth.rb +2 -1
  29. data/lib/kward/cancellation.rb +2 -2
  30. data/lib/kward/cli/commands.rb +1 -3
  31. data/lib/kward/cli/interactive_turn.rb +0 -17
  32. data/lib/kward/cli/plugins.rb +24 -7
  33. data/lib/kward/cli/prompt_interface.rb +1 -0
  34. data/lib/kward/cli/rendering.rb +7 -1
  35. data/lib/kward/cli/runtime_helpers.rb +11 -35
  36. data/lib/kward/cli/sessions.rb +14 -5
  37. data/lib/kward/cli/settings.rb +22 -0
  38. data/lib/kward/cli/slash_commands.rb +1 -561
  39. data/lib/kward/cli/tabs.rb +154 -36
  40. data/lib/kward/cli.rb +30 -26
  41. data/lib/kward/compactor.rb +3 -3
  42. data/lib/kward/config_files.rb +28 -0
  43. data/lib/kward/ekwsh.rb +0 -4
  44. data/lib/kward/hooks/catalog.rb +0 -5
  45. data/lib/kward/hooks/http_handler.rb +2 -1
  46. data/lib/kward/http.rb +18 -0
  47. data/lib/kward/image_attachments.rb +1 -1
  48. data/lib/kward/model/client.rb +33 -11
  49. data/lib/kward/model/payloads.rb +6 -1
  50. data/lib/kward/openrouter_model_cache.rb +2 -1
  51. data/lib/kward/pan/index.html.erb +459 -64
  52. data/lib/kward/pan/kward_logo.png +0 -0
  53. data/lib/kward/pan/server.rb +248 -23
  54. data/lib/kward/permissions/policy.rb +171 -0
  55. data/lib/kward/plugin_registry.rb +71 -1
  56. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  57. data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
  58. data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
  59. data/lib/kward/prompt_interface/layout.rb +0 -4
  60. data/lib/kward/prompt_interface/project_browser.rb +40 -1
  61. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  62. data/lib/kward/prompt_interface/runtime_state.rb +4 -0
  63. data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
  64. data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
  65. data/lib/kward/prompt_interface.rb +20 -1
  66. data/lib/kward/prompts/commands.rb +1 -3
  67. data/lib/kward/prompts.rb +20 -0
  68. data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
  69. data/lib/kward/rpc/server.rb +48 -41
  70. data/lib/kward/rpc/session_manager.rb +76 -25
  71. data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
  72. data/lib/kward/rpc/tool_metadata.rb +23 -0
  73. data/lib/kward/rpc/transcript_normalizer.rb +18 -5
  74. data/lib/kward/session_store.rb +55 -16
  75. data/lib/kward/skills/registry.rb +21 -1
  76. data/lib/kward/starter_pack_installer.rb +3 -1
  77. data/lib/kward/tab_driver.rb +87 -0
  78. data/lib/kward/tab_store.rb +74 -12
  79. data/lib/kward/tools/base.rb +14 -0
  80. data/lib/kward/tools/code_search.rb +8 -2
  81. data/lib/kward/tools/fetch_content.rb +4 -2
  82. data/lib/kward/tools/fetch_raw.rb +3 -1
  83. data/lib/kward/tools/registry.rb +45 -17
  84. data/lib/kward/tools/search/code.rb +47 -13
  85. data/lib/kward/tools/search/web.rb +61 -18
  86. data/lib/kward/tools/search/web_fetch.rb +206 -38
  87. data/lib/kward/tools/tool_call.rb +0 -4
  88. data/lib/kward/tools/web_search.rb +3 -1
  89. data/lib/kward/transcript_export.rb +1 -1
  90. data/lib/kward/update_check.rb +2 -1
  91. data/lib/kward/version.rb +1 -1
  92. data/templates/default/kward_navigation.rb +7 -2
  93. data/templates/default/layout/html/layout.erb +2 -2
  94. data/templates/default/layout/html/setup.rb +1 -0
  95. metadata +11 -12
  96. data/lib/kward/workers/git_guard.rb +0 -93
  97. data/lib/kward/workers/job.rb +0 -99
  98. data/lib/kward/workers/live_view.rb +0 -49
  99. data/lib/kward/workers/manager.rb +0 -328
  100. data/lib/kward/workers/queue_runner.rb +0 -166
  101. data/lib/kward/workers/queue_store.rb +0 -112
  102. data/lib/kward/workers/store.rb +0 -72
  103. data/lib/kward/workers/tool_policy.rb +0 -23
  104. data/lib/kward/workers/worker.rb +0 -82
  105. data/lib/kward/workers/write_lock.rb +0 -38
  106. data/lib/kward/workers.rb +0 -10
@@ -1,3 +1,4 @@
1
+ require "json"
1
2
  require "thread"
2
3
  require_relative "../cancellation"
3
4
 
@@ -10,6 +11,7 @@ module Kward
10
11
  TabRuntime = Struct.new(
11
12
  :session,
12
13
  :agent,
14
+ :driver,
13
15
  :diff,
14
16
  :snapshot,
15
17
  :status,
@@ -58,6 +60,28 @@ module Kward
58
60
  def ask_user_question(questions, cancellation: nil)
59
61
  @cli.send(:ask_tab_user_question, @tab, questions, cancellation: cancellation)
60
62
  end
63
+
64
+ def ask_tool_approval(tool_name:, args:, reason: nil)
65
+ answers = ask_user_question([
66
+ {
67
+ header: "Approval required · #{tool_name.to_s.tr("_", " ").capitalize}",
68
+ question: (["The agent wants to use #{tool_name}.", "Arguments:\n#{JSON.pretty_generate(args.to_h)}", reason].compact).join("\n"),
69
+ options: [
70
+ { label: "Allow once", description: "Run this tool call." },
71
+ { label: "Allow this tool for this session", description: "Run this call and future calls to #{tool_name}." },
72
+ { label: "Deny", description: "Do not run this tool call." }
73
+ ]
74
+ }
75
+ ])
76
+ answer = answers&.first
77
+ return { denied_message: answer[:answer] } if answer&.fetch(:custom, false) && !answer[:answer].to_s.empty?
78
+
79
+ case answer&.fetch(:answer, nil)
80
+ when "Allow once" then true
81
+ when "Allow this tool for this session" then :allow_for_session
82
+ else false
83
+ end
84
+ end
61
85
  end
62
86
 
63
87
  private
@@ -72,8 +96,8 @@ module Kward
72
96
  return restored if restored
73
97
 
74
98
  if agent.nil? && (resumed_agent = resume_last_session(session_store))
75
- release_implementation_writer
76
- @tabs << build_tab(@active_session, build_tab_agent(resumed_agent.conversation, @active_session), label: default_tab_label(0))
99
+ restored_agent = build_tab_agent(resumed_agent.conversation, @active_session)
100
+ @tabs << build_tab(@active_session, restored_agent, driver: SessionTabDriver.new(session: @active_session, agent: restored_agent), label: default_tab_label(0))
77
101
  return activate_tab(0, render: false)
78
102
  end
79
103
 
@@ -87,19 +111,18 @@ module Kward
87
111
  @active_session.attach(conversation)
88
112
  tab_agent = build_tab_agent(conversation, @active_session)
89
113
  end
90
- @tabs << build_tab(@active_session, tab_agent, label: default_tab_label(0))
114
+ @tabs << build_tab(@active_session, tab_agent, driver: SessionTabDriver.new(session: @active_session, agent: tab_agent), label: default_tab_label(0))
91
115
  activate_tab(0, render: false)
92
116
  end
93
117
 
94
118
  def restore_tabs(session_store)
95
119
  data = @tab_store&.load || {}
96
- paths = Array(data["session_paths"]).map(&:to_s)
97
- return nil if paths.empty?
120
+ descriptors = Array(data["tabs"])
121
+ return nil if descriptors.empty?
98
122
 
99
- paths.each_with_index do |path, index|
100
- session, conversation = restore_tab_session(session_store, path)
101
- tab = build_tab(session, build_tab_agent(conversation, session), label: restored_tab_label(data, index))
102
- @tabs << tab
123
+ descriptors.each_with_index do |_descriptor, index|
124
+ tab = build_tab_from_descriptor(data.fetch("tabs")[index], session_store, label: restored_tab_label(data, index))
125
+ @tabs << tab if tab
103
126
  rescue StandardError
104
127
  next
105
128
  end
@@ -110,6 +133,47 @@ module Kward
110
133
  activate_tab(@active_tab_index)
111
134
  end
112
135
 
136
+ def build_tab_from_descriptor(descriptor, session_store, label: nil)
137
+ descriptor = descriptor.transform_keys(&:to_s)
138
+ if descriptor["kind"] == "session"
139
+ session, conversation = restore_tab_session(session_store, descriptor.fetch("session_path"))
140
+ agent = build_tab_agent(conversation, session)
141
+ return build_tab(session, agent, driver: SessionTabDriver.new(session: session, agent: agent), label: label)
142
+ end
143
+
144
+ tab_type = plugin_registry.tab_type_for_id(descriptor["plugin_tab_type"])
145
+ driver = if tab_type
146
+ host = PluginTabHost.new(client: @client, workspace_root: session_store.cwd)
147
+ tab_type.handler.call(host, descriptor)
148
+ else
149
+ UnavailableTabDriver.new(descriptor: descriptor, message: "Plugin tab #{descriptor["plugin_tab_type"].inspect} is unavailable.")
150
+ end
151
+ raise "Plugin tab #{descriptor["plugin_tab_type"].inspect} did not return a tab driver." unless driver
152
+ build_tab(nil, nil, driver: driver, label: label || descriptor["label"] || tab_type&.title)
153
+ end
154
+
155
+ def open_plugin_tab(name, session_store)
156
+ tab_type = plugin_registry.tab_type_for(name)
157
+ return runtime_output("Plugin tab #{name.inspect} is not available.") unless tab_type
158
+
159
+ if tab_type.singleton == :global && (existing = @tabs.find { |tab| tab.driver.descriptor["plugin_tab_type"] == tab_type.id })
160
+ return switch_tab(@tabs.index(existing))
161
+ end
162
+
163
+ save_active_tab_state
164
+ stop_tab_live_view
165
+ descriptor = { "kind" => "plugin", "plugin_tab_type" => tab_type.id, "label" => tab_type.title }
166
+ host = PluginTabHost.new(client: @client, workspace_root: session_store.cwd)
167
+ driver = tab_type.handler.call(host, descriptor)
168
+ raise "Plugin tab #{name.inspect} did not return a tab driver." unless driver
169
+
170
+ @tabs << build_tab(nil, nil, driver: driver, label: tab_type.title)
171
+ @active_tab_index = @tabs.length - 1
172
+ activate_tab(@active_tab_index)
173
+ rescue StandardError => e
174
+ runtime_output("Could not open plugin tab #{name.inspect}: #{e.message}")
175
+ end
176
+
113
177
  def restore_tab_session(session_store, path)
114
178
  if File.file?(path)
115
179
  session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
@@ -131,6 +195,7 @@ module Kward
131
195
  tool_registry = ToolRegistry.new(
132
196
  workspace: workspace,
133
197
  prompt: prompt,
198
+ tool_approval: tab_tool_approval_callback(prompt),
134
199
  hook_manager: hook_manager,
135
200
  hook_context: hook_context
136
201
  )
@@ -147,10 +212,20 @@ module Kward
147
212
  agent
148
213
  end
149
214
 
150
- def build_tab(session, agent, label: nil)
215
+ def tab_tool_approval_callback(prompt)
216
+ return nil unless ConfigFiles.permission_policy(safely_read_config.to_h).enabled?
217
+
218
+ lambda do |tool_call:, name:, args:, cancellation:|
219
+ prompt.ask_tool_approval(tool_name: name, args: args, reason: args["hook_message"] || args[:hook_message])
220
+ end
221
+ end
222
+
223
+ def build_tab(session, agent, driver: nil, label: nil)
224
+ driver ||= SessionTabDriver.new(session: session, agent: agent)
151
225
  TabRuntime.new(
152
226
  session: session,
153
227
  agent: agent,
228
+ driver: driver,
154
229
  diff: session&.path ? SessionDiff.from_session_file(session.path) : SessionDiff.new,
155
230
  snapshot: nil,
156
231
  status: "idle",
@@ -162,7 +237,7 @@ module Kward
162
237
  steering: nil,
163
238
  error: nil,
164
239
  answer: nil,
165
- stream_state: new_tab_stream_state(agent),
240
+ stream_state: new_tab_stream_state(driver),
166
241
  markdown_chunks: [],
167
242
  label: label,
168
243
  unread: false,
@@ -177,6 +252,17 @@ module Kward
177
252
  @tabs && @tabs[@active_tab_index]
178
253
  end
179
254
 
255
+ def plugin_tab?(tab = active_tab)
256
+ tab && tab.session.nil?
257
+ end
258
+
259
+ def plugin_tab_command_allowed?(command)
260
+ name, = parse_slash_command(command)
261
+ return true if %w[tab exit quit].include?(name)
262
+
263
+ active_tab.driver.respond_to?(:handles_command?) && active_tab.driver.handles_command?(command)
264
+ end
265
+
180
266
  def assign_tab_question_prompt(agent, tab)
181
267
  prompt = agent.instance_variable_get(:@tab_question_prompt) if agent
182
268
  prompt.tab = tab if prompt.respond_to?(:tab=)
@@ -243,7 +329,8 @@ module Kward
243
329
  session = track_session(session_store.create(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort))
244
330
  conversation = new_conversation(workspace_root: session_store.cwd)
245
331
  session.attach(conversation)
246
- @tabs << build_tab(session, build_tab_agent(conversation, session), label: default_tab_label(@tabs.length))
332
+ agent = build_tab_agent(conversation, session)
333
+ @tabs << build_tab(session, agent, driver: SessionTabDriver.new(session: session, agent: agent), label: default_tab_label(@tabs.length))
247
334
  @active_tab_index = @tabs.length - 1
248
335
  activate_tab(@active_tab_index)
249
336
  end
@@ -263,6 +350,7 @@ module Kward
263
350
 
264
351
  stop_tab_live_view
265
352
  tab.session&.delete_if_unused if tab&.session.respond_to?(:delete_if_unused)
353
+ tab.driver.close if tab.driver.respond_to?(:close)
266
354
  @tabs.delete_at(@active_tab_index)
267
355
  @active_tab_index = [@active_tab_index, @tabs.length - 1].min
268
356
  activate_tab(@active_tab_index)
@@ -285,6 +373,7 @@ module Kward
285
373
 
286
374
  tab.session = @active_session
287
375
  tab.agent = build_tab_agent(agent.conversation, tab.session)
376
+ tab.driver = SessionTabDriver.new(session: tab.session, agent: tab.agent)
288
377
  assign_tab_question_prompt(tab.agent, tab)
289
378
  tab.diff = @session_diff || (tab.session&.path ? SessionDiff.from_session_file(tab.session.path) : SessionDiff.new)
290
379
  tab.snapshot = nil
@@ -298,7 +387,7 @@ module Kward
298
387
  tab.queued_inputs.clear
299
388
  tab.steering = nil
300
389
  tab.shell = nil
301
- tab.stream_state = new_tab_stream_state(tab.agent)
390
+ tab.stream_state = new_tab_stream_state(tab.driver)
302
391
  tab.markdown_chunks.clear
303
392
  update_prompt_tabs
304
393
  persist_tabs
@@ -323,11 +412,19 @@ module Kward
323
412
 
324
413
  @active_session = tab.session
325
414
  @session_diff = tab.diff || SessionDiff.new
326
- @footer_conversation = tab.agent.conversation
327
- @footer_tool_registry = tab.agent.tool_registry if tab.agent.respond_to?(:tool_registry)
328
- update_assistant_prompt(tab.agent.conversation)
415
+ if tab.agent
416
+ @footer_conversation = tab.agent.conversation
417
+ @footer_tool_registry = tab.agent.tool_registry if tab.agent.respond_to?(:tool_registry)
418
+ update_assistant_prompt(tab.agent.conversation)
419
+ else
420
+ @footer_conversation = nil
421
+ @footer_tool_registry = nil
422
+ @assistant_prompt = "#{tab.driver.assistant_label || "Plugin"}>"
423
+ @prompt.update_assistant_label(assistant_prompt_name) if @prompt.respond_to?(:update_assistant_label)
424
+ end
329
425
  tab.unread = false
330
426
  restore_tab_composer_snapshot(tab.snapshot)
427
+ update_plugin_tab_slash_commands(tab)
331
428
  update_prompt_tabs
332
429
  render_tab(tab) if render
333
430
  start_tab_live_view(tab) if tab.running?
@@ -344,10 +441,10 @@ module Kward
344
441
  end
345
442
 
346
443
  restore_prompt_transcript do
347
- if empty_tab_conversation?(tab.agent.conversation)
444
+ if tab.agent && empty_tab_conversation?(tab.agent.conversation)
348
445
  print_visual_banner
349
446
  else
350
- render_conversation_transcript(tab.agent.conversation)
447
+ render_transcript_messages(tab.driver.messages)
351
448
  end
352
449
  report_tab_runtime_error(tab) if %w[failed cancelled].include?(tab.status.to_s)
353
450
  end
@@ -404,20 +501,19 @@ module Kward
404
501
  end
405
502
 
406
503
  def start_tab_turn(tab, input, display_input: nil)
407
- stop_live_worker_view if respond_to?(:stop_live_worker_view, true)
408
504
  prepare_memory_context(tab.agent.conversation, input) if tab.agent.respond_to?(:conversation)
409
505
  print_user_transcript(input, display_input: display_input) if prompt_interface?
410
506
  tab.status = "queued"
411
507
  tab.unread = false
412
508
  tab.cancellation = Cancellation.new
413
- tab.steering = steering_supported? ? Steering.new : nil
509
+ tab.steering = tab.driver.supports_steering? && steering_supported? ? Steering.new : nil
414
510
  tab.error = nil
415
511
  tab.answer = nil
416
512
  tab.error_reported = false
417
513
  tab.event_history.clear
418
514
  tab.seen_events = 0
419
515
  tab.markdown_chunks.clear
420
- tab.stream_state = new_tab_stream_state(tab.agent)
516
+ tab.stream_state = new_tab_stream_state(tab.driver)
421
517
  update_prompt_tabs
422
518
  tab.thread = Thread.new { run_tab_turn(tab, input, display_input: display_input) }
423
519
  tab.thread.report_on_exception = false
@@ -425,12 +521,9 @@ module Kward
425
521
  end
426
522
 
427
523
  def run_tab_turn(tab, input, display_input: nil)
428
- options = agent_display_options(display_input)
429
- options[:cancellation] = tab.cancellation
430
- options[:steering] = tab.steering if tab.steering
431
524
  tab.status = "running"
432
525
  update_prompt_tabs
433
- tab.answer = tab.agent.ask(input, **options) do |event|
526
+ tab.answer = tab.driver.submit(input, display_input: display_input, cancellation: tab.cancellation, steering: tab.steering) do |event|
434
527
  tab.record_event(event)
435
528
  end
436
529
  tab.status = "ready"
@@ -489,7 +582,7 @@ module Kward
489
582
  tab.queued_inputs << "/exit"
490
583
  @prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
491
584
  when String
492
- handle_tab_busy_input(tab, poll_result)
585
+ return { tab_action: :busy_command } if handle_tab_busy_input(tab, poll_result) == :active_tab_changed
493
586
  end
494
587
  return next_tab_queued_input(tab) if tab.idle? && !tab.queued_inputs.empty?
495
588
  return :tab_idle if tab.idle?
@@ -512,6 +605,15 @@ module Kward
512
605
  tab.queued_inputs << input
513
606
  @prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
514
607
  return
608
+ elsif busy_tab_command?(input)
609
+ handle_tab_command(input.to_s.strip.sub(%r{\A/tab(?:\s+|\z)}, ""), @session_store)
610
+ unless active_tab.equal?(tab)
611
+ @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
612
+ return :active_tab_changed
613
+ end
614
+
615
+ restore_busy_input_prompt
616
+ return
515
617
  elsif slash_command_input?(input)
516
618
  # Slash commands are local control actions. Running or queuing them
517
619
  # from the busy composer is surprising because the state they act on
@@ -532,6 +634,10 @@ module Kward
532
634
  @prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
533
635
  end
534
636
 
637
+ def busy_tab_command?(input)
638
+ input.to_s.strip.match?(%r{\A/tab(?:\s|\z)})
639
+ end
640
+
535
641
  def refresh_active_tab
536
642
  tab = active_tab
537
643
  return unless tab
@@ -562,10 +668,10 @@ module Kward
562
668
  renderer = tab_live_renderer(tab)
563
669
  until @tab_live_view_stop
564
670
  events = tab.event_history[tab.seen_events..] || []
565
- events.each { |event| renderer.call(event, tab.agent) }
671
+ events.each { |event| renderer.call(event, tab.driver) }
566
672
  tab.seen_events += events.length
567
673
  if tab.idle?
568
- renderer.call(:flush, tab.agent)
674
+ renderer.call(:flush, tab.driver)
569
675
  break
570
676
  end
571
677
  sleep 0.05
@@ -575,7 +681,7 @@ module Kward
575
681
  end
576
682
 
577
683
  def tab_live_renderer(tab)
578
- lambda do |event, agent|
684
+ lambda do |event, driver|
579
685
  if event == :flush
580
686
  flush_interactive_markdown_deltas(tab.markdown_chunks, tab.stream_state, force: true)
581
687
  render_tab_answer(tab)
@@ -583,7 +689,11 @@ module Kward
583
689
  next
584
690
  end
585
691
 
586
- notify_plugin_transcript_event(event, agent.respond_to?(:conversation) ? agent.conversation : nil)
692
+ if driver.respond_to?(:conversation)
693
+ notify_plugin_transcript_event(event, driver.conversation)
694
+ elsif plugin_tab_type_for(driver)&.transcript_events
695
+ notify_plugin_tab_transcript_event(event, driver)
696
+ end
587
697
  handle_interactive_event(event, tab.markdown_chunks, tab.stream_state)
588
698
  flush_interactive_markdown_deltas(tab.markdown_chunks, tab.stream_state)
589
699
  rescue StandardError => e
@@ -591,6 +701,10 @@ module Kward
591
701
  end
592
702
  end
593
703
 
704
+ def plugin_tab_type_for(driver)
705
+ plugin_registry.tab_type_for_id(driver.descriptor["plugin_tab_type"])
706
+ end
707
+
594
708
  def render_tab_answer(tab)
595
709
  return unless tab.status == "ready"
596
710
  return if tab.stream_state[:streamed]
@@ -599,13 +713,13 @@ module Kward
599
713
  @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(tab.answer)}\n")
600
714
  end
601
715
 
602
- def new_tab_stream_state(agent)
716
+ def new_tab_stream_state(driver)
603
717
  {
604
718
  streamed: false,
605
719
  last_flush: monotonic_now,
606
720
  stream_block_open: false,
607
721
  markdown_streams: {},
608
- defer_assistant_streaming: defer_assistant_streaming?(agent)
722
+ defer_assistant_streaming: driver.respond_to?(:conversation) && defer_assistant_streaming?(driver.agent)
609
723
  }
610
724
  end
611
725
 
@@ -638,7 +752,7 @@ module Kward
638
752
  action, value = argument.to_s.strip.split(/\s+/, 2)
639
753
  case action
640
754
  when nil, ""
641
- runtime_output("Usage: /tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab name <label>")
755
+ runtime_output("Usage: /tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab open <plugin-tab> | /tab name <label>")
642
756
  nil
643
757
  when /^\d+$/
644
758
  switch_tab_number(action)
@@ -652,11 +766,16 @@ module Kward
652
766
  when "new"
653
767
  open_new_tab(session_store)
654
768
  active_tab&.agent
769
+ when "open"
770
+ return runtime_output("Usage: /tab open <plugin-tab>") if value.to_s.strip.empty?
771
+
772
+ open_plugin_tab(value.strip, session_store)
773
+ active_tab&.agent
655
774
  when "name", "rename"
656
775
  rename_active_tab(value)
657
776
  active_tab&.agent
658
777
  else
659
- runtime_output("Usage: /tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab name <label>")
778
+ runtime_output("Usage: /tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab open <plugin-tab> | /tab name <label>")
660
779
  nil
661
780
  end
662
781
  end
@@ -731,8 +850,7 @@ module Kward
731
850
  return unless @tab_store
732
851
 
733
852
  @tab_store.save(
734
- session_paths: @tabs.map { |tab| tab.session&.path }.compact,
735
- labels: @tabs.map { |tab| tab.label.to_s },
853
+ tabs: @tabs.map { |tab| tab.driver.descriptor.merge("label" => tab.label.to_s) },
736
854
  active_index: @active_tab_index
737
855
  )
738
856
  end
data/lib/kward/cli.rb CHANGED
@@ -35,12 +35,12 @@ require_relative "session_diff"
35
35
  require_relative "session_store"
36
36
  require_relative "session_naming"
37
37
  require_relative "tab_store"
38
+ require_relative "tab_driver"
38
39
  require_relative "session_trash"
39
40
  require_relative "session_tree_renderer"
40
41
  require_relative "starter_pack_installer"
41
42
  require_relative "steering"
42
43
  require_relative "update_check"
43
- require_relative "workers"
44
44
  require_relative "tools/tool_call"
45
45
  require_relative "tools/registry"
46
46
  require_relative "telemetry/stats"
@@ -114,8 +114,6 @@ module Kward
114
114
  @prompt_delimited = false
115
115
  @requested_mode = "auto"
116
116
  @skip_config = false
117
- @experimental_workers = false
118
- @foreground_turn_active = false
119
117
  @pending_reasoning_config = nil
120
118
  @pending_reasoning_config_mutex = Mutex.new
121
119
  @color_enabled = ANSI.enabled?($stdout)
@@ -253,7 +251,7 @@ module Kward
253
251
  raise ArgumentError, command_usage("rpc") unless @argv.length == 1
254
252
 
255
253
  ensure_client!
256
- Kward::RPC::Server.new(input: @stdin, output: $stdout, client: @client, experimental_workers: @experimental_workers).run
254
+ Kward::RPC::Server.new(input: @stdin, output: $stdout, client: @client).run
257
255
  return
258
256
  end
259
257
 
@@ -441,13 +439,15 @@ module Kward
441
439
  if input.is_a?(Hash) && input[:tab_action]
442
440
  tab_result = handle_tab_action(input, session_store)
443
441
  break if tab_result == PromptInterface::EXIT_INPUT
444
- agent = active_tab.agent if active_tab
442
+ agent = active_tab.agent if active_tab&.agent
445
443
  next
446
444
  end
447
445
  if input.is_a?(Hash) && input[:reasoning_action]
446
+ next if active_tab && plugin_tab?
447
+
448
448
  conversation = active_tab ? active_tab.agent.conversation : agent.conversation
449
449
  cycle_reasoning(conversation, direction: input[:reasoning_action], persist: :debounced)
450
- agent = active_tab.agent if active_tab
450
+ agent = active_tab.agent if active_tab&.agent
451
451
  next
452
452
  end
453
453
  next if input == :tab_idle
@@ -467,31 +467,37 @@ module Kward
467
467
  display_input = input if display_input
468
468
  end
469
469
  break if ["/exit", "/quit"].include?(command)
470
+ if active_tab && plugin_tab? && command.start_with?("/")
471
+ unless plugin_tab_command_allowed?(command)
472
+ runtime_output("#{command.split.first} is unavailable in this plugin tab.")
473
+ next
474
+ end
475
+ if active_tab.driver.respond_to?(:handle_command) && active_tab.driver.handles_command?(command)
476
+ output = active_tab.driver.handle_command(command)
477
+ runtime_output(output) unless output.to_s.empty?
478
+ next
479
+ end
480
+ end
470
481
  handled, replacement_agent = handle_local_slash_command(command, agent, session_store)
471
482
  if replacement_agent?(replacement_agent)
472
483
  agent = active_tab ? replace_active_tab_agent(replacement_agent) : replacement_agent
484
+ elsif active_tab&.agent
485
+ agent = active_tab.agent
473
486
  end
474
487
  end
475
488
  next if handled
476
- request_handled, request_replacement = handle_request_worker_input(command_input, agent, session_store)
477
- if request_handled
478
- if replacement_agent?(request_replacement)
479
- agent = active_tab ? replace_active_tab_agent(request_replacement) : request_replacement
480
- end
481
- next
482
- end
483
489
  next if shell_command_input?(command_input) && handle_interactive_shell_command(command_input, agent)
484
490
 
485
- flush_pending_reasoning_config(conversation: agent.conversation)
491
+ flush_pending_reasoning_config(conversation: active_tab.agent.conversation) if active_tab&.agent
492
+ flush_pending_reasoning_config(conversation: agent.conversation) unless active_tab
486
493
  expanded_input = expand_prompt_template(input)
487
494
  display_input = display_input || input if expanded_input
488
495
  input = expanded_input || input
489
- agent = refresh_implementation_writer(agent)
490
- @footer_conversation = agent.conversation
496
+ @footer_conversation = active_tab.agent.conversation if active_tab&.agent
497
+ @footer_conversation = agent.conversation unless active_tab
491
498
  begin
492
499
  @rewind_return_leaf_id = nil
493
- auto_name_active_session(display_input || input)
494
- @foreground_turn_active = true if @active_worker_role == "implementation"
500
+ auto_name_active_session(display_input || input) unless active_tab && plugin_tab?
495
501
  if active_tab
496
502
  submit_tab_input(active_tab, input, display_input: display_input)
497
503
  pending_inputs = []
@@ -503,22 +509,20 @@ module Kward
503
509
  pending_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
504
510
  rescue StandardError => e
505
511
  runtime_output("Error: #{e.message}")
506
- ensure
507
- @foreground_turn_active = false if @active_worker_role == "implementation"
508
- release_implementation_writer if @active_worker_role == "implementation"
509
512
  end
510
513
  end
511
514
 
512
- flush_pending_reasoning_config(conversation: agent.conversation)
513
- agent.conversation
515
+ flush_pending_reasoning_config(conversation: active_tab.agent.conversation) if active_tab&.agent
516
+ flush_pending_reasoning_config(conversation: agent.conversation) unless active_tab
517
+ active_tab&.agent&.conversation || agent.conversation
514
518
  rescue Interrupt
515
- flush_pending_reasoning_config(conversation: agent&.conversation)
519
+ flush_pending_reasoning_config(conversation: active_tab.agent.conversation) if active_tab&.agent
520
+ flush_pending_reasoning_config(conversation: agent&.conversation) unless active_tab
516
521
  runtime_output("Goodbye.")
517
- agent&.conversation
522
+ active_tab&.agent&.conversation || agent&.conversation
518
523
  ensure
519
524
  begin
520
525
  stop_tabs if respond_to?(:stop_tabs, true)
521
- stop_live_worker_view if respond_to?(:stop_live_worker_view, true)
522
526
  @prompt.close if prompt_interface?
523
527
  ensure
524
528
  cleanup_unused_sessions
@@ -37,7 +37,7 @@ module Kward
37
37
 
38
38
  Cut = Struct.new(:first_kept_index, :messages_to_summarize, :turn_prefix_messages, :split_turn, :preserved_messages, :preserved_start_index, keyword_init: true)
39
39
 
40
- # Interactive settings menu actions mixed into the CLI frontend.
40
+ # Configuration for manual and automatic conversation compaction.
41
41
  class Settings
42
42
  DEFAULT_ENABLED = true
43
43
  DEFAULT_RESERVE_TOKENS = 16_384
@@ -731,7 +731,7 @@ module Kward
731
731
  SummarizationFailed = Compaction::SummarizationFailed
732
732
 
733
733
  AUTO_COMPACTION_GUARD_RATIO = 0.10
734
- AUTO_COMPACTION_EXTRA_GUARD_CAP = 12_000
734
+ AUTO_COMPACTION_EXTRA_GUARD_FLOOR = 12_000
735
735
 
736
736
  # Creates an object for conversation compaction.
737
737
  def initialize(conversation:, client:, tool_result_summarizer: nil, settings: nil, summarizer: nil)
@@ -805,7 +805,7 @@ module Kward
805
805
  def self.auto_compaction_reserve_tokens(context_window:, configured_reserve_tokens:)
806
806
  context_window_i = context_window.to_i
807
807
  dynamic_guard = (context_window_i * AUTO_COMPACTION_GUARD_RATIO).to_i
808
- [configured_reserve_tokens.to_i, dynamic_guard, AUTO_COMPACTION_EXTRA_GUARD_CAP].max
808
+ [configured_reserve_tokens.to_i, dynamic_guard, AUTO_COMPACTION_EXTRA_GUARD_FLOOR].max
809
809
  end
810
810
 
811
811
  def compaction_messages(custom_instructions = nil)
@@ -5,6 +5,7 @@ require "yaml"
5
5
  require_relative "frontmatter"
6
6
  require_relative "private_file"
7
7
  require_relative "path_guard"
8
+ require_relative "permissions/policy"
8
9
  require_relative "ekwsh"
9
10
  require_relative "editor_mode"
10
11
  require_relative "diff_view_mode"
@@ -24,7 +25,9 @@ module Kward
24
25
  # Keep path decisions here. Higher-level code should ask `ConfigFiles` for
25
26
  # config, prompt, skill, plugin, cache, memory, and session locations instead of
26
27
  # reconstructing `~/.kward` paths independently.
28
+ # @api public
27
29
  module ConfigFiles
30
+ # Raised when a user configuration file cannot be parsed.
28
31
  class ConfigError < StandardError
29
32
  attr_reader :path, :format, :detail
30
33
 
@@ -39,6 +42,7 @@ module Kward
39
42
  MAX_SKILL_FILE_BYTES = 100_000
40
43
  MAX_PROMPT_FILE_BYTES = 32 * 1024
41
44
  DEFAULT_OVERLAY_SETTINGS = { "alignment" => "center", "width" => "maximum" }.freeze
45
+ PROJECT_BROWSER_ICON_THEMES = %w[off nerd-font].freeze
42
46
  DEFAULT_PERSONAS = {
43
47
  "characters" => [
44
48
  {
@@ -87,10 +91,12 @@ module Kward
87
91
  File.expand_path(ENV["KWARD_CONFIG_PATH"] || File.join(config_dir, "config.json"))
88
92
  end
89
93
 
94
+ # @return [String] directory containing reusable local caches
90
95
  def cache_dir
91
96
  File.join(config_dir, "cache")
92
97
  end
93
98
 
99
+ # @return [String] embedded-shell YAML config path
94
100
  def ekwsh_config_path
95
101
  File.join(config_dir, "ekwsh.yml")
96
102
  end
@@ -103,6 +109,9 @@ module Kward
103
109
  File.join(config_dir, "trusted_workspace_hooks.json")
104
110
  end
105
111
 
112
+ # Returns a fresh default config suitable for first-run persistence.
113
+ #
114
+ # @return [Hash] deep-mutable config defaults
106
115
  def default_config
107
116
  {
108
117
  "personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)),
@@ -124,6 +133,9 @@ module Kward
124
133
  "diff_view" => "auto"
125
134
  },
126
135
  "overlay" => DEFAULT_OVERLAY_SETTINGS.dup,
136
+ "project_browser" => {
137
+ "icons" => "off"
138
+ },
127
139
  "web_search" => {
128
140
  "enabled" => true,
129
141
  "provider" => "auto",
@@ -142,6 +154,10 @@ module Kward
142
154
  "mcpServers" => {},
143
155
  "tools" => {
144
156
  "workspace_guardrails" => true
157
+ },
158
+ "permissions" => {
159
+ "enabled" => false,
160
+ "mode" => "ask"
145
161
  }
146
162
  }
147
163
  end
@@ -409,6 +425,13 @@ module Kward
409
425
  settings
410
426
  end
411
427
 
428
+ # Returns the project browser icon theme, or off when unset or invalid.
429
+ def project_browser_icon_theme(config = read_config)
430
+ browser = config["project_browser"].is_a?(Hash) ? config["project_browser"] : {}
431
+ theme = browser["icons"].to_s
432
+ PROJECT_BROWSER_ICON_THEMES.include?(theme) ? theme : "off"
433
+ end
434
+
412
435
  # Returns whether the composer should show busy-state keyboard help.
413
436
  def composer_busy_help?(config = read_config)
414
437
  composer = config["composer"].is_a?(Hash) ? config["composer"] : {}
@@ -470,6 +493,11 @@ module Kward
470
493
  tools["workspace_guardrails"] != false
471
494
  end
472
495
 
496
+ # Builds the opt-in model-tool permission policy from persisted configuration.
497
+ def permission_policy(config = read_config)
498
+ Permissions::Policy.from_config(config)
499
+ end
500
+
473
501
  # Returns whether project-level Agent Skills should be loaded from the workspace.
474
502
  def project_skills_trusted?(config = read_config)
475
503
  skills = config["skills"].is_a?(Hash) ? config["skills"] : {}
data/lib/kward/ekwsh.rb CHANGED
@@ -551,9 +551,5 @@ module Kward
551
551
  ANSI.sanitize_transcript(text)
552
552
  end
553
553
 
554
- def clean_output(value)
555
- text = clean_chunk(value)
556
- text.end_with?("\n") || text.empty? ? text : "#{text}\n"
557
- end
558
554
  end
559
555
  end