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
@@ -21,6 +21,8 @@ module Kward
21
21
  shell_command_summary(args, text)
22
22
  when "web_search"
23
23
  web_search_summary(args, text)
24
+ when "read_skill"
25
+ read_skill_summary(text)
24
26
  else
25
27
  generic_tool_summary(name, text)
26
28
  end
@@ -76,6 +78,10 @@ module Kward
76
78
  lines.join("\n")
77
79
  end
78
80
 
81
+ def read_skill_summary(content)
82
+ "read_skill:\n#{content}"
83
+ end
84
+
79
85
  def error_tool_summary(name, args, content)
80
86
  path = args["path"] || args[:path]
81
87
  command = args["command"] || args[:command]
data/lib/kward/cli.rb CHANGED
@@ -9,10 +9,13 @@ require_relative "model/client"
9
9
  require_relative "compactor"
10
10
  require_relative "config_files"
11
11
  require_relative "clipboard"
12
+ require_relative "cancellation"
12
13
  require_relative "cli_transcript_formatter"
13
14
  require_relative "model/context_usage"
14
15
  require_relative "events"
15
16
  require_relative "export_path"
17
+ require_relative "ekwsh"
18
+ require_relative "interactive_pty_runner"
16
19
  require_relative "auth/anthropic_oauth"
17
20
  require_relative "auth/github_oauth"
18
21
  require_relative "auth/openrouter_api_key"
@@ -29,10 +32,13 @@ require_relative "model/retry_message"
29
32
  require_relative "rpc/server"
30
33
  require_relative "session_diff"
31
34
  require_relative "session_store"
35
+ require_relative "session_naming"
36
+ require_relative "tab_store"
32
37
  require_relative "session_trash"
33
38
  require_relative "session_tree_renderer"
34
39
  require_relative "starter_pack_installer"
35
40
  require_relative "steering"
41
+ require_relative "workers"
36
42
  require_relative "tools/tool_call"
37
43
  require_relative "tools/registry"
38
44
  require_relative "telemetry/stats"
@@ -48,10 +54,12 @@ require_relative "cli/slash_commands"
48
54
  require_relative "cli/memory_commands"
49
55
  require_relative "cli/settings"
50
56
  require_relative "cli/sessions"
57
+ require_relative "cli/tabs"
51
58
  require_relative "cli/compaction"
52
59
  require_relative "cli/rendering"
53
60
  require_relative "cli/prompt_interface"
54
61
  require_relative "cli/plugins"
62
+ require_relative "cli/git"
55
63
  require_relative "cli/interactive_turn"
56
64
  require_relative "cli/tool_summaries"
57
65
 
@@ -60,8 +68,6 @@ module Kward
60
68
  # Command-line interface for interactive chat, one-shot prompts, login,
61
69
  # telemetry export, Pan server mode, and the experimental JSON-RPC backend.
62
70
  class CLI
63
- # Order from the captain: The next line shall never be deleted.
64
- STATUS_MESSAGE = "This is a totally important status message about a non-existing status. Hi ChatGPT 👋"
65
71
  RESTORED_TOOL_OUTPUT_LIMIT = 2_000
66
72
  INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT = 10
67
73
  STREAM_RENDER_INTERVAL = 0.025
@@ -80,10 +86,12 @@ module Kward
80
86
  include CLI::MemoryCommands
81
87
  include CLI::Settings
82
88
  include CLI::Sessions
89
+ include CLI::Tabs
83
90
  include CLI::CompactionCommands
84
91
  include CLI::Rendering
85
92
  include CLI::PromptInterfaceSupport
86
93
  include CLI::Plugins
94
+ include CLI::GitCommands
87
95
  include CLI::InteractiveTurn
88
96
  include CLI::ToolSummaries
89
97
 
@@ -100,6 +108,11 @@ module Kward
100
108
  @plugin_registry = nil
101
109
  @working_directory = nil
102
110
  @prompt_delimited = false
