kward 0.76.0 → 0.78.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +8 -1
  3. data/CHANGELOG.md +62 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +13 -4
  6. data/doc/agent-tools.md +0 -3
  7. data/doc/api.md +4 -0
  8. data/doc/code-search.md +9 -6
  9. data/doc/composer.md +219 -0
  10. data/doc/configuration.md +114 -27
  11. data/doc/editor.md +18 -2
  12. data/doc/files.md +1 -1
  13. data/doc/getting-started.md +4 -0
  14. data/doc/git.md +2 -2
  15. data/doc/lifecycle-hooks.md +2 -14
  16. data/doc/pan.md +167 -0
  17. data/doc/permissions.md +179 -0
  18. data/doc/plugins.md +58 -0
  19. data/doc/rpc.md +61 -22
  20. data/doc/security.md +184 -0
  21. data/doc/tabs.md +4 -3
  22. data/doc/usage.md +15 -32
  23. data/doc/web-search.md +4 -2
  24. data/lib/kward/ansi.rb +2 -2
  25. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  26. data/lib/kward/auth/github_oauth.rb +3 -3
  27. data/lib/kward/auth/oauth_helpers.rb +4 -2
  28. data/lib/kward/auth/openai_oauth.rb +2 -1
  29. data/lib/kward/cancellation.rb +2 -2
  30. data/lib/kward/cli/commands.rb +1 -3
  31. data/lib/kward/cli/interactive_turn.rb +0 -17
  32. data/lib/kward/cli/plugins.rb +24 -7
  33. data/lib/kward/cli/prompt_interface.rb +1 -0
  34. data/lib/kward/cli/rendering.rb +7 -1
  35. data/lib/kward/cli/runtime_helpers.rb +11 -35
  36. data/lib/kward/cli/sessions.rb +14 -5
  37. data/lib/kward/cli/settings.rb +22 -0
  38. data/lib/kward/cli/slash_commands.rb +1 -561
  39. data/lib/kward/cli/tabs.rb +154 -36
  40. data/lib/kward/cli.rb +30 -26
  41. data/lib/kward/compactor.rb +3 -3
  42. data/lib/kward/config_files.rb +28 -0
  43. data/lib/kward/ekwsh.rb +0 -4
  44. data/lib/kward/hooks/catalog.rb +0 -5
  45. data/lib/kward/hooks/http_handler.rb +2 -1
  46. data/lib/kward/http.rb +18 -0
  47. data/lib/kward/image_attachments.rb +1 -1
  48. data/lib/kward/model/client.rb +33 -11
  49. data/lib/kward/model/payloads.rb +6 -1
  50. data/lib/kward/openrouter_model_cache.rb +2 -1
  51. data/lib/kward/pan/index.html.erb +459 -64
  52. data/lib/kward/pan/kward_logo.png +0 -0
  53. data/lib/kward/pan/server.rb +248 -23
  54. data/lib/kward/permissions/policy.rb +171 -0
  55. data/lib/kward/plugin_registry.rb +71 -1
  56. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  57. data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
  58. data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
  59. data/lib/kward/prompt_interface/layout.rb +0 -4
  60. data/lib/kward/prompt_interface/project_browser.rb +40 -1
  61. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  62. data/lib/kward/prompt_interface/runtime_state.rb +4 -0
  63. data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
  64. data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
  65. data/lib/kward/prompt_interface.rb +20 -1
  66. data/lib/kward/prompts/commands.rb +1 -3
  67. data/lib/kward/prompts.rb +20 -0
  68. data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
  69. data/lib/kward/rpc/server.rb +48 -41
  70. data/lib/kward/rpc/session_manager.rb +76 -25
  71. data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
  72. data/lib/kward/rpc/tool_metadata.rb +23 -0
  73. data/lib/kward/rpc/transcript_normalizer.rb +18 -5
  74. data/lib/kward/session_store.rb +55 -16
  75. data/lib/kward/skills/registry.rb +21 -1
  76. data/lib/kward/starter_pack_installer.rb +3 -1
  77. data/lib/kward/tab_driver.rb +87 -0
  78. data/lib/kward/tab_store.rb +74 -12
  79. data/lib/kward/tools/base.rb +14 -0
  80. data/lib/kward/tools/code_search.rb +8 -2
  81. data/lib/kward/tools/fetch_content.rb +4 -2
  82. data/lib/kward/tools/fetch_raw.rb +3 -1
  83. data/lib/kward/tools/registry.rb +45 -17
  84. data/lib/kward/tools/search/code.rb +47 -13
  85. data/lib/kward/tools/search/web.rb +61 -18
  86. data/lib/kward/tools/search/web_fetch.rb +206 -38
  87. data/lib/kward/tools/tool_call.rb +0 -4
  88. data/lib/kward/tools/web_search.rb +3 -1
  89. data/lib/kward/transcript_export.rb +1 -1
  90. data/lib/kward/update_check.rb +2 -1
  91. data/lib/kward/version.rb +1 -1
  92. data/templates/default/kward_navigation.rb +7 -2
  93. data/templates/default/layout/html/layout.erb +2 -2
  94. data/templates/default/layout/html/setup.rb +1 -0
  95. metadata +11 -12
  96. data/lib/kward/workers/git_guard.rb +0 -93
  97. data/lib/kward/workers/job.rb +0 -99
  98. data/lib/kward/workers/live_view.rb +0 -49
  99. data/lib/kward/workers/manager.rb +0 -328
  100. data/lib/kward/workers/queue_runner.rb +0 -166
  101. data/lib/kward/workers/queue_store.rb +0 -112
  102. data/lib/kward/workers/store.rb +0 -72
  103. data/lib/kward/workers/tool_policy.rb +0 -23
  104. data/lib/kward/workers/worker.rb +0 -82
  105. data/lib/kward/workers/write_lock.rb +0 -38
  106. data/lib/kward/workers.rb +0 -10
