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
@@ -43,9 +43,22 @@ module Kward
43
43
  end
44
44
 
45
45
  def build_interactive_agent(conversation)
46
+ @active_worker_role = "implementation"
47
+ set_visible_worker("implementation", status: "active")
48
+ build_worker_agent(conversation, role: "implementation")
49
+ end
50
+
51
+ def build_worker_agent(conversation, role: "implementation")
46
52
  conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
47
53
  workspace = configured_workspace(root: conversation.workspace_root)
48
- tool_registry = ToolRegistry.new(workspace: workspace, prompt: @prompt)
54
+ writer_id = worker_writer_id(role)
55
+ tool_registry = ToolRegistry.new(
56
+ workspace: workspace,
57
+ prompt: @prompt,
58
+ allowed_tool_names: Workers::ToolPolicy.allowed_tool_names(role),
59
+ write_lock: @worker_write_lock,
60
+ writer_id: writer_id
61
+ )
49
62
  @footer_conversation = conversation
50
63
  @footer_tool_registry = tool_registry
51
64
  Agent.new(
@@ -55,6 +68,34 @@ module Kward
55
68
  )
56
69
  end
57
70
 
71
+ def set_visible_worker(id, status: nil, worker: nil)
72
+ @visible_worker_id = id.to_s
73
+ @visible_worker_status = status
74
+ @visible_worker = worker
75
+ end
76
+
77
+ def worker_writer_id(role)
78
+ return nil unless Workers::ToolPolicy.write_capable?(role)
79
+
80
+ @worker_write_lock ||= Workers::WriteLock.new
81
+ owner_id = role.to_s.empty? ? "implementation" : role.to_s
82
+ return owner_id if @worker_write_lock.acquire(owner_id)
83
+
84
+ nil
85
+ end
86
+
87
+ def refresh_implementation_writer(agent)
88
+ return agent unless @active_worker_role == "implementation"
89
+ return agent unless agent&.respond_to?(:tool_registry)
90
+ return agent if agent.tool_registry.writer_id && @worker_write_lock&.owned_by?(agent.tool_registry.writer_id)
91
+
92
+ build_interactive_agent(agent.conversation)
93
+ end
94
+
95
+ def release_implementation_writer
96
+ @worker_write_lock&.release("implementation")
97
+ end
98
+
58
99
  def handle_interactive_shell_command(input, agent)
59
100
  command = input.to_s.sub(/\A!\s*/, "")
60
101
  if command.strip.empty?
@@ -73,6 +114,227 @@ module Kward
73
114
  input.to_s.start_with?("!")
74
115
  end
75
116
 