111
+ @requested_mode = "auto"
112
+ @experimental_workers = false
113
+ @foreground_turn_active = false
114
+ @pending_reasoning_config = nil
115
+ @pending_reasoning_config_mutex = Mutex.new
103
116
  @color_enabled = ANSI.enabled?($stdout)
104
117
  end
105
118
 
@@ -164,6 +177,17 @@ module Kward
164
177
  return
165
178
  end
166
179
 
180
+ if @argv.first == "edit"
181
+ if help_option_arguments?(@argv[1..] || [])
182
+ print_command_help("edit")
183
+ return
184
+ end
185
+ raise ArgumentError, command_usage("edit") unless @argv.length == 2
186
+
187
+ edit_file_command(@argv[1])
188
+ return
189
+ end
190
+
167
191
  if @argv.first == "sysprompt"
168
192
  if help_option_arguments?(@argv[1..] || [])
169
193
  print_command_help("sysprompt")
@@ -181,7 +205,7 @@ module Kward
181
205
  end
182
206
  raise ArgumentError, command_usage("rpc") unless @argv.length == 1
183
207
 
184
- Kward::RPC::Server.new(input: @stdin, output: $stdout, client: @client).run
208
+ Kward::RPC::Server.new(input: @stdin, output: $stdout, client: @client, experimental_workers: @experimental_workers).run
185
209
  return
186
210
  end
187
211
 
@@ -231,53 +255,107 @@ module Kward
231
255
  run_prompt_or_interactive
232
256
  end
233
257
 
258
+ def edit_file_command(path)
259
+ setup_interactive_prompt
260
+ unless @prompt.respond_to?(:edit_file)
261
+ raise ArgumentError, "The integrated editor requires an interactive terminal."
262
+ end
263
+
264
+ @prompt.edit_file(path, base_dir: Dir.pwd, allow_new: true)
265
+ ensure
266
+ @prompt.close if @prompt.respond_to?(:close) && prompt_interface?
267
+ end
268
+
234
269
  def run_prompt_or_interactive
270
+ stdin_input = read_stdin_input
235
271
  first_prompt = one_shot_prompt_argument
236
- if first_prompt
237
- answer = one_shot(first_prompt)
238
- puts answer unless answer.empty?
239
- return
240
- end
241
272
 
242
- stdin_prompt = piped_prompt
243
- unless stdin_prompt.empty?
244
- answer = one_shot(stdin_prompt)
273
+ case resolved_execution_mode(first_prompt: first_prompt, stdin_input: stdin_input)
274
+ when "chat"
275
+ interactive_loop
276
+ when "filter"
277
+ raise ArgumentError, "Filter mode requires stdin input." if stdin_input.nil?
278
+
279
+ answer = one_shot(filter_prompt(instruction: first_prompt, input: stdin_input), filter: true)
280
+ puts answer unless answer.empty?
281
+ when "oneshot"
282
+ input = first_prompt || stdin_input.to_s.strip
283
+ answer = one_shot(input)
245
284
  puts answer unless answer.empty?
246
- return
247
285
  end
248
-
249
- interactive_loop
250
286
  end
251
287
 
252
- def one_shot(input)
288
+ def one_shot(input, filter: false)
253
289
  streamed = false
254
290
  assistant_streamed = false
255
291
  markdown_chunks = []
256
292
  conversation = new_conversation
293
+ apply_filter_system_prompt(conversation) if filter
257
294
  agent = Agent.new(
258
295
  client: @client,
259
296
  tool_registry: ToolRegistry.new(workspace: configured_workspace, prompt: @prompt),
260
297
  conversation: conversation
261
298
  )
262
- answer = agent.ask(input) do |event|
263
- result = render_blocking_turn_event(event, markdown_chunks)
264
- streamed = true if result
265
- assistant_streamed = true if result == :assistant_streamed
299
+ answer = if filter
300
+ agent.ask(input)
301
+ else
302
+ agent.ask(input) do |event|
303
+ result = render_blocking_turn_event(event, markdown_chunks)
304
+ streamed = true if result
305
+ assistant_streamed = true if result == :assistant_streamed
306
+ end
266
307
  end