data/doc/usage.md CHANGED
@@ -78,7 +78,7 @@ For several commands, enter the embedded Kward shell:
78
78
  /shell
79
79
  ```
80
80
 
81
- `/shell` opens `ekwsh`, a Kward-native command mode. Kward keeps the tab bar, composer editing, and transcript rendering while each command runs through your configured shell. Built-ins such as `cd`, `pwd`, `export`, `unset`, `alias`, `clear`, `pty`, and `exit` maintain shell-mode state between commands. Plain Tab completes built-in command names, configured aliases, executables from `$PATH`, and file paths from the shell's current directory; `cd` completion suggests directories only. `ekwsh` preserves safe ANSI SGR color/style output while stripping terminal-control sequences that could corrupt the TUI, and sets conservative color-friendly environment defaults such as `CLICOLOR=1`, `COLORTERM=truecolor`, and `TERM=xterm-256color` when needed. Use `pty git log` or `/pty git log` when you intentionally want to hand the terminal to an interactive tool such as `less` or `vim`. You can set global shell env vars and aliases in `~/.kward/ekwsh.yml`; see [Configuration](configuration.md).
81
+ `/shell` opens `ekwsh`, a Kward-native command mode that keeps the tab bar and transcript visible. It preserves state such as the current directory, environment variables, and aliases between commands. Use `pty git log` or `/pty git log` when you intentionally want to hand the terminal to an interactive tool such as `less` or `vim`. See [Embedded shell](shell.md) for built-ins, completion, configuration, ANSI handling, PTY passthrough, and limitations.
82
82
 
83
83
  ## Shell commands
84
84
 
@@ -113,7 +113,7 @@ kward --skip-config edit ~/.kward/config.json
113
113
 
114
114
  ## Interactive slash commands
115
115
 
116
- Slash commands run local actions in the current session. Most do not send a prompt to the model; exceptions like `/git` and `/workers` orchestrate local flows that may then trigger model work.
116
+ Slash commands run local actions in the current session. Most do not send a prompt to the model; exceptions like `/git` orchestrate local flows that may then trigger model work.
117
117
 
118
118
  | Command | Use it when you want to... |
119
119
  | --- | --- |
@@ -125,7 +125,7 @@ Slash commands run local actions in the current session. Most do not send a prom
125
125
  | `/files` | browse project files in a nested tree and open them in the editor. |
126
126
  | `/shell` | run workspace commands in the embedded Kward shell. |
127
127
  | `/pty <command>` | hand the terminal to an interactive command such as `git log`/`less` or `vim`. |
128
- | `/settings` | configure prompt overlays. |
128
+ | `/settings` | configure models, accounts, memory, interface, tools, context, personalization, and logging. |
129
129
  | `/status` | see session, model, and context status. |
130
130
  | `/new` | start a fresh session in the current tab. |
131
131
  | `/tab 2` | switch to tab 2. |
@@ -140,6 +140,7 @@ Slash commands run local actions in the current session. Most do not send a prom
140
140
  | `/session name <name>` | name or clear the current session. |
141
141
  | `/rename <name>` | rename the current session. |
142
142
  | `/clone` | copy the current session into a new branch. |
143
+ | `/fork` | fork from an earlier prompt into a new session. |
143
144
  | `/rewind` | revisit an earlier prompt and try a different direction. |
144
145
  | `/tree` | inspect and navigate the full technical session tree. |
145
146
  | `/copy last` | copy the latest assistant answer. |
@@ -147,39 +148,21 @@ Slash commands run local actions in the current session. Most do not send a prom
147
148
  | `/export notes.md` | write the transcript to a Markdown file. |
148
149
  | `/compact [instructions]` | summarize older context so a long chat can continue. |
149
150
  | `/memory ...` | manage opt-in memory. |
151
+ | `/skill <name>` | activate a configured skill explicitly for the current session. |
152
+ | `/stats [range]` | summarize enabled local telemetry. |
153
+ | `/hooks ...` | inspect, diagnose, trust, or untrust lifecycle hooks. |
154
+ | `/scratchpad [text|markdown|ruby]` | open an unsaved editor buffer. |
150
155
  | `/redraw` | fix terminal drawing after resize or glitches. |
151
156
  | `/reload` | reload installed plugins. |
152
- | `/workers` | open the experimental worker pipeline (`new`, `do <task>`, or `list`). |
153
- | `/queue` | manage the experimental tab-backed worker queue (`add`, `list`, `open <id>`, `run`, `suspend <id>`, or `resume <id>`). |
154
157
  | `/exit` | leave Kward. |
158
+ | `/quit` | alias for `/exit`. |
155
159
 
156
- Prompt templates and plugins can add more slash commands.
157
-
158
- ### Experimental tab-backed worker queue
159
-
160
- Start Kward with `--experimental-workers` to try the tab-backed worker queue. The queue is an MVP for turning an existing tab/session into implementation work that can be reviewed later.
161
-
162
- Typical flow:
163
-
164
- ```text
165
- /plan Add retry handling to webhook delivery
166
- /queue add
167
- /queue run
168
- /queue open <id>
169
- ```
170
-
171
- `/queue add` stores the current tab session as a queued worker job. `/queue run` drains queued jobs one at a time. Each job continues its saved session as an implementation worker, starts only from a clean git workspace, commits any resulting changes, and then becomes `ready_for_review`. Use `/queue open <id>` to inspect the worker session, test the feature yourself, and continue the same session if a follow-up fix is needed.
172
-
173
- Current MVP limitations:
174
-
175
- - The queue is manual: run it with `/queue run` when you want it to work.
176
- - Jobs run sequentially and stop when one becomes `blocked` or `failed`.
177
- - A job will not start if the workspace is dirty; clean, commit, or stash your changes first.
178
- - `/queue suspend <id>` and `/queue resume <id>` provide explicit stash-based parking primitives, but automatic foreground-tool-triggered yielding is not wired yet.
179
- - The queue is experimental and stores metadata locally in Kward's config directory.
160
+ Prompt templates, configured skills, and plugins can add more slash commands. Their commands appear in the interactive slash-command picker alongside built-ins.
180
161
 
181
162
  ## Prompt history
182
163
 
164
+ The [Interactive composer](composer.md) guide covers multiline editing, slash and file completion, reasoning shortcuts, busy input, cancellation, attachments, and terminal compatibility.
165
+
183
166
  In interactive mode, Kward keeps prompt history per workspace under `~/.kward/history/`. Press Up/Down to recall previous prompts across restarts.
184
167
 
185
168
  Press `Ctrl-R` to search history. Type a fuzzy query in the composer, use Up/Down to choose a result from the overlay, then press Enter to place it back in the composer for editing or resubmission. Press Esc or Ctrl-C to cancel the search and restore the draft.
@@ -260,7 +243,7 @@ Important guardrails:
260
243
 
261
244
  ## Images
262
245
 
263
- If the active model supports images, Kward can attach image paths, Markdown image links, `file://` URLs, or image data URLs pasted into the composer. Supported formats are GIF, JPEG, PNG, and WebP, up to 20 MB per image.
246
+ If the active model supports images, Kward can attach image paths, Markdown image links, `file://` URLs, or image data URLs pasted into the composer. Supported formats are GIF, JPEG, PNG, and WebP, up to 20 MB per image. See [Interactive composer](composer.md#Attach_images) for attachment badges, removal, and terminal previews.
264
247
 
265
248
  Use this for tasks such as:
266
249
 
@@ -270,13 +253,13 @@ This screenshot shows the broken layout. Find the likely CSS issue.
270
253
 
271
254
  ## Pan mode
272
255
 
273
- Pan mode starts a simple LAN web UI:
256
+ Pan mode starts a mobile-friendly LAN web UI:
274
257
 
275
258
  ```bash
276
259
  kward --working-directory ~/code/project pan
277
260
  ```
278
261
 
279
- Use it only on trusted networks. It exposes the same file, shell, and web tools through a browser UI and requires credentials configured in `config.json`. See [Configuration](configuration.md).
262
+ Use it only on trusted networks. It exposes file, shell, web, and configured extension tools through a browser UI and requires credentials configured in `config.json`. Pan saves conversations to the normal workspace session store; its session drawer can create, resume, rename, and delete sessions. Session changes are disabled while turns are active or queued. See [Pan mode](pan.md) for setup, browser workflows, security, and limitations.
280
263
 
281
264
  ## RPC backend
282
265
 
data/doc/web-search.md CHANGED
@@ -85,7 +85,9 @@ Arguments:
85
85
  - `max_bytes`: default 16384, capped at 131072.
86
86
  - `extract`: optional `auto`, `text`, or `markdown`.
87
87
 
88
- In `auto` mode (default), Kward detects HTML content and extracts readable text by stripping scripts, styles, navigation, and forms, preserving headings, paragraphs, lists, code blocks, and blockquotes. Non-HTML content is returned as cleaned text. Use `markdown` to format extracted headings and code blocks as Markdown, or `text` for plain text without formatting.
88
+ In `auto` mode (default), Kward detects HTML content and extracts readable text while stripping scripts, styles, navigation prose, and forms. It preserves headings, paragraphs, ordered and unordered lists, code blocks, blockquotes, simple tables, and inline link destinations. A bounded `Links` section lists discovered main-content and navigation destinations so an agent can continue through a site without raw HTML. Relative links are resolved against the final fetched URL. Non-HTML content is returned as cleaned text. Use `markdown` to retain Markdown structure, or `text` for plain text with link destinations in parentheses.
89
+
90
+ Kward downloads and parses up to 2 MB of a page before applying `max_bytes` to the extracted result. This lets pages with large scripts or metadata before their main content remain readable while keeping model-facing output bounded. Responses above the download limit are rejected rather than parsed as incomplete HTML.
89
91
 
90
92
  Fetches follow up to 5 redirects and use a 10-second HTTP timeout.
91
93
 
@@ -99,4 +101,4 @@ Arguments:
99
101
  - `max_bytes`: default 16384, capped at 131072.
100
102
  - `accept`: optional HTTP `Accept` header.
101
103
 
102
- Fetches follow up to 5 redirects and use a 10-second HTTP timeout.
104
+ Fetches follow up to 5 redirects, enforce the requested byte limit while reading the response, and use a 10-second HTTP timeout.
data/lib/kward/ansi.rb CHANGED
@@ -262,8 +262,8 @@ module Kward
262
262
  end
263
263
 
264
264
  def inline_links(text, enabled: enabled?)
265
- text.split(/(\[[^\]\n]+\]\([^)\s\n]+\))/).map do |part|
266
- if (match = part.match(/\A\[([^\]\n]+)\]\(([^)\s\n]+)\)\z/))
265
+ text.split(/(\[[^\n\]]+\]\([^)\s]+\))/).map do |part|
266
+ if (match = part.match(/\A\[([^\n\]]+)\]\(([^)\s]+)\)\z/))
267
267
  "#{colorize(match[1], :cyan, enabled: enabled)} (#{colorize(match[2], :dim, enabled: enabled)})"