117
+ def run_ekwsh(agent)
118
+ unless @prompt.respond_to?(:ask)
119
+ runtime_output("The embedded shell is only available in interactive mode.")
120
+ return
121
+ end
122
+
123
+ tab = active_tab if respond_to?(:active_tab, true)
124
+ entering = tab.nil? || tab.shell.nil?
125
+ shell = tab&.shell || build_ekwsh(agent)
126
+ tab.shell = shell if tab
127
+ runtime_output("Entering ekwsh. Type exit or press Ctrl+D on an empty prompt to return.") if entering
128
+ run_ekwsh_loop(shell, tab: tab, history: build_ekwsh_history(agent))
129
+ end
130
+
131
+ def build_ekwsh(agent)
132
+ config = ConfigFiles.read_ekwsh_config
133
+ Ekwsh.new(
134
+ cwd: interactive_workspace_root(agent),
135
+ configured_env: config[:env],
136
+ aliases: config[:aliases],
137
+ shell: config[:shell],
138
+ timeout_seconds: config[:timeout_seconds],
139
+ max_output_bytes: config[:max_output_bytes]
140
+ )
141
+ end
142
+
143
+ def build_ekwsh_history(agent)
144
+ config = ConfigFiles.read_ekwsh_config
145
+ PromptHistory.new(
146
+ cwd: interactive_workspace_root(agent),
147
+ limit: config[:history_limit],
148
+ kind: "shell"
149
+ )
150
+ end
151
+
152
+ def run_interactive_pty_command(command, agent)
153
+ command = command.to_s.strip
154
+ if command.empty?
155
+ runtime_output("Usage: /pty <command>")
156
+ return
157
+ end
158
+
159
+ config = ConfigFiles.read_ekwsh_config
160
+ env = interactive_pty_environment(config[:env])
161
+ cwd = interactive_workspace_root(agent)
162
+ @prompt.say("$ #{command}\n[interactive PTY session started]\n") if @prompt.respond_to?(:say)
163
+ result = run_interactive_pty_with_terminal_handoff(config[:shell], command, env: env, cwd: cwd)
164
+ @prompt.say("[interactive PTY session exited with status #{result.exit_status}]\n") if @prompt.respond_to?(:say)
165
+ rescue Errno::ENOENT => e
166
+ runtime_output("Error: #{e.message}")
167
+ end
168
+
169
+ def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:)
170
+ runner = InteractivePtyRunner.new
171
+ if @prompt.respond_to?(:with_terminal_handoff)
172
+ @prompt.with_terminal_handoff do |input, output|
173
+ runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, output: output)
174
+ end
175
+ else
176
+ runner.run(shell, "-c", command, env: env, cwd: cwd)
177
+ end
178
+ end
179
+
180
+ def interactive_pty_environment(configured_env)
181
+ ENV.to_h.merge(configured_env.to_h.transform_keys(&:to_s).transform_values(&:to_s)).tap do |env|
182
+ env.delete("GIT_PAGER") if env["GIT_PAGER"] == "cat"
183
+ env["TERM"] = "xterm-256color" if env["TERM"].to_s.empty? || env["TERM"] == "dumb"
184
+ end
185
+ end
186
+
187
+ def run_ekwsh_loop(shell, tab: nil, history: nil)
188
+ with_ekwsh_history(history) do
189
+ run_ekwsh_loop_with_history(shell, tab: tab)
190
+ end
191
+ end
192
+
193
+ def run_ekwsh_loop_with_history(shell, tab: nil)
194
+ loop do
195
+ if @prompt.respond_to?(:editing_file?) && @prompt.editing_file?
196
+ editor_result = @prompt.run_editor
197
+ if editor_result.is_a?(Hash) && editor_result[:tab_action]
198
+ (@pending_inputs ||= []).unshift(editor_result)
199
+ return :tab_action
200
+ end
201
+ end
202
+
203
+ input = ask_ekwsh(shell)
204
+ if input.is_a?(Hash) && input[:tab_action]
205
+ (@pending_inputs ||= []).unshift(input)
206
+ return :tab_action
207
+ end
208
+ break if input.nil?
209
+
210
+ result = run_ekwsh_command(shell, input)
211
+ @prompt.clear_transcript if result.clear && @prompt.respond_to?(:clear_transcript)
212
+ @prompt.say(result.output) unless result.streamed || result.interactive_command || result.output.to_s.empty?
213
+ return :tab_action if pending_tab_action?
214
+
215
+ if result.open_editor_path
216
+ editor_result = open_ekwsh_editor(result.open_editor_path, shell)
217
+ return :tab_action if editor_result == :tab_action
218
+
219
+ next
220
+ end
221
+ if result.interactive_command
222
+ run_ekwsh_interactive_pty_command(shell, result)
223
+ next
224
+ end
225
+ if result.exit_shell
226
+ tab.shell = nil if tab
227
+ runtime_output("Shell exited.")
228
+ return :exited
229
+ end
230
+ end
231
+ tab.shell = nil if tab
232
+ runtime_output("Shell exited.")
233
+ :exited
234
+ end
235
+
236
+ def with_ekwsh_history(history)
237
+ if history && @prompt.respond_to?(:with_prompt_history)
238
+ @prompt.with_prompt_history(history) { yield }
239
+ else
240
+ yield
241
+ end
242
+ end
243
+
244
+ def open_ekwsh_editor(path, shell)
245
+ unless @prompt.respond_to?(:edit_file)
246
+ runtime_output("Integrated editor is unavailable in this prompt.")
247
+ return false
248
+ end
249
+
250
+ result = @prompt.edit_file(path, base_dir: shell.cwd, allow_new: true)
251
+ if result.is_a?(Hash) && result[:tab_action]
252
+ (@pending_inputs ||= []).unshift(result)
253
+ return :tab_action
254
+ end
255
+
256
+ result
257
+ end
258
+
259
+ def ask_ekwsh(shell)
260
+ provider = ->(input, cursor) { shell.complete(input, cursor) }
261
+ if @prompt.respond_to?(:with_completion_provider)
262
+ @prompt.with_completion_provider(provider, slash_overlay: false) { @prompt.ask(shell.prompt_label) }
263
+ else
264
+ @prompt.ask(shell.prompt_label)
265
+ end
266
+ end
267
+
268
+ def run_ekwsh_interactive_pty_command(shell, result)
269
+ @prompt.say(result.output) unless result.output.to_s.empty?
270
+ pty_result = run_interactive_pty_with_terminal_handoff(
271
+ shell.command_shell,
272
+ result.interactive_command,
273
+ env: shell.child_env(interactive: true),
274
+ cwd: shell.cwd
275
+ )
276
+ @prompt.say("[interactive PTY session exited with status #{pty_result.exit_status}]\n") if @prompt.respond_to?(:say)
277
+ end
278
+
279
+ def run_ekwsh_command(shell, input)
280
+ if @prompt.respond_to?(:begin_busy_input)
281
+ @prompt.begin_busy_input(shell.prompt_label, activity: "running")
282
+ end
283
+ if @prompt.respond_to?(:write_transcript_delta) && @prompt.respond_to?(:poll_input)
284
+ run_streaming_ekwsh_command(shell, input)
285
+ elsif @prompt.respond_to?(:write_transcript_delta)
286
+ shell.run(input) { |chunk| @prompt.write_transcript_delta(chunk) }
287
+ else
288
+ shell.run(input)
289
+ end
290
+ ensure
291
+ @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
292
+ end
293
+
294
+ def run_streaming_ekwsh_command(shell, input)
295
+ cancellation = Cancellation.new
296
+ chunks = Queue.new
297
+ queued_inputs = []
298
+ result = nil
299
+ error = nil
300
+ worker = Thread.new do
301
+ result = shell.run(input, cancellation: cancellation) { |chunk| chunks << chunk }
302
+ rescue StandardError => e
303
+ error = e
304
+ end
305
+ worker.report_on_exception = false
306
+
307
+ while worker.alive?
308
+ drain_ekwsh_chunks(chunks)
309
+ poll_result = collect_queued_input(queued_inputs)
310
+ if poll_result == PromptInterface::CANCEL_INPUT
311
+ cancellation.cancel!
312
+ elsif poll_result.is_a?(Hash) && poll_result[:tab_action]
313
+ (@pending_inputs ||= []).unshift(poll_result)
314
+ cancellation.cancel!
315
+ end
316
+ sleep 0.01
317
+ end
318
+ worker.join
319
+ drain_ekwsh_chunks(chunks)
320
+ raise error if error
321
+
322
+ queued_inputs.reverse_each { |pending_input| (@pending_inputs ||= []).unshift(pending_input) }
323
+ result
324
+ end
325
+
326
+ def drain_ekwsh_chunks(chunks)
327
+ loop do
328
+ @prompt.write_transcript_delta(chunks.pop(true))
329
+ rescue ThreadError
330
+ break
331
+ end
332
+ end
333
+
334
+ def pending_tab_action?
335
+ @pending_inputs&.first.is_a?(Hash) && @pending_inputs.first[:tab_action]
336
+ end
337
+
76
338
  def configured_workspace(root: current_workspace_root)
