kward 0.78.0 → 0.80.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/.github/workflows/ci.yml +13 -2
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +82 -1
- data/Gemfile.lock +2 -2
- data/README.md +33 -38
- data/doc/agent-tools.md +10 -11
- data/doc/api.md +3 -3
- data/doc/authentication.md +79 -110
- data/doc/code-search.md +1 -1
- data/doc/composer.md +1 -1
- data/doc/configuration.md +136 -27
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +9 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +3 -3
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +130 -0
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +15 -5
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +11 -3
- data/doc/rpc.md +65 -31
- data/doc/sandboxing.md +142 -0
- data/doc/security.md +31 -10
- data/doc/session-management.md +2 -4
- data/doc/skills.md +10 -0
- data/doc/tabs.md +37 -0
- data/doc/telegram.md +138 -0
- data/doc/transports.md +209 -0
- data/doc/usage.md +11 -3
- data/doc/web-search.md +3 -3
- data/doc/workspace-tools.md +4 -4
- data/examples/plugins/stardate_footer.rb +10 -0
- data/examples/plugins/telegram/plugin.rb +39 -0
- data/examples/plugins/telegram/telegram_api.rb +136 -0
- data/examples/plugins/telegram/telegram_transport.rb +304 -0
- data/kward.gemspec +1 -1
- data/lib/kward/ansi.rb +1 -0
- data/lib/kward/auth/api_key_store.rb +99 -0
- data/lib/kward/cli/auth_commands.rb +72 -13
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/doctor.rb +21 -0
- data/lib/kward/cli/git.rb +31 -8
- data/lib/kward/cli/plugins.rb +1 -0
- data/lib/kward/cli/prompt_interface.rb +7 -0
- data/lib/kward/cli/rendering.rb +6 -2
- data/lib/kward/cli/runtime_helpers.rb +4 -2
- data/lib/kward/cli/sessions.rb +1 -1
- data/lib/kward/cli/settings.rb +162 -36
- data/lib/kward/cli/slash_commands.rb +109 -1
- data/lib/kward/cli/tabs.rb +102 -37
- data/lib/kward/cli/transports.rb +67 -0
- data/lib/kward/cli/worktrees.rb +360 -0
- data/lib/kward/cli.rb +22 -1
- data/lib/kward/config_files.rb +106 -4
- data/lib/kward/conversation.rb +27 -8
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/local_command_runner.rb +2 -2
- data/lib/kward/model/azure_openai_config.rb +54 -0
- data/lib/kward/model/catalog.rb +113 -0
- data/lib/kward/model/client.rb +364 -23
- data/lib/kward/model/model_info.rb +60 -1
- data/lib/kward/model/payloads.rb +64 -4
- data/lib/kward/model/provider_catalog.rb +114 -0
- data/lib/kward/model/sources.rb +70 -0
- data/lib/kward/model/stream_parser.rb +141 -39
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +81 -8
- data/lib/kward/private_file.rb +9 -3
- data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
- data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
- data/lib/kward/prompt_interface/editor/controller.rb +43 -10
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
- data/lib/kward/prompt_interface/editor/state.rb +3 -9
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
- data/lib/kward/prompt_interface/file_overlay.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +7 -3
- data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
- data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
- data/lib/kward/prompt_interface/interactive/state.rb +2 -2
- data/lib/kward/prompt_interface/project_browser.rb +25 -6
- data/lib/kward/prompt_interface/runtime_state.rb +22 -6
- data/lib/kward/prompt_interface/screen.rb +3 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
- data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
- data/lib/kward/prompt_interface.rb +71 -12
- data/lib/kward/prompts/commands.rb +3 -1
- data/lib/kward/prompts.rb +22 -10
- data/lib/kward/rpc/auth_manager.rb +112 -152
- data/lib/kward/rpc/config_manager.rb +46 -6
- data/lib/kward/rpc/plugin_chat_manager.rb +56 -194
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +102 -14
- data/lib/kward/rpc/session_manager.rb +151 -38
- data/lib/kward/sandbox/capabilities.rb +39 -0
- data/lib/kward/sandbox/command_runner.rb +28 -0
- data/lib/kward/sandbox/environment.rb +24 -0
- data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
- data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
- data/lib/kward/sandbox/passthrough_runner.rb +13 -0
- data/lib/kward/sandbox/policy.rb +74 -0
- data/lib/kward/sandbox/runner_factory.rb +55 -0
- data/lib/kward/sandbox/unavailable_runner.rb +21 -0
- data/lib/kward/sandbox.rb +9 -0
- data/lib/kward/session_store.rb +110 -24
- data/lib/kward/skills/capture.rb +144 -0
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +26 -6
- data/lib/kward/tools/tool_call.rb +1 -0
- data/lib/kward/transport/gateway.rb +253 -0
- data/lib/kward/transport/host.rb +343 -0
- data/lib/kward/transport/manager.rb +179 -0
- data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
- data/lib/kward/transport/runtime.rb +41 -0
- data/lib/kward/transport/store.rb +101 -0
- data/lib/kward/transport.rb +204 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +18 -3
- data/lib/kward/workspace_factory.rb +34 -0
- data/templates/default/fulldoc/html/js/kward.js +3 -0
- data/templates/default/kward_navigation.rb +8 -1
- data/templates/default/layout/html/setup.rb +5 -0
- metadata +41 -2
|
@@ -15,6 +15,9 @@ module Kward
|
|
|
15
15
|
when "stats"
|
|
16
16
|
run_busy_local_command_and_requeue { print_stats(argument) }
|
|
17
17
|
[true, nil]
|
|
18
|
+
when "sandbox"
|
|
19
|
+
run_busy_local_command_and_requeue { handle_sandbox_command(argument, interactive_workspace_root(agent)) }
|
|
20
|
+
[true, nil]
|
|
18
21
|
when "memory"
|
|
19
22
|
activity = memory_summarize_command?(argument) ? "summarizing" : "loading"
|
|
20
23
|
run_busy_local_command_and_requeue(activity: activity) { handle_memory_command(argument, agent) }
|
|
@@ -23,7 +26,11 @@ module Kward
|
|
|
23
26
|
run_busy_local_command_and_requeue { handle_hooks_command(argument) }
|
|
24
27
|
[true, nil]
|
|
25
28
|
when "skill"
|
|
26
|
-
|
|
29
|
+
if argument.to_s.strip == "capture"
|
|
30
|
+
capture_skill_from_session(session_store)
|
|
31
|
+
else
|
|
32
|
+
run_busy_local_command_and_requeue { activate_skill_command(argument, agent) }
|
|
33
|
+
end
|
|
27
34
|
[true, nil]
|
|
28
35
|
when "redraw"
|
|
29
36
|
run_busy_local_command_and_requeue { @prompt.redraw if @prompt.respond_to?(:redraw) }
|
|
@@ -114,6 +121,48 @@ module Kward
|
|
|
114
121
|
PromptCommands.parse(command) || [nil, ""]
|
|
115
122
|
end
|
|
116
123
|
|
|
124
|
+
def handle_sandbox_command(argument, workspace_root)
|
|
125
|
+
action, value = argument.to_s.strip.split(/\s+/, 2)
|
|
126
|
+
case action
|
|
127
|
+
when nil, "", "status"
|
|
128
|
+
print_sandbox_status(workspace_root)
|
|
129
|
+
when *Sandbox::Policy::MODES
|
|
130
|
+
ConfigFiles.update_nested_config("sandbox", { "mode" => action })
|
|
131
|
+
runtime_output("Command sandbox mode set to #{action}. New sessions and tabs use the updated policy.")
|
|
132
|
+
when "network"
|
|
133
|
+
network = value.to_s
|
|
134
|
+
unless Sandbox::Policy::NETWORK_MODES.include?(network)
|
|
135
|
+
runtime_output("Usage: /sandbox network allow|deny")
|
|
136
|
+
return
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
ConfigFiles.update_nested_config("sandbox", { "network" => network })
|
|
140
|
+
runtime_output("Command sandbox child network set to #{network}. New sessions and tabs use the updated policy.")
|
|
141
|
+
else
|
|
142
|
+
runtime_output("Usage: /sandbox [status|off|read_only|workspace_write|network allow|network deny]")
|
|
143
|
+
end
|
|
144
|
+
rescue ArgumentError => error
|
|
145
|
+
runtime_output("Sandbox configuration error: #{error.message}")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def print_sandbox_status(workspace_root)
|
|
149
|
+
config = ConfigFiles.read_config
|
|
150
|
+
policy = ConfigFiles.sandbox_policy(workspace_root, config)
|
|
151
|
+
runner = Sandbox::RunnerFactory.build(policy)
|
|
152
|
+
capabilities = runner.capabilities
|
|
153
|
+
lines = [
|
|
154
|
+
"Command sandbox",
|
|
155
|
+
"Mode: #{policy.mode}",
|
|
156
|
+
"Child network: #{policy.network}",
|
|
157
|
+
"Backend: #{capabilities.backend}",
|
|
158
|
+
"Filesystem enforcement: #{capabilities.filesystem_enforced? ? "active" : "inactive"}",
|
|
159
|
+
"Child-network enforcement: #{capabilities.child_network_enforced? ? "active" : "inactive"}",
|
|
160
|
+
"Scope: model-requested run_shell_command workers only"
|
|
161
|
+
]
|
|
162
|
+
lines << "Reason: #{capabilities.reason}" unless capabilities.reason.to_s.empty?
|
|
163
|
+
runtime_output(lines.join("\n"))
|
|
164
|
+
end
|
|
165
|
+
|
|
117
166
|
def open_or_resume_session(session_store, argument)
|
|
118
167
|
unless session_store
|
|
119
168
|
say_sessions_unavailable
|
|
@@ -144,6 +193,65 @@ module Kward
|
|
|
144
193
|
end
|
|
145
194
|
end
|
|
146
195
|
|
|
196
|
+
def capture_skill_from_session(session_store)
|
|
197
|
+
unless session_store
|
|
198
|
+
say_sessions_unavailable
|
|
199
|
+
return
|
|
200
|
+
end
|
|
201
|
+
unless @prompt.respond_to?(:review_document)
|
|
202
|
+
runtime_output("Skill capture is available only in the interactive terminal prompt.")
|
|
203
|
+
return
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
source_path = select_skill_capture_session(session_store)
|
|
207
|
+
return unless source_path
|
|
208
|
+
|
|
209
|
+
capture = Skills::Capture.new(session_store: session_store, client: @client)
|
|
210
|
+
draft = run_busy_local_command_and_requeue(activity: "capturing skill") { capture.generate(source_path) }
|
|
211
|
+
review_captured_skill(capture, draft)
|
|
212
|
+
rescue Skills::Capture::Error => error
|
|
213
|
+
runtime_output("Skill capture failed: #{error.message}")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def select_skill_capture_session(session_store)
|
|
217
|
+
sessions = session_store.capture_candidates
|
|
218
|
+
if sessions.empty?
|
|
219
|
+
runtime_output("No saved sessions found.")
|
|
220
|
+
return nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
labels = session_picker_labels(sessions)
|
|
224
|
+
if @prompt.respond_to?(:select)
|
|
225
|
+
choice = @prompt.select("Capture session>", labels, title: "Capture skill from session")
|
|
226
|
+
return sessions[labels.index(choice)]&.path
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
runtime_output((["Saved sessions:"] + labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }).join("\n"))
|
|
230
|
+
answer = @prompt.ask("Session number or path>").to_s.strip
|
|
231
|
+
answer.match?(/\A\d+\z/) ? sessions[answer.to_i - 1]&.path : answer
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def review_captured_skill(capture, draft)
|
|
235
|
+
overwrite = false
|
|
236
|
+
saved = nil
|
|
237
|
+
@prompt.review_document(title: "Review captured skill", content: draft.content) do |content|
|
|
238
|
+
begin
|
|
239
|
+
saved = capture.save(content, overwrite: overwrite)
|
|
240
|
+
nil
|
|
241
|
+
rescue Skills::Capture::ConflictError
|
|
242
|
+
if overwrite
|
|
243
|
+
"Could not overwrite the existing skill. Review the name and try again."
|
|
244
|
+
else
|
|
245
|
+
overwrite = true
|
|
246
|
+
"A personal skill with this name exists. Press Ctrl+S again to overwrite it."
|
|
247
|
+
end
|
|
248
|
+
rescue Skills::Capture::Error => error
|
|
249
|
+
error.message
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
runtime_output("Saved personal skill: #{capture.skill_path(saved.name)}") if saved
|
|
253
|
+
end
|
|
254
|
+
|
|
147
255
|
def activate_skill_command(name, agent)
|
|
148
256
|
skill_name = name.to_s.strip
|
|
149
257
|
if skill_name.empty?
|
data/lib/kward/cli/tabs.rb
CHANGED
|
@@ -8,6 +8,12 @@ module Kward
|
|
|
8
8
|
class CLI
|
|
9
9
|
# TUI session tab coordination and asynchronous turn execution.
|
|
10
10
|
module Tabs
|
|
11
|
+
WORKTREE_AGENT_CONTEXT = <<~PROMPT.strip.freeze
|
|
12
|
+
This is an active Git worktree tab. The workspace is sandboxed separately from the host repository metadata.
|
|
13
|
+
When the user explicitly asks you to commit, use the advertised `git_commit` tool. Do not use `run_shell_command` for `git add` or `git commit`, because shared Git metadata is intentionally protected there.
|
|
14
|
+
`git_commit` requires a commit message and accepts optional workspace-relative paths; omit paths to include all current changes in this worktree. Do not create a commit unless the user requested one.
|
|
15
|
+
PROMPT
|
|
16
|
+
|
|
11
17
|
TabRuntime = Struct.new(
|
|
12
18
|
:session,
|
|
13
19
|
:agent,
|
|
@@ -91,6 +97,7 @@ module Kward
|
|
|
91
97
|
@active_tab_index = 0
|
|
92
98
|
@tab_store = session_store ? TabStore.new(config_dir: session_store.config_dir, cwd: session_store.cwd) : nil
|
|
93
99
|
@tab_live_view = nil
|
|
100
|
+
@tab_live_view_token = nil
|
|
94
101
|
@restored_tabs = false
|
|
95
102
|
restored = restore_tabs(session_store) if session_store && agent.nil?
|
|
96
103
|
return restored if restored
|
|
@@ -120,11 +127,14 @@ module Kward
|
|
|
120
127
|
descriptors = Array(data["tabs"])
|
|
121
128
|
return nil if descriptors.empty?
|
|
122
129
|
|
|
123
|
-
descriptors.each_with_index do |
|
|
124
|
-
tab = build_tab_from_descriptor(
|
|
130
|
+
descriptors.each_with_index do |descriptor, index|
|
|
131
|
+
tab = build_tab_from_descriptor(descriptor, session_store, label: restored_tab_label(data, index))
|
|
125
132
|
@tabs << tab if tab
|
|
126
|
-
rescue StandardError
|
|
127
|
-
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
descriptor = descriptor.transform_keys(&:to_s) if descriptor.respond_to?(:transform_keys)
|
|
135
|
+
if descriptor.is_a?(Hash) && descriptor.key?("worktree")
|
|
136
|
+
@tabs << unavailable_worktree_tab(descriptor, e, label: restored_tab_label(data, index))
|
|
137
|
+
end
|
|
128
138
|
end
|
|
129
139
|
return nil if @tabs.empty?
|
|
130
140
|
|
|
@@ -136,13 +146,21 @@ module Kward
|
|
|
136
146
|
def build_tab_from_descriptor(descriptor, session_store, label: nil)
|
|
137
147
|
descriptor = descriptor.transform_keys(&:to_s)
|
|
138
148
|
if descriptor["kind"] == "session"
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
149
|
+
worktree = if descriptor.key?("worktree")
|
|
150
|
+
binding = GitWorktreeBinding.from_descriptor(descriptor["worktree"])
|
|
151
|
+
raise GitWorktreeManager::Error, "Invalid worktree binding." unless binding
|
|
152
|
+
|
|
153
|
+
binding
|
|
154
|
+
end
|
|
155
|
+
validate_worktree_binding!(worktree)
|
|
156
|
+
root = worktree_root_for(worktree, fallback: session_store.cwd)
|
|
157
|
+
session, conversation = restore_tab_session(session_store, descriptor.fetch("session_path"), workspace_root: root, strict: worktree&.active? == true)
|
|
158
|
+
agent = build_tab_agent(conversation, session, worktree: worktree)
|
|
159
|
+
return build_tab(session, agent, driver: SessionTabDriver.new(session: session, agent: agent, worktree: worktree), label: label)
|
|
142
160
|
end
|
|
143
161
|
|
|
144
162
|
tab_type = plugin_registry.tab_type_for_id(descriptor["plugin_tab_type"])
|
|
145
|
-
driver = if tab_type
|
|
163
|
+
driver = if tab_type&.local
|
|
146
164
|
host = PluginTabHost.new(client: @client, workspace_root: session_store.cwd)
|
|
147
165
|
tab_type.handler.call(host, descriptor)
|
|
148
166
|
else
|
|
@@ -154,7 +172,7 @@ module Kward
|
|
|
154
172
|
|
|
155
173
|
def open_plugin_tab(name, session_store)
|
|
156
174
|
tab_type = plugin_registry.tab_type_for(name)
|
|
157
|
-
return runtime_output("Plugin tab #{name.inspect} is not available.") unless tab_type
|
|
175
|
+
return runtime_output("Plugin tab #{name.inspect} is not available.") unless tab_type&.local
|
|
158
176
|
|
|
159
177
|
if tab_type.singleton == :global && (existing = @tabs.find { |tab| tab.driver.descriptor["plugin_tab_type"] == tab_type.id })
|
|
160
178
|
return switch_tab(@tabs.index(existing))
|
|
@@ -174,30 +192,37 @@ module Kward
|
|
|
174
192
|
runtime_output("Could not open plugin tab #{name.inspect}: #{e.message}")
|
|
175
193
|
end
|
|
176
194
|
|
|
177
|
-
def restore_tab_session(session_store, path)
|
|
195
|
+
def restore_tab_session(session_store, path, workspace_root: session_store.cwd, strict: false)
|
|
178
196
|
if File.file?(path)
|
|
179
|
-
session, conversation = session_store.load(path, workspace: configured_workspace(root:
|
|
197
|
+
session, conversation = session_store.load(path, workspace: configured_workspace(root: workspace_root, strict: strict), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
180
198
|
return [track_session(session), conversation]
|
|
181
199
|
end
|
|
182
200
|
|
|
183
201
|
session = track_session(session_store.create(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort))
|
|
184
|
-
conversation = new_conversation(workspace_root:
|
|
202
|
+
conversation = new_conversation(workspace_root: workspace_root)
|
|
185
203
|
session.attach(conversation)
|
|
186
204
|
[session, conversation]
|
|
187
205
|
end
|
|
188
206
|
|
|
189
|
-
def build_tab_agent(conversation, _session)
|
|
207
|
+
def build_tab_agent(conversation, _session, worktree: nil)
|
|
190
208
|
conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
|
|
191
|
-
|
|
209
|
+
strict = worktree&.active? == true
|
|
210
|
+
update_worktree_agent_context(conversation, strict)
|
|
211
|
+
workspace = configured_workspace(root: conversation.workspace_root, strict: strict)
|
|
192
212
|
prompt = TabQuestionPrompt.new(self)
|
|
193
|
-
hook_manager = lifecycle_hook_manager(conversation)
|
|
194
|
-
hook_context = lifecycle_hook_context(conversation)
|
|
213
|
+
hook_manager = strict ? nil : lifecycle_hook_manager(conversation)
|
|
214
|
+
hook_context = strict ? nil : lifecycle_hook_context(conversation)
|
|
215
|
+
git_committer = if strict
|
|
216
|
+
->(message:, paths:) { git_commit_for_agent(workspace.root.to_s, message: message, paths: paths) }
|
|
217
|
+
end
|
|
195
218
|
tool_registry = ToolRegistry.new(
|
|
196
219
|
workspace: workspace,
|
|
197
220
|
prompt: prompt,
|
|
198
221
|
tool_approval: tab_tool_approval_callback(prompt),
|
|
199
222
|
hook_manager: hook_manager,
|
|
200
|
-
hook_context: hook_context
|
|
223
|
+
hook_context: hook_context,
|
|
224
|
+
mcp_clients: strict ? [] : nil,
|
|
225
|
+
git_committer: git_committer
|
|
201
226
|
)
|
|
202
227
|
@footer_conversation = conversation
|
|
203
228
|
@footer_tool_registry = tool_registry
|
|
@@ -212,6 +237,14 @@ module Kward
|
|
|
212
237
|
agent
|
|
213
238
|
end
|
|
214
239
|
|
|
240
|
+
def update_worktree_agent_context(conversation, strict)
|
|
241
|
+
desired = strict ? WORKTREE_AGENT_CONTEXT : nil
|
|
242
|
+
return if conversation.execution_profile_context == desired
|
|
243
|
+
return unless strict || conversation.execution_profile_context == WORKTREE_AGENT_CONTEXT
|
|
244
|
+
|
|
245
|
+
conversation.update_execution_profile_context!(desired)
|
|
246
|
+
end
|
|
247
|
+
|
|
215
248
|
def tab_tool_approval_callback(prompt)
|
|
216
249
|
return nil unless ConfigFiles.permission_policy(safely_read_config.to_h).enabled?
|
|
217
250
|
|
|
@@ -372,8 +405,9 @@ module Kward
|
|
|
372
405
|
return agent unless tab
|
|
373
406
|
|
|
374
407
|
tab.session = @active_session
|
|
375
|
-
|
|
376
|
-
tab.
|
|
408
|
+
worktree = reconcile_worktree_binding(tab, agent.conversation)
|
|
409
|
+
tab.agent = build_tab_agent(agent.conversation, tab.session, worktree: worktree)
|
|
410
|
+
tab.driver = SessionTabDriver.new(session: tab.session, agent: tab.agent, worktree: worktree)
|
|
377
411
|
assign_tab_question_prompt(tab.agent, tab)
|
|
378
412
|
tab.diff = @session_diff || (tab.session&.path ? SessionDiff.from_session_file(tab.session.path) : SessionDiff.new)
|
|
379
413
|
tab.snapshot = nil
|
|
@@ -389,6 +423,7 @@ module Kward
|
|
|
389
423
|
tab.shell = nil
|
|
390
424
|
tab.stream_state = new_tab_stream_state(tab.driver)
|
|
391
425
|
tab.markdown_chunks.clear
|
|
426
|
+
update_prompt_workspace_root(current_workspace_root)
|
|
392
427
|
update_prompt_tabs
|
|
393
428
|
persist_tabs
|
|
394
429
|
tab.agent
|
|
@@ -399,13 +434,23 @@ module Kward
|
|
|
399
434
|
return unless tab
|
|
400
435
|
|
|
401
436
|
if @prompt.respond_to?(:tab_view_snapshot)
|
|
402
|
-
tab.snapshot =
|
|
437
|
+
tab.snapshot = tab_view_snapshot_for(tab)
|
|
403
438
|
elsif @prompt.respond_to?(:composer_snapshot)
|
|
404
439
|
tab.snapshot = @prompt.composer_snapshot
|
|
405
440
|
end
|
|
406
441
|
tab.diff = @session_diff
|
|
407
442
|
end
|
|
408
443
|
|
|
444
|
+
def tab_view_snapshot_for(tab)
|
|
445
|
+
snapshot_method = @prompt.method(:tab_view_snapshot)
|
|
446
|
+
supports_transcript_option = snapshot_method.parameters.any? do |type, name|
|
|
447
|
+
type == :keyrest || ([:key, :keyreq].include?(type) && name == :include_transcript)
|
|
448
|
+
end
|
|
449
|
+
return snapshot_method.call unless supports_transcript_option
|
|
450
|
+
|
|
451
|
+
snapshot_method.call(include_transcript: tab.running? || !tab.shell.nil?)
|
|
452
|
+
end
|
|
453
|
+
|
|
409
454
|
def activate_tab(index, render: true)
|
|
410
455
|
tab = @tabs[index]
|
|
411
456
|
return nil unless tab
|
|
@@ -424,6 +469,8 @@ module Kward
|
|
|
424
469
|
end
|
|
425
470
|
tab.unread = false
|
|
426
471
|
restore_tab_composer_snapshot(tab.snapshot)
|
|
472
|
+
update_prompt_workspace_root(current_workspace_root)
|
|
473
|
+
@prompt.refresh_composer_status if @prompt.respond_to?(:refresh_composer_status)
|
|
427
474
|
update_plugin_tab_slash_commands(tab)
|
|
428
475
|
update_prompt_tabs
|
|
429
476
|
render_tab(tab) if render
|
|
@@ -640,12 +687,12 @@ module Kward
|
|
|
640
687
|
|
|
641
688
|
def refresh_active_tab
|
|
642
689
|
tab = active_tab
|
|
643
|
-
return unless tab
|
|
690
|
+
return unless tab&.thread && !tab.thread.alive? && tab.running?
|
|
644
691
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
692
|
+
tab.thread.join
|
|
693
|
+
return unless tab.status == "running"
|
|
694
|
+
|
|
695
|
+
tab.status = "ready"
|
|
649
696
|
update_prompt_tabs
|
|
650
697
|
end
|
|
651
698
|
|
|
@@ -653,23 +700,31 @@ module Kward
|
|
|
653
700
|
return unless prompt_interface?
|
|
654
701
|
|
|
655
702
|
stop_tab_live_view
|
|
656
|
-
|
|
657
|
-
@
|
|
703
|
+
token = Object.new
|
|
704
|
+
@tab_live_view_token = token
|
|
705
|
+
@tab_live_view = Thread.new { run_tab_live_view(tab, token) }
|
|
658
706
|
@tab_live_view.report_on_exception = false
|
|
659
707
|
end
|
|
660
708
|
|
|
661
709
|
def stop_tab_live_view
|
|
662
|
-
|
|
663
|
-
@
|
|
664
|
-
|
|
710
|
+
thread = @tab_live_view
|
|
711
|
+
@tab_live_view_token = nil
|
|
712
|
+
thread&.join(0.2)
|
|
713
|
+
@tab_live_view = nil if @tab_live_view.equal?(thread)
|
|
665
714
|
end
|
|
666
715
|
|
|
667
|
-
def run_tab_live_view(tab)
|
|
716
|
+
def run_tab_live_view(tab, token)
|
|
668
717
|
renderer = tab_live_renderer(tab)
|
|
669
|
-
|
|
718
|
+
while @tab_live_view_token.equal?(token)
|
|
670
719
|
events = tab.event_history[tab.seen_events..] || []
|
|
671
|
-
events.each
|
|
672
|
-
|
|
720
|
+
events.each do |event|
|
|
721
|
+
break unless @tab_live_view_token.equal?(token)
|
|
722
|
+
|
|
723
|
+
renderer.call(event, tab.driver)
|
|
724
|
+
tab.seen_events += 1
|
|
725
|
+
end
|
|
726
|
+
break unless @tab_live_view_token.equal?(token)
|
|
727
|
+
|
|
673
728
|
if tab.idle?
|
|
674
729
|
renderer.call(:flush, tab.driver)
|
|
675
730
|
break
|
|
@@ -677,7 +732,8 @@ module Kward
|
|
|
677
732
|
sleep 0.05
|
|
678
733
|
end
|
|
679
734
|
ensure
|
|
680
|
-
@
|
|
735
|
+
@tab_live_view_token = nil if @tab_live_view_token.equal?(token)
|
|
736
|
+
@tab_live_view = nil if @tab_live_view == Thread.current
|
|
681
737
|
end
|
|
682
738
|
|
|
683
739
|
def tab_live_renderer(tab)
|
|
@@ -726,6 +782,8 @@ module Kward
|
|
|
726
782
|
def tab_labels
|
|
727
783
|
@tabs.each_with_index.map do |tab, index|
|
|
728
784
|
label = tab.label.to_s.empty? ? default_tab_label(index) : tab.label.to_s
|
|
785
|
+
binding = worktree_binding_for(tab)
|
|
786
|
+
label = "#{label} · #{binding.branch}" if binding&.active?
|
|
729
787
|
{ name: label, color: tab_label_color(tab) }
|
|
730
788
|
end
|
|
731
789
|
end
|
|
@@ -752,7 +810,7 @@ module Kward
|
|
|
752
810
|
action, value = argument.to_s.strip.split(/\s+/, 2)
|
|
753
811
|
case action
|
|
754
812
|
when nil, ""
|
|
755
|
-
runtime_output(
|
|
813
|
+
runtime_output(tab_usage)
|
|
756
814
|
nil
|
|
757
815
|
when /^\d+$/
|
|
758
816
|
switch_tab_number(action)
|
|
@@ -774,12 +832,19 @@ module Kward
|
|
|
774
832
|
when "name", "rename"
|
|
775
833
|
rename_active_tab(value)
|
|
776
834
|
active_tab&.agent
|
|
835
|
+
when "worktree"
|
|
836
|
+
handle_worktree_command(value)
|
|
837
|
+
active_tab&.agent
|
|
777
838
|
else
|
|
778
|
-
runtime_output(
|
|
839
|
+
runtime_output(tab_usage)
|
|
779
840
|
nil
|
|
780
841
|
end
|
|
781
842
|
end
|
|
782
843
|
|
|
844
|
+
def tab_usage
|
|
845
|
+
"/tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab open <plugin-tab> | /tab name <label> | /tab worktree [activate|detach|status|merge|merge abort|remove]"
|
|
846
|
+
end
|
|
847
|
+
|
|
783
848
|
def switch_tab_number(number)
|
|
784
849
|
index = number.to_i - 1
|
|
785
850
|
return switch_tab(index) if @tabs && index.between?(0, @tabs.length - 1)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require_relative "../transport/runtime"
|
|
2
|
+
|
|
3
|
+
module Kward
|
|
4
|
+
class CLI
|
|
5
|
+
# Foreground transport lifecycle commands.
|
|
6
|
+
module Transports
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def handle_transport_command(arguments)
|
|
10
|
+
command = arguments.first.to_s
|
|
11
|
+
case command
|
|
12
|
+
when "list"
|
|
13
|
+
raise ArgumentError, command_usage("transport") unless arguments.length == 1
|
|
14
|
+
|
|
15
|
+
with_transport_runtime { |runtime| print_transport_entries(runtime.manager.list) }
|
|
16
|
+
when "status"
|
|
17
|
+
raise ArgumentError, command_usage("transport") unless arguments.length <= 2
|
|
18
|
+
|
|
19
|
+
with_transport_runtime do |runtime|
|
|
20
|
+
entries = arguments.length == 2 ? [runtime.manager.status(arguments[1])] : runtime.manager.status
|
|
21
|
+
print_transport_entries(entries)
|
|
22
|
+
end
|
|
23
|
+
when "run"
|
|
24
|
+
raise ArgumentError, command_usage("transport") unless (2..3).cover?(arguments.length)
|
|
25
|
+
|
|
26
|
+
workspace_root = arguments[2] ? expanded_working_directory(arguments[2]) : @working_directory
|
|
27
|
+
run_transport(arguments[1], workspace_root: workspace_root)
|
|
28
|
+
else
|
|
29
|
+
raise ArgumentError, command_usage("transport")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def print_transport_entries(entries)
|
|
34
|
+
entries.each do |entry|
|
|
35
|
+
@prompt.say("#{entry[:id]}\t#{entry[:state] || "stopped"}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def run_transport(name, workspace_root: nil)
|
|
40
|
+
runtime = Transport::Runtime.new(client: ensure_client!)
|
|
41
|
+
runtime.manager.start(name, workspace_root: workspace_root)
|
|
42
|
+
@prompt.say("Transport #{name} is running. Press Ctrl-C to stop.")
|
|
43
|
+
wait_for_transport_shutdown
|
|
44
|
+
ensure
|
|
45
|
+
runtime&.shutdown
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def with_transport_runtime
|
|
49
|
+
runtime = Transport::Runtime.new(client: ensure_client!)
|
|
50
|
+
yield runtime
|
|
51
|
+
ensure
|
|
52
|
+
runtime&.shutdown
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def wait_for_transport_shutdown
|
|
56
|
+
stopped = false
|
|
57
|
+
previous_handlers = {}
|
|
58
|
+
%w[INT TERM].each do |signal|
|
|
59
|
+
previous_handlers[signal] = Signal.trap(signal) { stopped = true }
|
|
60
|
+
end
|
|
61
|
+
sleep 0.1 until stopped
|
|
62
|
+
ensure
|
|
63
|
+
previous_handlers&.each { |signal, handler| Signal.trap(signal, handler) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|