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