77
339
  Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
78
340
  end
@@ -146,10 +408,12 @@ module Kward
146
408
  @client.reload_config if @client.respond_to?(:reload_config)
147
409
  end
148
410
 
149
- def refresh_conversation_runtime(conversation)
411
+ def refresh_conversation_runtime(conversation, reasoning_effort: current_reasoning_effort, refresh_system_message: true)
150
412
  return unless conversation&.respond_to?(:update_runtime_context!)
151
413
 
152
- conversation.update_runtime_context!(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
414
+ runtime_changed = [conversation.provider, conversation.model, conversation.reasoning_effort] != [current_model_provider, current_model_id, reasoning_effort]
415
+ conversation.update_runtime_context!(provider: current_model_provider, model: current_model_id, reasoning_effort: reasoning_effort, refresh: refresh_system_message)
416
+ conversation.persist_runtime_context! if runtime_changed && conversation.respond_to?(:persist_runtime_context!)
153
417
  update_assistant_prompt(conversation)
154
418
  end
155
419
 
@@ -162,7 +426,7 @@ module Kward
162
426
  end
163
427
 
164
428
  def default_session_name(input)
165
- input.to_s.gsub(/\s+/, " ").strip.slice(0, 120).to_s
429
+ SessionNaming.default_name(input)
166
430
  end
167
431
 
168
432
  end
@@ -12,7 +12,7 @@ module Kward
12
12
  return @session_store if @session_store
13
13
  return nil if agent
14
14
 
15
- SessionStore.new
15
+ @session_store = SessionStore.new
16
16
  end
17
17
 
18
18
  def resume_last_session(session_store)
@@ -69,7 +69,7 @@ module Kward
69
69
  end
70
70
 
71
71
  def mutation_tool_call?(tool_call)
72
- ["edit_file", "write_file", "edit", "write"].include?(ToolCall.name(tool_call).to_s)
72
+ ToolCall.file_change_tool?(ToolCall.name(tool_call))
73
73
  end
74
74
 
75
75
  def cleanup_unused_sessions
@@ -222,6 +222,21 @@ module Kward
222
222
  when /\Ashow busy help/, /\Ahide busy help/
223
223
  set_composer_busy_help(!composer_busy_help?)
224
224
  runtime_output("Busy help #{composer_busy_help? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.")
225
+ when /\Atab keybindings/
226
+ configure_tab_keybindings
227
+ when /\Aeditor mode/
228
+ configure_editor_mode
229
+ when /\Aeditor line numbers/
230
+ configure_editor_line_numbers
231
+ when /\Aenable auto-close pairs/, /\Adisable auto-close pairs/
232
+ set_editor_auto_close_pairs_enabled(!editor_auto_close_pairs_enabled?)
233
+ runtime_output("Editor auto-close pairs #{editor_auto_close_pairs_enabled? ? "enabled" : "disabled"}.")
234
+ when /\Aenable soft-wrap/, /\Adisable soft-wrap/
235
+ set_editor_soft_wrap_enabled(!editor_soft_wrap_enabled?)
236
+ runtime_output("Editor soft-wrap #{editor_soft_wrap_enabled? ? "enabled" : "disabled"}.")
237
+ when /\Aenable bar cursor/, /\Adisable bar cursor/
238
+ set_editor_bar_cursor_enabled(!editor_bar_cursor_enabled?)
239
+ runtime_output("Editor bar cursor #{editor_bar_cursor_enabled? ? "enabled" : "disabled"}.")
225
240
  when /\Aenable session auto-resume/, /\Adisable session auto-resume/
226
241
  set_session_auto_resume_enabled(!session_auto_resume_enabled?)
227
242
  runtime_output("Session auto-resume #{session_auto_resume_enabled? ? "enabled" : "disabled"}.")
@@ -234,6 +249,12 @@ module Kward
234
249
  "Overlay alignment (#{settings["alignment"]})",
235
250
  "Overlay width (#{settings["width"]})",
236
251
  "#{composer_busy_help? ? "Hide" : "Show"} busy help (currently #{on_off(composer_busy_help?)})",
252
+ "Tab keybindings (#{composer_tab_keybindings})",
253
+ "Editor mode (#{editor_mode})",
254
+ "Editor line numbers (#{editor_line_numbers})",
255
+ "#{editor_auto_close_pairs_enabled? ? "Disable" : "Enable"} auto-close pairs (currently #{on_off(editor_auto_close_pairs_enabled?)})",
256
+ "#{editor_soft_wrap_enabled? ? "Disable" : "Enable"} soft-wrap (currently #{on_off(editor_soft_wrap_enabled?)})",
257
+ "#{editor_bar_cursor_enabled? ? "Disable" : "Enable"} bar cursor (currently #{on_off(editor_bar_cursor_enabled?)})",
237
258
  "#{session_auto_resume_enabled? ? "Disable" : "Enable"} session auto-resume (currently #{on_off(session_auto_resume_enabled?)})",
238
259
  "Back"
239
260
  ]
