kward 0.71.0 → 0.72.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -0
  5. data/doc/agent-tools.md +15 -6
  6. data/doc/authentication.md +22 -1
  7. data/doc/code-search.md +42 -2
  8. data/doc/configuration.md +106 -3
  9. data/doc/context-budgeting.md +136 -0
  10. data/doc/context-tools.md +16 -3
  11. data/doc/editor.md +394 -0
  12. data/doc/extensibility.md +16 -7
  13. data/doc/files.md +100 -0
  14. data/doc/getting-started.md +25 -18
  15. data/doc/git.md +122 -0
  16. data/doc/memory.md +24 -4
  17. data/doc/personas.md +34 -5
  18. data/doc/plugins.md +72 -1
  19. data/doc/releasing.md +37 -9
  20. data/doc/rpc.md +74 -4
  21. data/doc/session-management.md +35 -1
  22. data/doc/shell.md +286 -0
  23. data/doc/tabs.md +122 -0
  24. data/doc/troubleshooting.md +77 -1
  25. data/doc/usage.md +53 -7
  26. data/doc/web-search.md +12 -4
  27. data/doc/workspace-tools.md +51 -12
  28. data/examples/plugins/space_invaders.rb +377 -0
  29. data/lib/kward/agent.rb +1 -1
  30. data/lib/kward/cli/commands.rb +33 -2
  31. data/lib/kward/cli/git.rb +150 -0
  32. data/lib/kward/cli/interactive_turn.rb +73 -9
  33. data/lib/kward/cli/plugins.rb +54 -4
  34. data/lib/kward/cli/prompt_interface.rb +32 -1
  35. data/lib/kward/cli/runtime_helpers.rb +133 -3
  36. data/lib/kward/cli/sessions.rb +2 -2
  37. data/lib/kward/cli/settings.rb +218 -9
  38. data/lib/kward/cli/slash_commands.rb +415 -2
  39. data/lib/kward/cli/tabs.rb +695 -0
  40. data/lib/kward/cli.rb +158 -26
  41. data/lib/kward/config_files.rb +123 -1
  42. data/lib/kward/context_budget_meter.rb +44 -0
  43. data/lib/kward/conversation.rb +12 -4
  44. data/lib/kward/editor_mode.rb +25 -0
  45. data/lib/kward/ekwsh.rb +362 -0
  46. data/lib/kward/plugin_registry.rb +61 -0
  47. data/lib/kward/project_files.rb +52 -0
  48. data/lib/kward/prompt_history.rb +82 -0
  49. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  50. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  51. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  52. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  53. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  54. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  55. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  56. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  57. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  58. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  59. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  60. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  61. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  62. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  63. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  64. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  65. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  66. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  67. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  68. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  69. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  70. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  71. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  72. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  73. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  74. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  75. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  76. data/lib/kward/prompt_interface/key_handler.rb +387 -35
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  78. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +98 -50
  80. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  81. data/lib/kward/prompt_interface/screen.rb +16 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
  83. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  84. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  85. data/lib/kward/prompt_interface.rb +286 -8
  86. data/lib/kward/prompts/commands.rb +5 -0
  87. data/lib/kward/prompts.rb +2 -0
  88. data/lib/kward/rpc/server.rb +42 -3
  89. data/lib/kward/rpc/session_manager.rb +35 -47
  90. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  91. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  92. data/lib/kward/session_store.rb +44 -0
  93. data/lib/kward/session_tree_nodes.rb +136 -0
  94. data/lib/kward/session_tree_renderer.rb +9 -131
  95. data/lib/kward/tab_store.rb +47 -0
  96. data/lib/kward/text_boundary.rb +25 -0
  97. data/lib/kward/tools/context_budget_stats.rb +54 -0
  98. data/lib/kward/tools/context_for_task.rb +202 -0
  99. data/lib/kward/tools/read_file.rb +8 -4
  100. data/lib/kward/tools/registry.rb +62 -16
  101. data/lib/kward/tools/tool_call.rb +10 -0
  102. data/lib/kward/version.rb +1 -1
  103. data/lib/kward/workers/git_guard.rb +68 -0
  104. data/lib/kward/workers/live_view.rb +49 -0
  105. data/lib/kward/workers/manager.rb +288 -0
  106. data/lib/kward/workers/store.rb +72 -0
  107. data/lib/kward/workers/tool_policy.rb +23 -0
  108. data/lib/kward/workers/worker.rb +82 -0
  109. data/lib/kward/workers/write_lock.rb +38 -0
  110. data/lib/kward/workers.rb +7 -0
  111. data/lib/kward/workspace.rb +110 -24
  112. data/templates/default/fulldoc/html/css/kward.css +107 -36
  113. data/templates/default/kward_navigation.rb +12 -1
  114. data/templates/default/layout/html/layout.erb +4 -2
  115. data/templates/default/layout/html/setup.rb +6 -0
  116. metadata +53 -1