267
308
  flush_markdown_deltas(markdown_chunks) if streamed
309
+ return answer if filter
310
+
268
311
  assistant_streamed ? "" : render_markdown_transcript(answer)
269
312
  end
270
313
 
314
+ def resolved_execution_mode(first_prompt:, stdin_input:)
315
+ return @requested_mode unless @requested_mode == "auto"
316
+ return "chat" if stdin_input.nil? && first_prompt.nil?
317
+ return "filter" if !stdin_input.nil? && first_prompt
318
+
319
+ "oneshot"
320
+ end
321
+
322
+ def filter_prompt(instruction:, input:)
323
+ <<~PROMPT
324
+ Instruction:
325
+ #{instruction}
326
+
327
+ Input:
328
+ #{input}
329
+ PROMPT
330
+ end
271
331
 
332
+ def apply_filter_system_prompt(conversation)
333
+ return unless conversation.system_message
334
+
335
+ conversation.system_message[:content] = [conversation.system_message[:content], filter_system_prompt].compact.join("\n\n")
336
+ end
272
337
 
338
+ def filter_system_prompt
339
+ <<~PROMPT.strip
340
+ You are being used as a command-line text filter.
273
341
 
342
+ Transform the provided input according to the user's instruction.
343
+ Return only the transformed output.
274
344
 
345
+ Do not include explanations, introductions, summaries, Markdown fences, or commentary.
346
+ Do not say what you changed.
347
+ Preserve the input format unless the instruction requires changing it.
348
+ If the input is code, data, markup, or configuration, output only the resulting code/data/markup/configuration.
349
+ PROMPT
350
+ end
275
351
 
276
352
  def interactive_loop(agent: nil)
277
353
  setup_interactive_prompt
278
354
  session_store = interactive_session_store(agent)
279
355
  @resumed_last_session = false
280
- if session_store && agent.nil?
356
+ if session_store && @prompt.respond_to?(:update_tabs)
357
+ agent = setup_interactive_tabs(session_store, agent)
358
+ elsif session_store && agent.nil?
281
359
  agent = resume_last_session(session_store) || build_new_session_agent(session_store)
282
360
  elsif session_store
283
361
  @active_session = track_session(session_store.create(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort))
@@ -290,13 +368,29 @@ module Kward
290
368
  update_assistant_prompt(agent.conversation)
291
369
  @footer_conversation = agent.conversation
292
370
 
293
- print_visual_banner unless @resumed_last_session
371
+ print_visual_banner unless @resumed_last_session || @restored_tabs
294
372
  render_resumed_last_session_transcript(agent.conversation) if @resumed_last_session
295
373
 
296
374
  @pending_inputs = []
297
375
 
298
376
  loop do
299
- input = @pending_inputs.shift || @prompt.ask("You>")
377
+ if @pending_inputs.empty? && active_tab&.shell
378
+ run_ekwsh_loop(active_tab.shell, tab: active_tab, history: build_ekwsh_history(active_tab.agent))
379
+ end
380
+ input = @pending_inputs.shift || (active_tab ? poll_active_tab_input : @prompt.ask("You>"))
381
+ if input.is_a?(Hash) && input[:tab_action]
382
+ tab_result = handle_tab_action(input, session_store)
383
+ break if tab_result == PromptInterface::EXIT_INPUT
384
+ agent = active_tab.agent if active_tab
385
+ next
386
+ end
387
+ if input.is_a?(Hash) && input[:reasoning_action]
388
+ conversation = active_tab ? active_tab.agent.conversation : agent.conversation
389
+ cycle_reasoning(conversation, direction: input[:reasoning_action], persist: :debounced)
390
+ agent = active_tab.agent if active_tab
391
+ next
392
+ end
393
+ next if input == :tab_idle
300
394
  break if input.nil?
301
395
 
302
396
  display_input = submitted_display_input(input)
@@ -314,31 +408,57 @@ module Kward
314
408
  end
315
409
  break if ["/exit", "/quit"].include?(command)