@@ -243,6 +264,84 @@ module Kward
243
264
  ConfigFiles.composer_busy_help?(safely_read_config.to_h)
244
265
  end
245
266
 
267
+ def composer_tab_keybindings
268
+ ConfigFiles.composer_tab_keybindings(safely_read_config.to_h)
269
+ end
270
+
271
+ def configure_tab_keybindings
272
+ selected = @prompt.select("Tab keybindings", tab_keybinding_choices, title: "Settings")
273
+ value = selected.to_s.split.first.to_s.downcase
274
+ return unless %w[auto ctrl alt].include?(value)
275
+
276
+ update_nested_config("composer", "tab_keybindings" => value)
277
+ runtime_output("Tab keybindings set to #{value}. Restart the TUI to apply this setting.")
278
+ end
279
+
280
+ def tab_keybinding_choices
281
+ current = composer_tab_keybindings
282
+ %w[auto ctrl alt].map { |value| value == current ? "#{value} (current)" : value }
283
+ end
284
+
285
+ def editor_mode
286
+ ConfigFiles.editor_mode(safely_read_config.to_h)
287
+ end
288
+
289
+ def configure_editor_mode
290
+ selected = @prompt.select("Editor mode", editor_mode_choices, title: "Settings")
291
+ value = selected.to_s.split.first.to_s.downcase
292
+ return unless %w[modern emacs vibe].include?(value)
293
+
294
+ update_nested_config("editor", "mode" => value)
295
+ runtime_output("Editor mode set to #{value}. New editor buffers will use this mode.")
296
+ end
297
+
298
+ def editor_mode_choices
299
+ current = editor_mode
300
+ %w[modern emacs vibe].map { |value| value == current ? "#{value} (current)" : value }
301
+ end
302
+
303
+ def editor_line_numbers
304
+ ConfigFiles.editor_line_numbers(safely_read_config.to_h)
305
+ end
306
+
307
+ def configure_editor_line_numbers
308
+ selected = @prompt.select("Editor line numbers", editor_line_number_choices, title: "Settings")
309
+ value = selected.to_s.split.first.to_s.downcase
310
+ return unless %w[absolute relative].include?(value)
311
+
312
+ update_nested_config("editor", "line_numbers" => value)
313
+ runtime_output("Editor line numbers set to #{value}.")
314
+ end
315
+
316
+ def editor_line_number_choices
317
+ current = editor_line_numbers
318
+ %w[absolute relative].map { |value| value == current ? "#{value} (current)" : value }
319
+ end
320
+
321
+ def editor_auto_close_pairs_enabled?
322
+ ConfigFiles.editor_auto_close_pairs?(safely_read_config.to_h)
323
+ end
324
+
325
+ def set_editor_auto_close_pairs_enabled(enabled)
326
+ update_nested_config("editor", "auto_close_pairs" => enabled)
327
+ end
328
+
329
+ def editor_soft_wrap_enabled?
330
+ ConfigFiles.editor_soft_wrap?(safely_read_config.to_h)
331
+ end
332
+
333
+ def set_editor_soft_wrap_enabled(enabled)
334
+ update_nested_config("editor", "soft_wrap" => enabled)
335
+ end
336
+
337
+ def editor_bar_cursor_enabled?
338
+ ConfigFiles.editor_bar_cursor?(safely_read_config.to_h)
339
+ end
340
+
341
+ def set_editor_bar_cursor_enabled(enabled)
342
+ update_nested_config("editor", "bar_cursor" => enabled)
343
+ end
344
+
246
345
  def session_auto_resume_enabled?