@@ -0,0 +1,695 @@
1
+ require "thread"
2
+ require_relative "../cancellation"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
7
+ class CLI
8
+ # TUI session tab coordination and asynchronous turn execution.
9
+ module Tabs
10
+ TabRuntime = Struct.new(
11
+ :session,
12
+ :agent,
13
+ :diff,
14
+ :snapshot,
15
+ :status,
16
+ :thread,
17
+ :cancellation,
18
+ :event_history,
19
+ :seen_events,
20
+ :queued_inputs,
21
+ :steering,
22
+ :error,
23
+ :answer,
24
+ :stream_state,
25
+ :markdown_chunks,
26
+ :label,
27
+ :unread,
28
+ :pending_question,
29
+ :shell,
30
+ keyword_init: true
31
+ ) do
32
+ def running?
33
+ %w[queued running waiting_for_question].include?(status.to_s)
34
+ end
35
+
36
+ def idle?
37
+ !running?
38
+ end
39
+
40
+ def record_event(event)
41
+ event_history << event
42
+ end
43
+ end
44
+
45
+ class TabQuestionPrompt
46
+ attr_accessor :tab
47
+
48
+ def initialize(cli)
49
+ @cli = cli
50
+ end
51
+
52
+ def ask_user_question(questions, cancellation: nil)
53
+ @cli.send(:ask_tab_user_question, @tab, questions, cancellation: cancellation)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def setup_interactive_tabs(session_store, agent)
60
+ @tabs = []
61
+ @active_tab_index = 0
62
+ @tab_store = session_store ? TabStore.new(config_dir: session_store.config_dir, cwd: session_store.cwd) : nil
63
+ @tab_live_view = nil
64
+ @restored_tabs = false
65
+ restored = restore_tabs(session_store) if session_store && agent.nil?
66
+ return restored if restored
67
+
68
+ if agent.nil? && (resumed_agent = resume_last_session(session_store))
69
+ release_implementation_writer
70
+ @tabs << build_tab(@active_session, build_tab_agent(resumed_agent.conversation, @active_session), label: default_tab_label(0))
71
+ return activate_tab(0, render: false)
72
+ end
73
+
74
+ if agent
75
+ @active_session = track_session(session_store.create(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort))
76
+ @active_session.attach(agent.conversation)
77
+ tab_agent = build_tab_agent(agent.conversation, @active_session)
78
+ else
79
+ @active_session = track_session(session_store.create(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort))
80
+ conversation = new_conversation(workspace_root: session_store.cwd)
81
+ @active_session.attach(conversation)
82
+ tab_agent = build_tab_agent(conversation, @active_session)
83
+ end
84
+ @tabs << build_tab(@active_session, tab_agent, label: default_tab_label(0))
85
+ activate_tab(0, render: false)
86
+ end
87
+
88
+ def restore_tabs(session_store)
89
+ data = @tab_store&.load || {}
90
+ paths = Array(data["session_paths"]).map(&:to_s)
91
+ return nil if paths.empty?
92
+
93
+ paths.each_with_index do |path, index|
94
+ session, conversation = restore_tab_session(session_store, path)
95
+ tab = build_tab(session, build_tab_agent(conversation, session), label: restored_tab_label(data, index))
96
+ @tabs << tab
97
+ rescue StandardError
98
+ next
99
+ end
100
+ return nil if @tabs.empty?
101
+
102
+ @active_tab_index = [[data["active_index"].to_i, 0].max, @tabs.length - 1].min
103
+ @restored_tabs = true
104
+ activate_tab(@active_tab_index)
105
+ end
106
+
107
+ def restore_tab_session(session_store, path)
108
+ if File.file?(path)
109
+ 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)
110
+ return [track_session(session), conversation]
111
+ end
112
+
113
+ session = track_session(session_store.create(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort))
114
+ conversation = new_conversation(workspace_root: session_store.cwd)
115
+ session.attach(conversation)
116
+ [session, conversation]
117
+ end
118
+
119
+ def build_tab_agent(conversation, _session)
120
+ conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
121
+ workspace = configured_workspace(root: conversation.workspace_root)
122
+ prompt = TabQuestionPrompt.new(self)
123
+ tool_registry = ToolRegistry.new(workspace: workspace, prompt: prompt)
124
+ @footer_conversation = conversation
125
+ @footer_tool_registry = tool_registry
126
+ agent = Agent.new(client: @client, tool_registry: tool_registry, conversation: conversation)
127
+ agent.instance_variable_set(:@tab_question_prompt, prompt)
128
+ agent
129
+ end
130
+
131
+ def build_tab(session, agent, label: nil)
132
+ TabRuntime.new(
133
+ session: session,
134
+ agent: agent,
135
+ diff: session&.path ? SessionDiff.from_session_file(session.path) : SessionDiff.new,
136
+ snapshot: nil,
137
+ status: "idle",
138
+ thread: nil,
139
+ cancellation: nil,
140
+ event_history: [],
141
+ seen_events: 0,
142
+ queued_inputs: [],
143
+ steering: nil,
144
+ error: nil,
145
+ answer: nil,
146
+ stream_state: new_tab_stream_state(agent),
147
+ markdown_chunks: [],
148
+ label: label,
149
+ unread: false,
150
+ pending_question: nil,
151
+ shell: nil
152
+ ).tap { |tab| assign_tab_question_prompt(agent, tab) }
153
+ end
154
+
155
+ def active_tab
156
+ @tabs && @tabs[@active_tab_index]
157
+ end
158
+
159
+ def assign_tab_question_prompt(agent, tab)
160
+ prompt = agent.instance_variable_get(:@tab_question_prompt) if agent
161
+ prompt.tab = tab if prompt.respond_to?(:tab=)
162
+ end
163
+
164
+ def ask_tab_user_question(tab, questions, cancellation: nil)
165
+ return @prompt.ask_user_question(questions) unless tab
166
+ return "Error: ask_user_question requires interactive prompt support." unless @prompt.respond_to?(:ask_user_question)
167
+
168
+ request = {
169
+ questions: questions,
170
+ answers: Queue.new,
171
+ cancellation: cancellation
172
+ }
173
+ cancellation&.on_cancel { request[:answers] << nil }
174
+ tab.pending_question = request
175
+ tab.status = "waiting_for_question"
176
+ update_prompt_tabs
177
+
178
+ answers = request[:answers].pop
179
+ cancellation&.raise_if_cancelled!
180
+ answers
181
+ ensure
182
+ if tab&.pending_question.equal?(request)
183
+ tab.pending_question = nil
184
+ tab.status = "running" if tab.status == "waiting_for_question"
185
+ update_prompt_tabs
186
+ end
187
+ end
188
+
189
+ def service_active_tab_question
190
+ tab = active_tab
191
+ request = tab&.pending_question
192
+ return false unless request
193
+
194
+ tab.pending_question = nil
195
+ answers = @prompt.ask_user_question(request[:questions])
196
+ tab.status = "running" if tab.status == "waiting_for_question"
197
+ update_prompt_tabs
198
+ request[:answers] << answers
199
+ true
200
+ end
201
+
202
+ def handle_tab_action(action, session_store)
203
+ case action[:tab_action]
204
+ when :new
205
+ open_new_tab(session_store)
206
+ when :close
207
+ close_active_tab
208
+ when :next
209
+ switch_tab((@active_tab_index + 1) % @tabs.length) if @tabs && @tabs.length > 1
210
+ when :previous
211
+ switch_tab((@active_tab_index - 1) % @tabs.length) if @tabs && @tabs.length > 1
212
+ when :select
213
+ switch_tab(action[:index].to_i) if @tabs && action[:index].to_i < @tabs.length
214
+ end
215
+ end
216
+
217
+ def open_new_tab(session_store)
218
+ return say_sessions_unavailable unless session_store
219
+
220
+ save_active_tab_state
221
+ stop_tab_live_view
222
+ session = track_session(session_store.create(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort))
223
+ conversation = new_conversation(workspace_root: session_store.cwd)
224
+ session.attach(conversation)
225
+ @tabs << build_tab(session, build_tab_agent(conversation, session), label: default_tab_label(@tabs.length))
226
+ @active_tab_index = @tabs.length - 1
227
+ activate_tab(@active_tab_index)
228
+ end
229
+
230
+ def close_active_tab
231
+ tab = active_tab
232
+ if tab&.running?
233
+ runtime_output("Tab #{active_tab_number} is running and cannot be closed yet.")
234
+ return nil
235
+ end
236
+
237
+ if @tabs.length <= 1
238
+ @tabs.clear
239
+ persist_tabs
240
+ return PromptInterface::EXIT_INPUT
241
+ end
242
+
243
+ stop_tab_live_view
244
+ tab.session&.delete_if_unused if tab&.session.respond_to?(:delete_if_unused)
245
+ @tabs.delete_at(@active_tab_index)
246
+ @active_tab_index = [@active_tab_index, @tabs.length - 1].min
247
+ activate_tab(@active_tab_index)
248
+ nil
249
+ end
250
+
251
+ def switch_tab(index)
252
+ return if index == @active_tab_index
253
+ return unless index.between?(0, @tabs.length - 1)
254
+
255
+ save_active_tab_state
256
+ stop_tab_live_view
257
+ @active_tab_index = index
258
+ activate_tab(index)
259
+ end
260
+
261
+ def replace_active_tab_agent(agent)
262
+ tab = active_tab
263
+ return agent unless tab
264
+
265
+ tab.session = @active_session
266
+ tab.agent = build_tab_agent(agent.conversation, tab.session)
267
+ assign_tab_question_prompt(tab.agent, tab)
268
+ tab.diff = @session_diff || (tab.session&.path ? SessionDiff.from_session_file(tab.session.path) : SessionDiff.new)
269
+ tab.snapshot = nil
270
+ tab.status = "idle"
271
+ tab.error = nil
272
+ tab.answer = nil
273
+ tab.unread = false
274
+ tab.event_history.clear
275
+ tab.seen_events = 0
276
+ tab.queued_inputs.clear
277
+ tab.steering = nil
278
+ tab.shell = nil
279
+ tab.stream_state = new_tab_stream_state(tab.agent)
280
+ tab.markdown_chunks.clear
281
+ update_prompt_tabs
282
+ persist_tabs
283
+ tab.agent
284
+ end
285
+
286
+ def save_active_tab_state
287
+ tab = active_tab
288
+ return unless tab
289
+
290
+ if @prompt.respond_to?(:tab_view_snapshot)
291
+ tab.snapshot = @prompt.tab_view_snapshot
292
+ elsif @prompt.respond_to?(:composer_snapshot)
293
+ tab.snapshot = @prompt.composer_snapshot
294
+ end
295
+ tab.diff = @session_diff
296
+ end
297
+
298
+ def activate_tab(index, render: true)
299
+ tab = @tabs[index]
300
+ return nil unless tab
301
+
302
+ @active_session = tab.session
303
+ @session_diff = tab.diff || SessionDiff.new
304
+ @footer_conversation = tab.agent.conversation
305
+ @footer_tool_registry = tab.agent.tool_registry if tab.agent.respond_to?(:tool_registry)
306
+ update_assistant_prompt(tab.agent.conversation)
307
+ tab.unread = false
308
+ restore_tab_composer_snapshot(tab.snapshot)
309
+ update_prompt_tabs
310
+ render_tab(tab) if render
311
+ start_tab_live_view(tab) if tab.running?
312
+ persist_tabs
313
+ service_active_tab_question
314
+ tab.agent
315
+ end
316
+
317
+ def render_tab(tab)
318
+ if tab.snapshot && @prompt.respond_to?(:restore_tab_view_snapshot) && (tab.running? || tab.shell)
319
+ @prompt.restore_tab_view_snapshot(tab.snapshot)
320
+ return
321
+ end
322
+
323
+ restore_prompt_transcript do
324
+ if empty_tab_conversation?(tab.agent.conversation)
325
+ print_visual_banner
326
+ else
327
+ render_conversation_transcript(tab.agent.conversation)
328
+ end
329
+ render_tab_error(tab) if tab.status == "failed"
330
+ end
331
+ restore_tab_composer_snapshot(tab.snapshot)
332
+ end
333
+
334
+ def restore_tab_composer_snapshot(snapshot)
335
+ return unless @prompt.respond_to?(:restore_composer_snapshot)
336
+
337
+ @prompt.restore_composer_snapshot(snapshot || {})
338
+ end
339
+
340
+ def empty_tab_conversation?(conversation)
341
+ conversation.messages.none? do |message|
342
+ role = message["role"] || message[:role]
343
+ role != "system"
344
+ end
345
+ end
346
+
347
+ def render_tab_error(tab)
348
+ runtime_output("Tab #{active_tab_number} error: #{tab.error}") unless tab.error.to_s.empty?
349
+ end
350
+
351
+ def submit_tab_input(tab, input, display_input: nil)
352
+ return if input.to_s.strip.empty?
353
+
354
+ save_active_tab_state
355
+ start_tab_turn(tab, input, display_input: display_input)
356
+ start_tab_live_view(tab) if tab == active_tab
357
+ end
358
+
359
+ def start_tab_turn(tab, input, display_input: nil)
360
+ stop_live_worker_view if respond_to?(:stop_live_worker_view, true)
361
+ prepare_memory_context(tab.agent.conversation, input) if tab.agent.respond_to?(:conversation)
362
+ print_user_transcript(input, display_input: display_input) if prompt_interface?
363
+ tab.status = "queued"
364
+ tab.unread = false
365
+ tab.cancellation = Cancellation.new
366
+ tab.steering = steering_supported? ? Steering.new : nil
367
+ tab.error = nil
368
+ tab.answer = nil
369
+ tab.event_history.clear
370
+ tab.seen_events = 0
371
+ tab.markdown_chunks.clear
372
+ tab.stream_state = new_tab_stream_state(tab.agent)
373
+ update_prompt_tabs
374
+ tab.thread = Thread.new { run_tab_turn(tab, input, display_input: display_input) }
375
+ tab.thread.report_on_exception = false
376
+ update_prompt_tabs
377
+ end
378
+
379
+ def run_tab_turn(tab, input, display_input: nil)
380
+ options = agent_display_options(display_input)
381
+ options[:cancellation] = tab.cancellation
382
+ options[:steering] = tab.steering if tab.steering
383
+ tab.status = "running"
384
+ update_prompt_tabs
385
+ tab.answer = tab.agent.ask(input, **options) do |event|
386
+ tab.record_event(event)
387
+ end
388
+ tab.status = "ready"
389
+ tab.unread = tab != active_tab
390
+ rescue Cancellation::CancelledError
391
+ tab.status = "cancelled"
392
+ tab.unread = false
393
+ rescue StandardError => e
394
+ tab.error = e.message
395
+ tab.status = "failed"
396
+ tab.unread = false
397
+ ensure
398
+ finish_tab_turn(tab)
399
+ end
400
+
401
+ def finish_tab_turn(tab)
402
+ persist_memory_state(tab.agent.conversation) if tab.agent.respond_to?(:conversation)
403
+ auto_summarize_memory(tab.agent.conversation) if tab.agent.respond_to?(:conversation) && tab.queued_inputs.empty? && tab.status == "ready"
404
+ tab.diff = tab.session&.path ? SessionDiff.from_session_file(tab.session.path) : tab.diff
405
+ update_prompt_tabs
406
+ rescue StandardError
407
+ nil
408
+ end
409
+
410
+ def poll_active_tab_input
411
+ tab = active_tab
412
+ unless tab&.running?
413
+ input = @prompt.ask("You>")
414
+ return { tab_action: :close } if input.nil? && tab && @tabs.length > 1
415
+
416
+ return input
417
+ end
418
+
419
+ @prompt.begin_busy_input("You>") if @prompt.respond_to?(:begin_busy_input)
420
+ loop do
421
+ refresh_active_tab
422
+ if service_active_tab_question
423
+ return next_tab_queued_input(tab) if tab.idle? && !tab.queued_inputs.empty?
424
+ return :tab_idle if tab.idle?
425
+
426
+ next
427
+ end
428
+ poll_result = @prompt.poll_input
429
+ case poll_result
430
+ when Hash
431
+ if poll_result[:tab_action]
432
+ @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
433
+ return poll_result
434
+ end
435
+ when PromptInterface::CANCEL_INPUT
436
+ tab.cancellation&.cancel!
437
+ tab.thread&.raise(Cancellation::CancelledError, "cancelled") if tab.thread&.alive?
438
+ when PromptInterface::EXIT_INPUT
439
+ tab.queued_inputs << "/exit"
440
+ @prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
441
+ when String
442
+ handle_tab_busy_input(tab, poll_result)
443
+ end
444
+ return next_tab_queued_input(tab) if tab.idle? && !tab.queued_inputs.empty?
445
+ return :tab_idle if tab.idle?
446
+
447
+ sleep 0.01 if poll_result.nil?
448
+ end
449
+ ensure
450
+ @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input) && tab&.idle?
451
+ end
452
+
453
+ def next_tab_queued_input(tab)
454
+ input = tab.queued_inputs.shift
455
+ tab.queued_inputs.reverse_each { |queued| @pending_inputs.unshift(queued) } if defined?(@pending_inputs) && @pending_inputs
456
+ tab.queued_inputs.clear
457
+ input
458
+ end
459
+
460
+ def handle_tab_busy_input(tab, input)
461
+ command = input.to_s.strip
462
+ if command == "/workers" || command.start_with?("/workers ") || command == "/tab" || command.start_with?("/tab ")
463
+ _handled, replacement_agent = handle_local_slash_command(command, tab.agent, @session_store)
464
+ tab.agent = replacement_agent if replacement_agent?(replacement_agent)
465
+ restore_busy_input_prompt
466
+ return
467
+ end
468
+
469
+ if slash_command_input?(input)
470
+ tab.queued_inputs << input
471
+ @prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
472
+ return
473
+ end
474
+
475
+ if tab.steering && !input.to_s.strip.empty?
476
+ begin
477
+ tab.steering.submit(input)
478
+ @prompt.set_steered_count(1) if @prompt.respond_to?(:set_steered_count)
479
+ return
480
+ rescue StandardError
481
+ # Fall through to queueing.
482
+ end
483
+ end
484
+ tab.queued_inputs << input unless input.to_s.strip.empty?
485
+ @prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
486
+ end
487
+
488
+ def refresh_active_tab
489
+ tab = active_tab
490
+ return unless tab
491
+
492
+ if tab.thread && !tab.thread.alive? && tab.running?
493
+ tab.thread.join
494
+ tab.status = "ready" if tab.status == "running"
495
+ end
496
+ update_prompt_tabs
497
+ end
498
+
499
+ def start_tab_live_view(tab)
500
+ return unless prompt_interface?
501
+
502
+ stop_tab_live_view
503
+ @tab_live_view_stop = false
504
+ @tab_live_view = Thread.new { run_tab_live_view(tab) }
505
+ @tab_live_view.report_on_exception = false
506
+ end
507
+
508
+ def stop_tab_live_view
509
+ @tab_live_view_stop = true
510
+ @tab_live_view&.join(0.2)
511
+ @tab_live_view = nil
512
+ end
513
+
514
+ def run_tab_live_view(tab)
515
+ renderer = tab_live_renderer(tab)
516
+ until @tab_live_view_stop
517
+ events = tab.event_history[tab.seen_events..] || []
518
+ events.each { |event| renderer.call(event, tab.agent) }
519
+ tab.seen_events += events.length
520
+ if tab.idle?
521
+ renderer.call(:flush, tab.agent)
522
+ break
523
+ end
524
+ sleep 0.05
525
+ end
526
+ ensure
527
+ @tab_live_view_stop = false if @tab_live_view == Thread.current
528
+ end
529
+
530
+ def tab_live_renderer(tab)
531
+ lambda do |event, agent|
532
+ if event == :flush
533
+ flush_interactive_markdown_deltas(tab.markdown_chunks, tab.stream_state, force: true)
534
+ render_tab_answer(tab)
535
+ @prompt.redraw if @prompt.respond_to?(:redraw)
536
+ next
537
+ end
538
+
539
+ notify_plugin_transcript_event(event, agent.respond_to?(:conversation) ? agent.conversation : nil)
540
+ handle_interactive_event(event, tab.markdown_chunks, tab.stream_state)
541
+ flush_interactive_markdown_deltas(tab.markdown_chunks, tab.stream_state)
542
+ rescue StandardError => e
543
+ runtime_output("Tab view error: #{e.message}")
544
+ end
545
+ end
546
+
547
+ def render_tab_answer(tab)
548
+ return unless tab.status == "ready"
549
+ return if tab.stream_state[:streamed]
550
+ return if tab.answer.to_s.empty?
551
+
552
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(tab.answer)}\n")
553
+ end
554
+
555
+ def new_tab_stream_state(agent)
556
+ {
557
+ streamed: false,
558
+ last_flush: monotonic_now,
559
+ stream_block_open: false,
560
+ markdown_streams: {},
561
+ defer_assistant_streaming: defer_assistant_streaming?(agent)
562
+ }
563
+ end
564
+
565
+ def tab_labels
566
+ @tabs.each_with_index.map do |tab, index|
567
+ label = tab.label.to_s.empty? ? default_tab_label(index) : tab.label.to_s
568
+ { name: label, color: tab_label_color(tab) }
569
+ end
570
+ end
571
+
572
+ def tab_label_color(tab)
573
+ return :green if tab.status.to_s == "waiting_for_question"
574
+ return :yellow if tab.running?
575
+ return :red if %w[failed cancelled].include?(tab.status.to_s)
576
+ return :green if tab.unread
577
+
578
+ nil
579
+ end
580
+
581
+ def default_tab_label(index)
582
+ index.zero? ? "Main" : "Tab"
583
+ end
584
+
585
+ def restored_tab_label(data, index)
586
+ label = Array(data["labels"])[index].to_s
587
+ label.empty? ? default_tab_label(index) : label
588
+ end
589
+
590
+ def handle_tab_command(argument, session_store)
591
+ action, value = argument.to_s.strip.split(/\s+/, 2)
592
+ case action
593
+ when nil, ""
594
+ runtime_output("Usage: /tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab name <label>")
595
+ nil
596
+ when /^\d+$/
597
+ switch_tab_number(action)
598
+ active_tab&.agent
599
+ when "move"
600
+ move_active_tab(value)
601
+ active_tab&.agent
602
+ when "close"
603
+ @pending_inputs.unshift("/exit") if close_active_tab == PromptInterface::EXIT_INPUT && defined?(@pending_inputs) && @pending_inputs
604
+ active_tab&.agent
605
+ when "new"
606
+ open_new_tab(session_store)
607
+ active_tab&.agent
608
+ when "name", "rename"
609
+ rename_active_tab(value)
610
+ active_tab&.agent
611
+ else
612
+ runtime_output("Usage: /tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab name <label>")
613
+ nil
614
+ end
615
+ end
616
+
617
+ def switch_tab_number(number)
618
+ index = number.to_i - 1
619
+ return switch_tab(index) if @tabs && index.between?(0, @tabs.length - 1)
620
+
621
+ runtime_output("Tab #{number} does not exist.")
622
+ end
623
+
624
+ def move_active_tab(value)
625
+ return runtime_output("Usage: /tab move 1-n|left|right") unless @tabs && @tabs.length > 1
626
+
627
+ target_index = case value.to_s.strip
628
+ when /^\d+$/
629
+ value.to_i - 1
630
+ when "left"
631
+ @active_tab_index - 1
632
+ when "right"
633
+ @active_tab_index + 1
634
+ else
635
+ return runtime_output("Usage: /tab move 1-n|left|right")
636
+ end
637
+ return runtime_output("Tab #{value} does not exist.") unless target_index.between?(0, @tabs.length - 1)
638
+ return if target_index == @active_tab_index
639
+
640
+ save_active_tab_state
641
+ stop_tab_live_view
642
+ tab = @tabs.delete_at(@active_tab_index)
643
+ @tabs.insert(target_index, tab)
644
+ @active_tab_index = target_index
645
+ activate_tab(@active_tab_index)
646
+ end
647
+
648
+ def rename_active_tab(value)
649
+ tab = active_tab
650
+ return unless tab
651
+
652
+ name = value.to_s.strip
653
+ return runtime_output("Usage: /tab name <label>") if name.empty?
654
+
655
+ tab.label = name
656
+ update_prompt_tabs
657
+ persist_tabs
658
+ end
659
+
660
+ def active_tab_number
661
+ @active_tab_index.to_i + 1
662
+ end
663
+
664
+ def update_prompt_tabs
665
+ return unless @prompt.respond_to?(:update_tabs)
666
+
667
+ @prompt.update_tabs(labels: tab_labels, active_index: @active_tab_index)
668
+ end
669
+
670
+ def stop_tabs
671
+ stop_tab_live_view
672
+ Array(@tabs).each do |tab|
673
+ next unless tab&.running?
674
+
675
+ tab.cancellation&.cancel!
676
+ tab.thread&.raise(Cancellation::CancelledError, "cancelled") if tab.thread&.alive?
677
+ tab.thread&.join(0.2)
678
+ rescue StandardError
679
+ nil
680
+ end
681
+ end
682
+
683
+ def persist_tabs
684
+ return unless @tab_store
685
+
686
+ @tab_store.save(
687
+ session_paths: @tabs.map { |tab| tab.session&.path }.compact,
688
+ labels: @tabs.map { |tab| tab.label.to_s },
689
+ active_index: @active_tab_index
690
+ )
691
+ end
692
+
693
+ end
694
+ end
695
+ end