316
410
  handled, replacement_agent = handle_local_slash_command(command, agent, session_store)
317
- agent = replacement_agent if replacement_agent
411
+ if replacement_agent?(replacement_agent)
412
+ agent = active_tab ? replace_active_tab_agent(replacement_agent) : replacement_agent
413
+ end
318
414
  end
319
415
  next if handled
416
+ request_handled, request_replacement = handle_request_worker_input(command_input, agent, session_store)
417
+ if request_handled
418
+ if replacement_agent?(request_replacement)
419
+ agent = active_tab ? replace_active_tab_agent(request_replacement) : request_replacement
420
+ end
421
+ next
422
+ end
320
423
  next if shell_command_input?(command_input) && handle_interactive_shell_command(command_input, agent)
321
424
 
425
+ flush_pending_reasoning_config(conversation: agent.conversation)
322
426
  expanded_input = expand_prompt_template(input)
323
427
  display_input = display_input || input if expanded_input
324
428
  input = expanded_input || input
429
+ agent = refresh_implementation_writer(agent)
325
430
  @footer_conversation = agent.conversation
326
431
  begin
327
432
  @rewind_return_leaf_id = nil
328
433
  auto_name_active_session(display_input || input)
329
- pending_inputs = run_interactive_turn(agent, input, display_input: display_input)
434
+ @foreground_turn_active = true if @active_worker_role == "implementation"
435
+ if active_tab
436
+ submit_tab_input(active_tab, input, display_input: display_input)
437
+ pending_inputs = []
438
+ else
439
+ pending_inputs = run_interactive_turn(agent, input, display_input: display_input)
440
+ agent = @busy_replacement_agent if replacement_agent?(@busy_replacement_agent)
441
+ @busy_replacement_agent = nil
442
+ end
330
443
  pending_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
331
444
  rescue StandardError => e
332
445
  runtime_output("Error: #{e.message}")
446
+ ensure
447
+ @foreground_turn_active = false if @active_worker_role == "implementation"
448
+ release_implementation_writer if @active_worker_role == "implementation"
333
449
  end
334
450
  end
335
451
 
452
+ flush_pending_reasoning_config(conversation: agent.conversation)
336
453
  agent.conversation
337
454
  rescue Interrupt
455
+ flush_pending_reasoning_config(conversation: agent&.conversation)
338
456
  runtime_output("Goodbye.")
339
457
  agent&.conversation
340
458
  ensure
341
459
  begin
460
+ stop_tabs if respond_to?(:stop_tabs, true)
461
+ stop_live_worker_view if respond_to?(:stop_live_worker_view, true)
342
462
  @prompt.close if prompt_interface?
343
463
  ensure
344
464
  cleanup_unused_sessions
@@ -347,9 +467,13 @@ module Kward
347
467
  end
348
468
 
349
469
  def piped_prompt
350
- return "" if @stdin.tty?
470
+ read_stdin_input.to_s.strip
471
+ end
472
+
473
+ def read_stdin_input
474
+ return nil if @stdin.tty?
351
475
 
352
- @stdin.read.strip
476
+ @stdin.read
353
477
  end
354
478
 
355
479
  end
@@ -1,5 +1,5 @@
1
- require "base64"
2
1
  require "open3"
2
+ require_relative "terminal_sequences"
3
3
  require "rbconfig"
4
4
 
5
5
  # Namespace for the Kward CLI agent runtime.
@@ -39,8 +39,7 @@ module Kward
39
39
  end
40
40
 
41
41
  def write_osc52(content)
42
- encoded = Base64.strict_encode64(content)
43
- @output.print("\e]52;c;#{encoded}\a")
42
+ @output.print(TerminalSequences.osc52(content))
44
43
  @output.flush if @output.respond_to?(:flush)
45
44
  end
46
45
 
@@ -130,8 +130,7 @@ module Kward
130
130
  end
131
131
 
132
132
  def tool_calls(message)