268
268
  else
269
269
  inline_emphasis(part, enabled: enabled)
@@ -202,8 +202,8 @@ module Kward
202
202
  parse_successful_json(response, "Anthropic OAuth token exchange")
203
203
  end
204
204
 
205
- def post_json(uri, params)
206
- super(uri, params, headers: { "Accept" => "application/json" })
205
+ def post_json(uri, params = nil, **keyword_params)
206
+ super(uri, params, headers: { "Accept" => "application/json" }, **keyword_params)
207
207
  end
208
208
  end
209
209
  end
@@ -4,6 +4,7 @@ require "time"
4
4
  require "uri"
5
5
  require_relative "file"
6
6
  require_relative "../config_files"
7
+ require_relative "../http"
7
8
 
8
9
  # Namespace for the Kward CLI agent runtime.
9
10
  module Kward
@@ -15,7 +16,6 @@ module Kward
15
16
  DEFAULT_SCOPE = "read:user"
16
17
  DEFAULT_CLIENT_ID = "Iv1.b507a08c87ecfe98"
17
18
  COPILOT_HEADERS = {
18
- "User-Agent" => "GitHubCopilotChat/0.35.0",
19
19
  "Editor-Version" => "vscode/1.107.0",
20
20
  "Editor-Plugin-Version" => "copilot-chat/0.35.0",
21
21
  "Copilot-Integration-Id" => "vscode-chat"
@@ -150,7 +150,7 @@ module Kward
150
150
  end
151
151
 
152
152
  def post_form(uri, params)
153
- request = Net::HTTP::Post.new(uri)
153
+ request = Http.apply_user_agent(Net::HTTP::Post.new(uri))
154
154
  request["Content-Type"] = "application/x-www-form-urlencoded"
155
155
  request["Accept"] = "application/json"
156
156
  request.body = URI.encode_www_form(params)
@@ -160,7 +160,7 @@ module Kward
160
160
  end
161
161
 
162
162
  def get_json(uri, headers = {})
163
- request = Net::HTTP::Get.new(uri)
163
+ request = Http.apply_user_agent(Net::HTTP::Get.new(uri))
164
164
  request["Accept"] = "application/json"
165
165
  COPILOT_HEADERS.merge(headers).each { |key, value| request[key] = value }
166
166
 
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "socket"
7
7
  require "time"
8
8
  require "uri"
9
+ require_relative "../http"
9
10
 
10
11
  # Namespace for the Kward CLI agent runtime.
11
12
  module Kward
@@ -58,8 +59,9 @@ module Kward
58
59
  socket&.close
59
60
  end
60
61
 
61
- def post_json(uri, params, headers: {})
62
- request = Net::HTTP::Post.new(uri)
62
+ def post_json(uri, params = nil, headers: {}, **keyword_params)
63
+ params = (params || {}).merge(keyword_params)
64
+ request = Http.apply_user_agent(Net::HTTP::Post.new(uri))
63
65
  request["Content-Type"] = "application/json"
64
66
  headers.each { |key, value| request[key] = value }
65
67
  request.body = JSON.dump(params)
@@ -5,6 +5,7 @@ require "time"
5
5
  require "uri"
6
6
  require_relative "file"
7
7
  require_relative "oauth_helpers"
8
+ require_relative "../http"
8
9
 
9
10
  # Namespace for the Kward CLI agent runtime.
10
11
  module Kward
@@ -215,7 +216,7 @@ module Kward
215
216
  end
216
217
 
217
218
  def post_form(uri, params)
218
- request = Net::HTTP::Post.new(uri)
219
+ request = Http.apply_user_agent(Net::HTTP::Post.new(uri))
219
220
  request["Content-Type"] = "application/x-www-form-urlencoded"
220
221
  request.body = URI.encode_www_form(params)
221
222
 
@@ -2,9 +2,9 @@ require "thread"
2
2
 
3
3
  # Namespace for the Kward CLI agent runtime.
4
4
  module Kward
5
- # Cooperative cancellation token shared by model calls, tools, and workers.
5
+ # Cooperative cancellation token shared by model calls, tools, and background operations.
6
6
  class Cancellation
7
- # Cooperative cancellation token shared by model calls, tools, and workers.
7
+ # Cooperative cancellation token shared by model calls, tools, and background operations.
8
8
  class CancelledError < StandardError; end
9
9
 
10
10
  def initialize
@@ -156,7 +156,7 @@ module Kward
156
156
  },
