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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -1
- data/Gemfile.lock +2 -2
- data/README.md +4 -0
- data/doc/agent-tools.md +15 -6
- data/doc/authentication.md +22 -1
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +106 -3
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +16 -3
- data/doc/editor.md +394 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +122 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +72 -1
- data/doc/releasing.md +37 -9
- data/doc/rpc.md +74 -4
- data/doc/session-management.md +35 -1
- data/doc/shell.md +286 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +53 -7
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +51 -12
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/cli/commands.rb +33 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +32 -1
- data/lib/kward/cli/runtime_helpers.rb +133 -3
- data/lib/kward/cli/sessions.rb +2 -2
- data/lib/kward/cli/settings.rb +218 -9
- data/lib/kward/cli/slash_commands.rb +415 -2
- data/lib/kward/cli/tabs.rb +695 -0
- data/lib/kward/cli.rb +158 -26
- data/lib/kward/config_files.rb +123 -1
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +12 -4
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +362 -0
- data/lib/kward/plugin_registry.rb +61 -0
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +82 -0
- data/lib/kward/prompt_interface/composer_controller.rb +69 -1
- data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
- data/lib/kward/prompt_interface/composer_state.rb +96 -27
- data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
- data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
- data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
- data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
- data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
- data/lib/kward/prompt_interface/editor/search.rb +76 -0
- data/lib/kward/prompt_interface/editor/selections.rb +120 -0
- data/lib/kward/prompt_interface/editor/state.rb +1249 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
- data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
- data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
- data/lib/kward/prompt_interface/file_overlay.rb +211 -0
- data/lib/kward/prompt_interface/git_prompt.rb +299 -0
- data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
- data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
- data/lib/kward/prompt_interface/interactive/state.rb +62 -0
- data/lib/kward/prompt_interface/key_handler.rb +387 -35
- data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
- data/lib/kward/prompt_interface/project_browser.rb +524 -0
- data/lib/kward/prompt_interface/question_prompt.rb +98 -50
- data/lib/kward/prompt_interface/runtime_state.rb +43 -0
- data/lib/kward/prompt_interface/screen.rb +16 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
- data/lib/kward/prompt_interface.rb +286 -8
- data/lib/kward/prompts/commands.rb +5 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/rpc/server.rb +42 -3
- data/lib/kward/rpc/session_manager.rb +35 -47
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/session_store.rb +44 -0
- data/lib/kward/session_tree_nodes.rb +136 -0
- data/lib/kward/session_tree_renderer.rb +9 -131
- data/lib/kward/tab_store.rb +47 -0
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +202 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +62 -16
- data/lib/kward/tools/tool_call.rb +10 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +68 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -0
- data/lib/kward/workers/store.rb +72 -0
- data/lib/kward/workers/tool_policy.rb +23 -0
- data/lib/kward/workers/worker.rb +82 -0
- data/lib/kward/workers/write_lock.rb +38 -0
- data/lib/kward/workers.rb +7 -0
- data/lib/kward/workspace.rb +110 -24
- data/templates/default/fulldoc/html/css/kward.css +107 -36
- data/templates/default/kward_navigation.rb +12 -1
- data/templates/default/layout/html/layout.erb +4 -2
- data/templates/default/layout/html/setup.rb +6 -0
- 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
|