133
- calls = value(message, :tool_calls)
134
- calls.is_a?(Array) ? calls : []
133
+ MessageAccess.tool_calls(message)
135
134
  end
136
135
 
137
136
  def tool_call_name(tool_call)
@@ -335,7 +334,7 @@ module Kward
335
334
 
336
335
  def tool_call_args(tool_call)
337
336
  function = tool_call["function"] || tool_call[:function] || {}
338
- parse_tool_arguments(function["arguments"] || function[:arguments])
337
+ ToolCall.parse_arguments(function["arguments"] || function[:arguments])
339
338
  end
340
339
 
341
340
  def tool_command(tool_call)
@@ -351,15 +350,6 @@ module Kward
351
350
  "#{name}(#{rendered})"
352
351
  end
353
352
  end
354
-
355
- def parse_tool_arguments(arguments)
356
- return {} if arguments.nil? || arguments.empty?
357
- return arguments if arguments.is_a?(Hash)
358
-
359
- JSON.parse(arguments)
360
- rescue JSON::ParserError
361
- {}
362
- end
363
353
  end
364
354
 
365
355
  # Compaction support object used by conversation summarization.
@@ -441,7 +431,7 @@ module Kward
441
431
  end
442
432
 
443
433
  def message_role(message)
444
- message["role"] || message[:role]
434
+ MessageAccess.role(message)
445
435
  end
446
436
  end
447
437
 
@@ -518,22 +508,20 @@ module Kward
518
508
  end
519
509
 
520
510
  def compaction_summary(message)
521
- message["summary"] || message[:summary] || message["content"] || message[:content]
511
+ MessageAccess.summary(message) || MessageAccess.content(message)
522
512
  end
523
513
 
524
514
  def compaction_details(message)
525
- return {} unless message
526
-
527
- details = message["details"] || message[:details]
515
+ details = MessageAccess.value(message, :details)
528
516
  details.is_a?(Hash) ? details : {}
529
517
  end
530
518
 
531
519
  def entry_id(message, index)
532
- message["id"] || message[:id] || "message:#{index}"
520
+ MessageAccess.value(message, :id) || "message:#{index}"
533
521
  end
534
522
 
535
523
  def message_role(message)
536
- message["role"] || message[:role]
524
+ MessageAccess.role(message)
537
525
  end
538
526
  end
539
527
 
@@ -1,7 +1,10 @@
1
+ require "digest"
1
2
  require "fileutils"
2
3
  require "json"
3
4
  require "yaml"
4
5
  require_relative "private_file"
6
+ require_relative "ekwsh"
7
+ require_relative "editor_mode"
5
8
  require_relative "prompts/templates"
6
9
  require_relative "skills/registry"
7
10
 
@@ -64,6 +67,10 @@ module Kward
64
67
  File.join(config_dir, "cache")
65
68
  end
66
69
 
70
+ def ekwsh_config_path
71
+ File.join(config_dir, "ekwsh.yml")
72
+ end
73
+
67
74
  def default_config
68
75
  {
69
76
  "personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)),
@@ -72,7 +79,16 @@ module Kward
72
79
  "auto_summary" => false
73
80
  },
74
81
  "composer" => {
75
- "busy_help" => true
82
+ "busy_help" => true,
83
+ "tab_keybindings" => "auto"
84
+ },
85
+ "editor" => {
86
+ "mode" => "modern",
87
+ "auto_indent" => true,
88
+ "auto_close_pairs" => true,
89
+ "soft_wrap" => true,
90
+ "bar_cursor" => true,
91
+ "line_numbers" => "absolute"
76
92
  },