157
157
  "pan" => {
158
158
  usage: "kward pan",
159
- description: "Start Pan mode, a minimal LAN web UI with a prompt textarea and transcript.",
159
+ description: "Start Pan mode, a mobile-friendly LAN web UI with persistent sessions.",
160
160
  examples: ["kward pan", "kward --working-directory ~/code/project pan"]
161
161
  },
162
162
  "rpc" => {
@@ -220,8 +220,6 @@ module Kward
220
220
  @prompt_delimited = true
221
221
  remaining.concat(arguments[(index + 1)..] || [])
222
222
  break
223
- when "--experimental-workers"
224
- @experimental_workers = true
225
223
  when "--skip-config"
226
224
  @skip_config = true
227
225
  when "--filter"
@@ -7,7 +7,6 @@ module Kward
7
7
  private
8
8
 
9
9
  def run_interactive_turn(agent, input, display_input: nil)
10
- stop_live_worker_view if respond_to?(:stop_live_worker_view, true)
11
10
  prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
12
11
  print_user_transcript(input, display_input: display_input) if prompt_interface?
13
12
  return run_blocking_interactive_turn(agent, input, display_input: display_input) unless prompt_interface?
@@ -235,22 +234,6 @@ module Kward
235
234
  ["/exit", "/quit", "/new"].include?(input.to_s.strip)
236
235
  end
237
236
 
238
- def handle_busy_worker_input(input, agent, queued_inputs)
239
- return false unless agent
240
-
241
- command = input.to_s.strip
242
- return false unless command == "/workers" || command.start_with?("/workers ")
243
-
244
- _handled, replacement_agent = handle_local_slash_command(command, agent, @session_store)
245
- @busy_replacement_agent = replacement_agent if replacement_agent?(replacement_agent)
246
- restore_busy_input_prompt
247
- true
248
- rescue StandardError => e
249
- runtime_output("Error: #{e.message}")
250
- restore_busy_input_prompt
251
- true
252
- end
253
-
254
237
  def replacement_agent?(object)
255
238
  object.respond_to?(:conversation) && object.respond_to?(:ask)
256
239
  end
@@ -32,6 +32,7 @@ module Kward
32
32
 
33
33
  def reload_plugins(conversation)
34
34
  @plugin_registry = PluginRegistry.load(reserved_commands: reserved_slash_command_names)
35
+ @prompt.update_slash_commands(slash_command_entries) if @prompt.respond_to?(:update_slash_commands)
35
36
  conversation.plugin_registry = @plugin_registry if conversation.respond_to?(:plugin_registry=)
36
37
  conversation.refresh_system_message! if conversation.respond_to?(:refresh_system_message!)
37
38
  runtime_output("Plugins reloaded.")
@@ -101,6 +102,17 @@ module Kward
101
102
  end
102
103
  end
103
104
 
105
+ def update_plugin_tab_slash_commands(tab)
106
+ return unless @prompt.respond_to?(:update_slash_commands)
107
+
108
+ entries = if tab&.driver&.respond_to?(:slash_command_entries)
109
+ tab.driver.slash_command_entries
110
+ else
111
+ []
112
+ end
113
+ @prompt.update_slash_commands(slash_command_entries + Array(entries))
114
+ end
115
+
104
116
  def slash_command_entries
105
117
  prompt_entries = prompt_templates.map do |template|
106
118
  {
@@ -126,19 +138,13 @@ module Kward
126
138
  end
127
139
 
128
140
  def builtin_slash_commands
129
- return BUILTIN_SLASH_COMMANDS if experimental_workers_enabled?
130
-
131
- BUILTIN_SLASH_COMMANDS.reject { |command| %w[workers queue].include?(command[:name]) }
141
+ BUILTIN_SLASH_COMMANDS
132
142
  end
133
143
 
134
144
  def builtin_slash_command_names
135
145
  builtin_slash_commands.map { |command| command[:name] }
136
146
  end
137
147
 
138
- def experimental_workers_enabled?
139
- @experimental_workers == true
140
- end
141
-
142
148
  def expand_prompt_template(input)
143
149
  PromptCommands.expand(input, templates: prompt_templates, reserved_commands: builtin_slash_command_names)
144
150
  end
@@ -197,6 +203,17 @@ module Kward
197
203
  plugin_registry.notify_transcript_event(event, plugin_context(conversation, ""))
198
204
  end
199
205
 
206
+ def notify_plugin_tab_transcript_event(event, driver)
207
+ return if plugin_registry.transcript_event_handlers.empty?
208
+
209
+ context = PluginRegistry::Context.new(
210
+ conversation: driver,
211
+ workspace_root: current_workspace_root,
212
+ say_callback: lambda { |message| runtime_output(message) }
213
+ )
214
+ plugin_registry.notify_transcript_event(event, context)
215
+ end
216
+
200
217
  end
201
218
  end
202
219
  end
@@ -18,6 +18,7 @@ module Kward
18
18
  @prompt = prompt_interface.new(
19
19
  slash_commands: slash_command_entries,
20
20
  overlay_settings: ConfigFiles.overlay_settings,
21
+ project_browser_icon_theme: ConfigFiles.project_browser_icon_theme,
21
22
  footer: prompt_footer_renderer,
22
23
  composer_status: method(:composer_status_text),
23
24
  busy_help: ConfigFiles.composer_busy_help?,
@@ -7,9 +7,15 @@ module Kward
7
7
  private
8
8
 
9
9
  def render_conversation_transcript(conversation)
10
+ render_transcript_messages(conversation.messages)
11
+ end
12
+
13
+ # Renders a transcript supplied by either a Kward conversation or a
14
+ # plugin-owned tab driver.
15
+ def render_transcript_messages(messages)
10
16
  tool_calls_by_id = {}
11
17
  @prompt.say("\n#{colored("Transcript", :gray, :bold)}\n")
12
- conversation.messages.each do |message|
18
+ Array(messages).each do |message|
13
19
  role = message_role(message)
14
20
  next if role == "system"
15
21
 
@@ -43,23 +43,14 @@ module Kward
43
43
  end
44
44
 
45
45
  def build_interactive_agent(conversation)
46
- @active_worker_role = "implementation"
47
- set_visible_worker("implementation", status: "active")
48
- build_worker_agent(conversation, role: "implementation")
49
- end
50
-
51
- def build_worker_agent(conversation, role: "implementation")
52
46
  conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
53
47
  workspace = configured_workspace(root: conversation.workspace_root)
54
- writer_id = worker_writer_id(role)
55
48
  hook_manager = lifecycle_hook_manager(conversation)
56
49
  hook_context = lifecycle_hook_context(conversation)
57
50
  tool_registry = ToolRegistry.new(
58
51
  workspace: workspace,
59
52
  prompt: @prompt,
60
- allowed_tool_names: Workers::ToolPolicy.allowed_tool_names(role),
61
- write_lock: @worker_write_lock,
62
- writer_id: writer_id,
53
+ tool_approval: interactive_tool_approval_callback,
63
54
  hook_manager: hook_manager,
64
55
  hook_context: hook_context
65
56
  )
@@ -74,32 +65,17 @@ module Kward
74
65
  )
75
66
  end
76
67
 
77
- def set_visible_worker(id, status: nil, worker: nil)
78
- @visible_worker_id = id.to_s
79
- @visible_worker_status = status
80
- @visible_worker = worker
81
- end
82
-
83
- def worker_writer_id(role)
84
- return nil unless Workers::ToolPolicy.write_capable?(role)
85
-
86
- @worker_write_lock ||= Workers::WriteLock.new
87
- owner_id = role.to_s.empty? ? "implementation" : role.to_s
88
- return owner_id if @worker_write_lock.acquire(owner_id)
68
+ def interactive_tool_approval_callback
69
+ return nil unless @prompt.respond_to?(:ask_tool_approval)
70
+ return nil unless ConfigFiles.permission_policy(safely_read_config.to_h).enabled?
89
71
 
90
- nil
91
- end
92
-
93
- def refresh_implementation_writer(agent)
94
- return agent unless @active_worker_role == "implementation"
95
- return agent unless agent&.respond_to?(:tool_registry)
96
- return agent if agent.tool_registry.writer_id && @worker_write_lock&.owned_by?(agent.tool_registry.writer_id)
97
-
98
- build_interactive_agent(agent.conversation)
99
- end
100
-
101
- def release_implementation_writer
102
- @worker_write_lock&.release("implementation")
72
+ lambda do |tool_call:, name:, args:, cancellation:|
73
+ @prompt.ask_tool_approval(
74
+ tool_name: name,
75
+ args: args,
76
+ reason: args["hook_message"] || args[:hook_message]
77
+ )
78
+ end
103
79
  end
104
80
 
105
81
  def handle_interactive_shell_command(input, agent)
@@ -80,6 +80,15 @@ module Kward
80
80
  @cleanup_sessions.clear
81
81
  end
82
82
 
83
+ def live_session_paths
84
+ (Array(@tabs).filter_map { |tab| tab.session&.path } + [@active_session&.path]).compact.uniq
85
+ end
86
+
87
+ def recent_sessions(session_store, tree: false, limit: nil)
88
+ method = tree ? :recent_tree : :recent
89
+ session_store.public_send(method, limit: limit, keep_empty_path: live_session_paths)
90
+ end
91
+
83
92
  def cleanup_replaced_session(previous_session)
84
93
  return unless previous_session
85
94
  return if @active_session && File.expand_path(previous_session.path) == File.expand_path(@active_session.path)
@@ -590,8 +599,8 @@ module Kward
590
599
  end
591
600
 
592
601
  def insert_session_copy(session_store, sessions, labels, source, copy_path)
593
- copy_info = session_store.recent_tree(limit: nil).find { |session| File.expand_path(session.path) == File.expand_path(copy_path) }
594
- copy_info ||= session_store.recent(limit: nil).find { |session| File.expand_path(session.path) == File.expand_path(copy_path) }
602
+ copy_info = recent_sessions(session_store, tree: true).find { |session| File.expand_path(session.path) == File.expand_path(copy_path) }
603
+ copy_info ||= recent_sessions(session_store).find { |session| File.expand_path(session.path) == File.expand_path(copy_path) }
595
604
  return nil unless copy_info
596
605
 
597
606
  source_index = sessions.index(source) || 0
@@ -618,7 +627,7 @@ module Kward
618
627
  return nil unless source
619
628
 
620
629
  session_store.load(source.path).first.rename(name)
621
- updated = session_store.recent_tree(limit: nil)
630
+ updated = recent_sessions(session_store, tree: true)
622
631
  sessions.replace(updated)
623
632
  labels.replace(session_picker_labels(sessions))
624
633
  index = sessions.index { |session| File.expand_path(session.path) == File.expand_path(source.path) } || 0
@@ -716,7 +725,7 @@ module Kward
716
725
  end
717
726
 
718
727
  def select_session_path(session_store)
719
- select_session_path_from_sessions(session_store.recent_tree(limit: nil), session_store: session_store)
728
+ select_session_path_from_sessions(recent_sessions(session_store, tree: true), session_store: session_store)
720
729
  end
721
730
 
722
731
  def reopen_sessions_after_fork(session_store, source_path, source_label)
@@ -724,7 +733,7 @@ module Kward
724
733
  fork_session_from_picker(session_store, source_path)
725
734
  end
726
735
 
727
- sessions = session_store.recent_tree(limit: nil)
736
+ sessions = recent_sessions(session_store, tree: true)
728
737
  labels = session_picker_labels(sessions)
729
738
  initial_index = if fork_path
730
739
  sessions.index { |session| File.expand_path(session.path) == File.expand_path(fork_path) }
@@ -243,6 +243,8 @@ module Kward
243
243
  next unless width
244
244
 
245
245
  update_overlay_settings("width" => width)
246
+ when /\Afile icons/
247
+ configure_project_browser_icon_theme
246
248
  when /\Ashow busy help/, /\Ahide busy help/
247
249
  set_composer_busy_help(!composer_busy_help?)
248
250
  runtime_output("Busy help #{composer_busy_help? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.")
@@ -277,6 +279,7 @@ module Kward
277
279
  [
278
280
  "Overlay alignment (#{settings["alignment"]})",
279
281
  "Overlay width (#{settings["width"]})",
282
+ "File icons (#{project_browser_icon_theme})",
280
283
  "#{composer_busy_help? ? "Hide" : "Show"} busy help (currently #{on_off(composer_busy_help?)})",
281
284
  "Tab keybindings (#{composer_tab_keybindings})",
282
285
  "Editor mode (#{editor_mode})",
@@ -290,6 +293,25 @@ module Kward
290
293
  ]
291
294
  end
292
295
 
296
+ def project_browser_icon_theme
297
+ ConfigFiles.project_browser_icon_theme(safely_read_config.to_h)
298
+ end
299
+
300
+ def configure_project_browser_icon_theme
301
+ selected = @prompt.select("File icons", project_browser_icon_theme_choices, title: "Settings")
302
+ theme = selected.to_s.split.first.to_s.downcase
303
+ return unless ConfigFiles::PROJECT_BROWSER_ICON_THEMES.include?(theme)
304
+
305
+ update_nested_config("project_browser", "icons" => theme)
306
+ @prompt.update_project_browser_icon_theme(theme) if @prompt.respond_to?(:update_project_browser_icon_theme)
307
+ runtime_output("File icons set to #{theme}.")
308
+ end
309
+
310
+ def project_browser_icon_theme_choices
311
+ current = project_browser_icon_theme
312
+ ConfigFiles::PROJECT_BROWSER_ICON_THEMES.map { |theme| theme == current ? "#{theme} (current)" : theme }
313
+ end
314
+
293
315
  def composer_busy_help?
294
316
  ConfigFiles.composer_busy_help?(safely_read_config.to_h)
295
317
  end