247
346
  ConfigFiles.session_auto_resume_enabled?(safely_read_config.to_h)
248
347
  end
@@ -455,11 +554,7 @@ module Kward
455
554
  end
456
555
 
457
556
  def update_nested_config(section, values)
458
- config = ConfigFiles.read_config
459
- current = config[section].is_a?(Hash) ? config[section].dup : {}
460
- config[section] = current.merge(values)
461
- ConfigFiles.write_config(config)
462
- config
557
+ ConfigFiles.update_nested_config(section, values)
463
558
  end
464
559
 
465
560
  def on_off(value)
@@ -526,10 +621,7 @@ module Kward
526
621
  effort, = choices.find { |_value, label| selected.to_s.downcase.start_with?(label.downcase) }
527
622
  raise "Reasoning effort must be one of: #{choices.map(&:first).join(", ")}" unless effort
528
623
 
529
- ConfigFiles.update_config(ModelInfo.reasoning_config_key_for_provider(current_model_provider) => effort)
530
- reload_client_config
531
- refresh_conversation_runtime(conversation)
532
- @prompt.redraw if @prompt.respond_to?(:redraw)
624
+ set_reasoning_effort(effort, conversation, provider: provider)
533
625
  rescue StandardError => e
534
626
  runtime_output("Reasoning error: #{e.message}")