77
93
  "sessions" => {
78
94
  "auto_resume" => false
@@ -101,6 +117,17 @@ module Kward
101
117
  File.join(cache_dir, "openrouter_models.json")
102
118
  end
103
119
 
120
+ def project_browser_state_path
121
+ File.join(cache_dir, "project_browser_state.json")
122
+ end
123
+
124
+ def prompt_history_path(cwd, config_dir: self.config_dir, kind: "prompt")
125
+ key = Digest::SHA256.hexdigest(canonical_workspace_root(cwd))[0, 24]
126
+ return File.join(config_dir, "history", "#{key}.jsonl") if kind.to_s == "prompt"
127
+
128
+ File.join(config_dir, "history", kind.to_s, "#{key}.jsonl")
129
+ end
130
+
104
131
  # @return [String] directory containing structured memory files
105
132
  def memory_dir
106
133
  File.join(config_dir, "memory")
@@ -144,6 +171,69 @@ module Kward
144
171
  PrivateFile.write_json(path, config)
145
172
  end
146
173
 
174
+ def read_ekwsh_config(path = ekwsh_config_path)
175
+ path = File.expand_path(path)
176
+ return normalize_ekwsh_config(nil) unless File.exist?(path)
177
+
178
+ data = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
179
+ normalize_ekwsh_config(data)
180
+ rescue Psych::SyntaxError => e
181
+ raise "Invalid ekwsh YAML config: #{path}: #{e.message}"
182
+ end
183
+
184
+ def normalize_ekwsh_config(data)
185
+ data = data.transform_keys(&:to_s) if data.is_a?(Hash)
186
+ settings = data.is_a?(Hash) ? data : {}
187
+ {
188
+ shell: normalize_ekwsh_shell(settings["shell"]),
189
+ timeout_seconds: normalize_positive_integer(settings["timeout_seconds"], Ekwsh::DEFAULT_TIMEOUT_SECONDS),
190
+ max_output_bytes: normalize_positive_integer(settings["max_output_bytes"], Ekwsh::DEFAULT_MAX_OUTPUT_BYTES),
191
+ history_limit: normalize_positive_integer(settings["history_limit"], Ekwsh::DEFAULT_HISTORY_LIMIT),
192
+ env: normalize_ekwsh_env(settings["env"]),
193
+ aliases: normalize_ekwsh_aliases(settings["aliases"])
194
+ }
195
+ end
196
+
197
+ def normalize_ekwsh_shell(value)
198
+ shell = value.to_s.strip
199
+ return Ekwsh::DEFAULT_SHELL if shell.empty?
200
+ return shell if shell.start_with?("/") && File.executable?(shell)
201
+
202
+ Ekwsh::DEFAULT_SHELL
203
+ end
204
+
205
+ def normalize_positive_integer(value, default)
206
+ integer = Integer(value)
207
+ integer.positive? ? integer : default
208
+ rescue ArgumentError, TypeError
209
+ default
210
+ end
211
+
212
+ def normalize_ekwsh_env(values)
213
+ return {} unless values.is_a?(Hash)
214
+
215
+ values.each_with_object({}) do |(key, value), result|
216
+ key = key.to_s
217
+ next unless key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
218
+ next if value.nil?
219
+
220
+ result[key] = value.to_s
221
+ end
222
+ end
223
+
224
+ def normalize_ekwsh_aliases(values)
225
+ return {} unless values.is_a?(Hash)
226
+
227
+ values.each_with_object({}) do |(name, command), result|
228
+ name = name.to_s
229
+ command = command.to_s.strip
230
+ next unless Ekwsh.valid_alias_name?(name)
231
+ next if command.empty?
232
+
233
+ result[name] = command
234
+ end
235
+ end
236
+
147
237
  # Merges top-level config values and writes the updated config privately.
148
238
  def update_config(values, path = config_path)
149
239
  raise "Config values must be an object" unless values.is_a?(Hash)
@@ -154,6 +244,17 @@ module Kward
154
244
  config
155
245
  end
156
246
 
247
+ # Merges values into a one-level nested config section and writes privately.
248
+ def update_nested_config(section, values, path = config_path)
249
+ raise "Config values must be an object" unless values.is_a?(Hash)
250
+
251
+ config = read_config(path)
252
+ current = config[section.to_s].is_a?(Hash) ? config[section.to_s].dup : {}
253
+ config[section.to_s] = current.merge(values.transform_keys(&:to_s))
254
+ write_config(config, path)
255
+ config
256
+ end
257
+
157
258
  # Removes a top-level config key when it exists.
158
259
  def delete_config_key(key, path = config_path)
159
260
  config = read_config(path)
@@ -193,6 +294,49 @@ module Kward
193
294
  composer["busy_help"] != false
194
295
  end
195
296
 
297
+ # Returns the configured tab keybinding family, or auto when unset/invalid.
298
+ def composer_tab_keybindings(config = read_config)
299
+ composer = config["composer"].is_a?(Hash) ? config["composer"] : {}
300
+ value = composer["tab_keybindings"].to_s.downcase
301
+ %w[auto ctrl alt].include?(value) ? value : "auto"
302
+ end
303
+
304
+ # Returns the built-in TUI editor keymap mode.
305
+ def editor_mode(config = read_config)
306
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
307
+ EditorMode.normalize(editor["mode"])
308
+ end
309
+
310
+ # Returns whether the built-in TUI editor should auto-indent new lines.
311
+ def editor_auto_indent?(config = read_config)
312
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
313
+ editor["auto_indent"] != false
314
+ end
315
+
316
+ # Returns whether the built-in TUI editor should auto-close typed pairs.
317
+ def editor_auto_close_pairs?(config = read_config)
318
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
319
+ editor["auto_close_pairs"] != false
320
+ end
321
+
322
+ # Returns whether the built-in TUI editor should soft-wrap long lines.
323
+ def editor_soft_wrap?(config = read_config)
324
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
325
+ editor["soft_wrap"] != false
326
+ end
327
+
328
+ # Returns whether editable built-in TUI editor buffers should use a bar cursor.
329
+ def editor_bar_cursor?(config = read_config)
330
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
331
+ editor["bar_cursor"] != false
332
+ end
333
+
334
+ # Returns the built-in TUI editor line-number display mode.
335
+ def editor_line_numbers(config = read_config)
336
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
337
+ EditorMode.normalize_line_numbers(editor["line_numbers"])
338
+ end
339
+
196
340
  # Returns whether file tools must stay inside the active workspace root.
197
341
  def workspace_guardrails_enabled?(config = read_config)
198
342
  tools = config["tools"].is_a?(Hash) ? config["tools"] : {}
@@ -0,0 +1,44 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Tracks approximate context bytes saved by tool budgeting during one process.
4
+ class ContextBudgetMeter
5
+ Snapshot = Struct.new(:calls, :original_bytes, :returned_bytes, :saved_bytes, :tool_breakdown, keyword_init: true)
6
+
7
+ def initialize
8
+ @mutex = Mutex.new
9
+ @calls = 0
10
+ @original_bytes = 0
11
+ @returned_bytes = 0
12
+ @tool_breakdown = Hash.new { |hash, key| hash[key] = { calls: 0, originalBytes: 0, returnedBytes: 0, savedBytes: 0 } }
13
+ end
14
+
15
+ def record(tool_name:, original_bytes:, returned_bytes:)
16
+ original_bytes = original_bytes.to_i
17
+ returned_bytes = returned_bytes.to_i
18
+ saved_bytes = [original_bytes - returned_bytes, 0].max
19
+ @mutex.synchronize do
20
+ @calls += 1
21
+ @original_bytes += original_bytes
22
+ @returned_bytes += returned_bytes
23
+ entry = @tool_breakdown[tool_name.to_s]
24
+ entry[:calls] += 1
25
+ entry[:originalBytes] += original_bytes
26
+ entry[:returnedBytes] += returned_bytes
27
+ entry[:savedBytes] += saved_bytes
28
+ end
29
+ saved_bytes
30
+ end
31
+
32
+ def snapshot
33
+ @mutex.synchronize do
34
+ Snapshot.new(
35
+ calls: @calls,
36
+ original_bytes: @original_bytes,
37
+ returned_bytes: @returned_bytes,
38
+ saved_bytes: [@original_bytes - @returned_bytes, 0].max,
39
+ tool_breakdown: @tool_breakdown.transform_values(&:dup)
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end