535
627
  end
@@ -609,6 +701,122 @@ module Kward
609
701
  end
610
702
  end
611
703
 
704
+ REASONING_CONFIG_DEBOUNCE_SECONDS = 0.5
705
+
706
+ def cycle_reasoning(conversation = current_footer_conversation, direction: :next, persist: :immediate)
707
+ provider = conversation&.provider || current_model_provider
708
+ model = conversation&.model || current_model_id
709
+ choices = ModelInfo.reasoning_effort_choices(provider, model)
710
+ return false if choices.empty?
711
+
712
+ current = (pending_reasoning_effort(provider) || conversation&.reasoning_effort || current_reasoning_effort).to_s
713
+ current_index = choices.index { |effort, _label| effort == current }
714
+ current_index ||= direction == :previous ? 0 : -1
715
+ offset = direction == :previous ? -1 : 1
716
+ effort = choices[(current_index + offset) % choices.length].first
717
+ persist == :debounced ? apply_reasoning_effort(effort, conversation, provider: provider) : set_reasoning_effort(effort, conversation, provider: provider)
718
+ true
719
+ rescue StandardError => e
720
+ runtime_output("Reasoning error: #{e.message}")
721
+ false
722
+ end
723
+
724
+ def set_reasoning_effort(effort, conversation = nil, provider: nil)
725
+ @pending_reasoning_config_mutex.synchronize { @pending_reasoning_config = nil }
726
+ persist_reasoning_config(effort, provider: provider)
727
+ apply_reasoning_effort(effort, conversation, provider: provider, queue_config: false)
728
+ end
729
+
730
+ def apply_reasoning_effort(effort, conversation = nil, provider: nil, queue_config: true)
731
+ queue_reasoning_config(effort, provider: provider, conversation: conversation) if queue_config
732
+ if queue_config
733
+ update_conversation_reasoning_effort(conversation, effort)
734
+ refresh_reasoning_status
735
+ else
736
+ refresh_conversation_runtime(conversation, reasoning_effort: effort)
737
+ @prompt.redraw if @prompt.respond_to?(:redraw)
738
+ end
739
+ end
740
+
741
+ def refresh_reasoning_status
742
+ if @prompt.respond_to?(:refresh_composer_status)
743
+ @prompt.refresh_composer_status
744
+ else
745
+ @prompt.redraw if @prompt.respond_to?(:redraw)
746
+ end
747
+ end
748
+
749
+ def update_conversation_reasoning_effort(conversation, effort)
750
+ return unless conversation&.respond_to?(:update_runtime_context!)
751
+
752
+ conversation.update_runtime_context!(
753
+ provider: conversation.provider || current_model_provider,
754
+ model: conversation.model || current_model_id,
755
+ reasoning_effort: effort,
756
+ refresh: false
757
+ )
758
+ end
759
+
760
+ def pending_reasoning_effort(provider)
761
+ @pending_reasoning_config_mutex.synchronize do
762
+ pending = @pending_reasoning_config
763
+ return nil unless pending
764
+ return nil unless pending[:provider].to_s.downcase == provider.to_s.downcase
765
+
766
+ pending[:effort]
767
+ end
768
+ end
769
+
770
+ def queue_reasoning_config(effort, provider: nil, conversation: nil)
771
+ pending = {
772
+ effort: effort,
773
+ provider: provider || current_model_provider,
774
+ conversation: conversation,
775
+ deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + REASONING_CONFIG_DEBOUNCE_SECONDS
776
+ }
777
+ @pending_reasoning_config_mutex.synchronize { @pending_reasoning_config = pending }
778
+ schedule_reasoning_config_flush
779
+ end
780
+
781
+ def schedule_reasoning_config_flush
782
+ return if @pending_reasoning_config_thread&.alive?
783
+
784
+ @pending_reasoning_config_thread = Thread.new do
785
+ loop do
786
+ sleep REASONING_CONFIG_DEBOUNCE_SECONDS
787
+ break if flush_pending_reasoning_config(force: false)
788
+ break unless @pending_reasoning_config_mutex.synchronize { @pending_reasoning_config }
789
+ end
790
+ rescue StandardError => e
791
+ runtime_output("Reasoning error: #{e.message}")
792
+ end
793
+ end
794
+
795
+ def flush_pending_reasoning_config(force: true, conversation: nil)
796
+ pending = nil
797
+ @pending_reasoning_config_mutex.synchronize do
798
+ pending = @pending_reasoning_config
799
+ return false unless pending
800
+
801
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
802
+ return false if !force && now < pending[:deadline].to_f
803
+
804
+ @pending_reasoning_config = nil
805
+ end
806
+ persist_reasoning_config(pending[:effort], provider: pending[:provider])
807
+ conversation ||= pending[:conversation]
808
+ if conversation&.reasoning_effort.to_s == pending[:effort].to_s
809
+ refresh_conversation_runtime(conversation, reasoning_effort: pending[:effort])
810
+ conversation.persist_runtime_context! if conversation.respond_to?(:persist_runtime_context!)
811
+ end
812
+ true
813
+ end
814
+
815
+ def persist_reasoning_config(effort, provider: nil)
816
+ ConfigFiles.update_config(ModelInfo.reasoning_config_key_for_provider(provider || current_model_provider) => effort)
817
+ reload_client_config
818
+ end
819
+
612
820
  def reasoning_choices(choices, conversation = current_footer_conversation)
613
821
  current = (conversation.reasoning_effort || (@client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT)).to_s
614
822
  choices.map do |effort, label|