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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +8 -1
- data/CHANGELOG.md +62 -0
- data/Gemfile.lock +2 -2
- data/README.md +13 -4
- data/doc/agent-tools.md +0 -3
- data/doc/api.md +4 -0
- data/doc/code-search.md +9 -6
- data/doc/composer.md +219 -0
- data/doc/configuration.md +114 -27
- data/doc/editor.md +18 -2
- data/doc/files.md +1 -1
- data/doc/getting-started.md +4 -0
- data/doc/git.md +2 -2
- data/doc/lifecycle-hooks.md +2 -14
- data/doc/pan.md +167 -0
- data/doc/permissions.md +179 -0
- data/doc/plugins.md +58 -0
- data/doc/rpc.md +61 -22
- data/doc/security.md +184 -0
- data/doc/tabs.md +4 -3
- data/doc/usage.md +15 -32
- data/doc/web-search.md +4 -2
- data/lib/kward/ansi.rb +2 -2
- data/lib/kward/auth/anthropic_oauth.rb +2 -2
- data/lib/kward/auth/github_oauth.rb +3 -3
- data/lib/kward/auth/oauth_helpers.rb +4 -2
- data/lib/kward/auth/openai_oauth.rb +2 -1
- data/lib/kward/cancellation.rb +2 -2
- data/lib/kward/cli/commands.rb +1 -3
- data/lib/kward/cli/interactive_turn.rb +0 -17
- data/lib/kward/cli/plugins.rb +24 -7
- data/lib/kward/cli/prompt_interface.rb +1 -0
- data/lib/kward/cli/rendering.rb +7 -1
- data/lib/kward/cli/runtime_helpers.rb +11 -35
- data/lib/kward/cli/sessions.rb +14 -5
- data/lib/kward/cli/settings.rb +22 -0
- data/lib/kward/cli/slash_commands.rb +1 -561
- data/lib/kward/cli/tabs.rb +154 -36
- data/lib/kward/cli.rb +30 -26
- data/lib/kward/compactor.rb +3 -3
- data/lib/kward/config_files.rb +28 -0
- data/lib/kward/ekwsh.rb +0 -4
- data/lib/kward/hooks/catalog.rb +0 -5
- data/lib/kward/hooks/http_handler.rb +2 -1
- data/lib/kward/http.rb +18 -0
- data/lib/kward/image_attachments.rb +1 -1
- data/lib/kward/model/client.rb +33 -11
- data/lib/kward/model/payloads.rb +6 -1
- data/lib/kward/openrouter_model_cache.rb +2 -1
- data/lib/kward/pan/index.html.erb +459 -64
- data/lib/kward/pan/kward_logo.png +0 -0
- data/lib/kward/pan/server.rb +248 -23
- data/lib/kward/permissions/policy.rb +171 -0
- data/lib/kward/plugin_registry.rb +71 -1
- data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
- data/lib/kward/prompt_interface/layout.rb +0 -4
- data/lib/kward/prompt_interface/project_browser.rb +40 -1
- data/lib/kward/prompt_interface/question_prompt.rb +12 -3
- data/lib/kward/prompt_interface/runtime_state.rb +4 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
- data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
- data/lib/kward/prompt_interface.rb +20 -1
- data/lib/kward/prompts/commands.rb +1 -3
- data/lib/kward/prompts.rb +20 -0
- data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
- data/lib/kward/rpc/server.rb +48 -41
- data/lib/kward/rpc/session_manager.rb +76 -25
- data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
- data/lib/kward/rpc/tool_metadata.rb +23 -0
- data/lib/kward/rpc/transcript_normalizer.rb +18 -5
- data/lib/kward/session_store.rb +55 -16
- data/lib/kward/skills/registry.rb +21 -1
- data/lib/kward/starter_pack_installer.rb +3 -1
- data/lib/kward/tab_driver.rb +87 -0
- data/lib/kward/tab_store.rb +74 -12
- data/lib/kward/tools/base.rb +14 -0
- data/lib/kward/tools/code_search.rb +8 -2
- data/lib/kward/tools/fetch_content.rb +4 -2
- data/lib/kward/tools/fetch_raw.rb +3 -1
- data/lib/kward/tools/registry.rb +45 -17
- data/lib/kward/tools/search/code.rb +47 -13
- data/lib/kward/tools/search/web.rb +61 -18
- data/lib/kward/tools/search/web_fetch.rb +206 -38
- data/lib/kward/tools/tool_call.rb +0 -4
- data/lib/kward/tools/web_search.rb +3 -1
- data/lib/kward/transcript_export.rb +1 -1
- data/lib/kward/update_check.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/templates/default/kward_navigation.rb +7 -2
- data/templates/default/layout/html/layout.erb +2 -2
- data/templates/default/layout/html/setup.rb +1 -0
- metadata +11 -12
- data/lib/kward/workers/git_guard.rb +0 -93
- data/lib/kward/workers/job.rb +0 -99
- data/lib/kward/workers/live_view.rb +0 -49
- data/lib/kward/workers/manager.rb +0 -328
- data/lib/kward/workers/queue_runner.rb +0 -166
- data/lib/kward/workers/queue_store.rb +0 -112
- data/lib/kward/workers/store.rb +0 -72
- data/lib/kward/workers/tool_policy.rb +0 -23
- data/lib/kward/workers/worker.rb +0 -82
- data/lib/kward/workers/write_lock.rb +0 -38
- data/lib/kward/workers.rb +0 -10
|
@@ -34,6 +34,10 @@ module Kward
|
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Registered plugin-owned tab runtime. Its factory receives a
|
|
38
|
+
# `PluginTabHost` and its persisted descriptor, then returns a driver.
|
|
39
|
+
TabType = Struct.new(:id, :name, :title, :singleton, :rpc, :transcript_events, :path, :handler, keyword_init: true)
|
|
40
|
+
|
|
37
41
|
# Read-only event passed to plugin transcript observers.
|
|
38
42
|
TranscriptEvent = Struct.new(:type, :payload, keyword_init: true) do
|
|
39
43
|
def to_h
|
|
@@ -87,14 +91,17 @@ module Kward
|
|
|
87
91
|
nil
|
|
88
92
|
end
|
|
89
93
|
|
|
94
|
+
# @return [String, nil] active session identifier
|
|
90
95
|
def session_id
|
|
91
96
|
@session&.id
|
|
92
97
|
end
|
|
93
98
|
|
|
99
|
+
# @return [String, nil] human-readable active session name
|
|
94
100
|
def session_name
|
|
95
101
|
@session&.name
|
|
96
102
|
end
|
|
97
103
|
|
|
104
|
+
# @return [String, nil] saved active session path
|
|
98
105
|
def session_path
|
|
99
106
|
@session&.path
|
|
100
107
|
end
|
|
@@ -108,30 +115,45 @@ module Kward
|
|
|
108
115
|
nil
|
|
109
116
|
end
|
|
110
117
|
|
|
118
|
+
# Allows the current lifecycle event to continue.
|
|
119
|
+
# @return [Hooks::Decision]
|
|
111
120
|
def allow(message = nil, metadata: nil)
|
|
112
121
|
Hooks::Decision.allow(message, metadata: metadata)
|
|
113
122
|
end
|
|
114
123
|
|
|
124
|
+
# Denies the current lifecycle event.
|
|
125
|
+
# @return [Hooks::Decision]
|
|
115
126
|
def deny(message = nil, metadata: nil)
|
|
116
127
|
Hooks::Decision.deny(message, metadata: metadata)
|
|
117
128
|
end
|
|
118
129
|
|
|
130
|
+
# Requests frontend approval for the current lifecycle event.
|
|
131
|
+
# @return [Hooks::Decision]
|
|
119
132
|
def ask(message = nil, metadata: nil)
|
|
120
133
|
Hooks::Decision.ask(message, metadata: metadata)
|
|
121
134
|
end
|
|
122
135
|
|
|
136
|
+
# Continues with an event-specific payload replacement.
|
|
137
|
+
# @param payload [Hash] replacement fields supported by the event
|
|
138
|
+
# @return [Hooks::Decision]
|
|
123
139
|
def modify(payload, message: nil, metadata: nil)
|
|
124
140
|
Hooks::Decision.modify(payload, message: message, metadata: metadata)
|
|
125
141
|
end
|
|
126
142
|
|
|
143
|
+
# Allows the event while recording a warning.
|
|
144
|
+
# @return [Hooks::Decision]
|
|
127
145
|
def warn(message = nil, metadata: nil)
|
|
128
146
|
Hooks::Decision.warn(message, metadata: metadata)
|
|
129
147
|
end
|
|
130
148
|
|
|
149
|
+
# Requests a retry when the current event supports it.
|
|
150
|
+
# @return [Hooks::Decision]
|
|
131
151
|
def retry(message = nil, payload: nil, metadata: nil)
|
|
132
152
|
Hooks::Decision.retry(message, payload: payload, metadata: metadata)
|
|
133
153
|
end
|
|
134
154
|
|
|
155
|
+
# Defers the event when the current workflow supports it.
|
|
156
|
+
# @return [Hooks::Decision]
|
|
135
157
|
def defer(message = nil, payload: nil, metadata: nil)
|
|
136
158
|
Hooks::Decision.defer(message, payload: payload, metadata: metadata)
|
|
137
159
|
end
|
|
@@ -246,6 +268,23 @@ module Kward
|
|
|
246
268
|
def interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", &block)
|
|
247
269
|
@registry.register_interactive_command(name, rows: rows, fps: fps, description: description, argument_hint: argument_hint, path: @path, &block)
|
|
248
270
|
end
|
|
271
|
+
|
|
272
|
+
# Registers a plugin-owned tab type for the interactive CLI. `id` is a
|
|
273
|
+
# durable identifier used in persisted tab layouts and must not change.
|
|
274
|
+
# The factory receives a `PluginTabHost` and a descriptor hash.
|
|
275
|
+
#
|
|
276
|
+
# @param name [String] command name used by `/tab open <name>`
|
|
277
|
+
# @param id [String] stable persisted tab type identifier
|
|
278
|
+
# @param title [String] default tab label
|
|
279
|
+
# @param singleton [Symbol] `:global` for one shared plugin runtime
|
|
280
|
+
# @param transcript_events [Boolean] allow global transcript observers to receive this tab's events
|
|
281
|
+
# @yieldparam host [PluginTabHost] supported host dependencies
|
|
282
|
+
# @yieldparam descriptor [Hash] persisted tab descriptor
|
|
283
|
+
# @return [void]
|
|
284
|
+
# @api public
|
|
285
|
+
def tab_type(name, id:, title: nil, singleton: nil, rpc: false, transcript_events: false, &block)
|
|
286
|
+
@registry.register_tab_type(name, id: id, title: title, singleton: singleton, rpc: rpc, transcript_events: transcript_events, path: @path, &block)
|
|
287
|
+
end
|
|
249
288
|
end
|
|
250
289
|
|
|
251
290
|
# Mutable singleton guard used while loading trusted plugin files.
|
|
@@ -264,6 +303,8 @@ module Kward
|
|
|
264
303
|
@reserved_commands = reserved_commands.map(&:to_s)
|
|
265
304
|
@commands = {}
|
|
266
305
|
@interactive_commands = {}
|
|
306
|
+
@tab_types = {}
|
|
307
|
+
@tab_types_by_id = {}
|
|
267
308
|
@footer = nil
|
|
268
309
|
@footer_path = nil
|
|
269
310
|
@transcript_event_handlers = []
|
|
@@ -294,6 +335,18 @@ module Kward
|
|
|
294
335
|
@interactive_commands[name.to_s]
|
|
295
336
|
end
|
|
296
337
|
|
|
338
|
+
def tab_types
|
|
339
|
+
@tab_types.values
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def tab_type_for(name)
|
|
343
|
+
@tab_types[name.to_s]
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def tab_type_for_id(id)
|
|
347
|
+
@tab_types_by_id[id.to_s]
|
|
348
|
+
end
|
|
349
|
+
|
|
297
350
|
def footer_renderer
|
|
298
351
|
@footer
|
|
299
352
|
end
|
|
@@ -411,6 +464,23 @@ module Kward
|
|
|
411
464
|
)
|
|
412
465
|
end
|
|
413
466
|
|
|
467
|
+
def register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transcript_events: false, path: nil, &handler)
|
|
468
|
+
name = name.to_s
|
|
469
|
+
id = id.to_s
|
|
470
|
+
raise "Plugin tab type name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
|
|
471
|
+
raise "Plugin tab type id is required" if id.empty?
|
|
472
|
+
raise "Plugin tab type #{name} requires a handler" unless handler
|
|
473
|
+
|
|
474
|
+
if @tab_types.key?(name) || @tab_types_by_id.key?(id)
|
|
475
|
+
warn "Warning: skipping duplicate Kward plugin tab type #{id}: #{path}"
|
|
476
|
+
return nil
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
tab_type = TabType.new(id: id, name: name, title: title.to_s.empty? ? name.capitalize : title.to_s, singleton: singleton&.to_sym, rpc: rpc == true, transcript_events: transcript_events == true, path: path, handler: handler)
|
|
480
|
+
@tab_types[name] = tab_type
|
|
481
|
+
@tab_types_by_id[id] = tab_type
|
|
482
|
+
end
|
|
483
|
+
|
|
414
484
|
def register_footer(path: nil, &renderer)
|
|
415
485
|
raise "Plugin footer requires a renderer" unless renderer
|
|
416
486
|
|
|
@@ -482,7 +552,7 @@ module Kward
|
|
|
482
552
|
end
|
|
483
553
|
end
|
|
484
554
|
|
|
485
|
-
def transcript_event(type, payload)
|
|
555
|
+
def transcript_event(type, payload = {})
|
|
486
556
|
TranscriptEvent.new(
|
|
487
557
|
type: type,
|
|
488
558
|
payload: DeepCopy.freeze(DeepCopy.dup(payload))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
4
|
+
module Kward
|
|
5
|
+
class PromptInterface
|
|
6
|
+
# Tool-approval overlay built on the structured question modal.
|
|
7
|
+
module ApprovalPrompt
|
|
8
|
+
# Asks the captain to approve a model-requested tool call. Cancellation is
|
|
9
|
+
# intentionally a denial so a closed or interrupted overlay never permits
|
|
10
|
+
# a side effect.
|
|
11
|
+
def ask_tool_approval(tool_name:, args:, reason: nil)
|
|
12
|
+
summary, details = tool_approval_details(tool_name, args)
|
|
13
|
+
question = (["The agent wants to #{summary}."] + Array(details) + [reason.to_s].reject(&:empty?)).join("\n")
|
|
14
|
+
answers = ask_user_question([
|
|
15
|
+
{
|
|
16
|
+
header: "Approval required · #{tool_approval_title(tool_name)}",
|
|
17
|
+
question: question,
|
|
18
|
+
options: [
|
|
19
|
+
{ label: "Allow once", description: "Run this tool call." },
|
|
20
|
+
{ label: "Allow this tool for this session", description: "Run this call and future calls to #{tool_name}." },
|
|
21
|
+
{ label: "Deny", description: "Do not run this tool call." }
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
])
|
|
25
|
+
answer = answers&.first
|
|
26
|
+
return { denied_message: answer[:answer] } if answer&.fetch(:custom, false) && !answer[:answer].to_s.empty?
|
|
27
|
+
|
|
28
|
+
case answer&.fetch(:answer, nil)
|
|
29
|
+
when "Allow once" then true
|
|
30
|
+
when "Allow this tool for this session" then :allow_for_session
|
|
31
|
+
else false
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def tool_approval_title(tool_name)
|
|
38
|
+
case tool_name.to_s
|
|
39
|
+
when "run_shell_command" then "Shell command"
|
|
40
|
+
when "write_file" then "Write file"
|
|
41
|
+
when "edit_file" then "Edit file"
|
|
42
|
+
when "fetch_content", "fetch_raw" then "Network request"
|
|
43
|
+
when "web_search" then "Web search"
|
|
44
|
+
else tool_name.to_s.tr("_", " ").capitalize
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def tool_approval_details(tool_name, args)
|
|
49
|
+
action = case tool_name.to_s
|
|
50
|
+
when "run_shell_command" then "run this shell command"
|
|
51
|
+
when "write_file" then "write this file"
|
|
52
|
+
when "edit_file" then "edit this file"
|
|
53
|
+
when "read_file", "read_skill" then "read these resources"
|
|
54
|
+
when "fetch_content", "fetch_raw" then "make this network request"
|
|
55
|
+
when "web_search" then "search the web"
|
|
56
|
+
else "use #{tool_name}"
|
|
57
|
+
end
|
|
58
|
+
[action, ["Arguments:\n#{JSON.pretty_generate(args.to_h)}"]]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -177,7 +177,7 @@ module Kward
|
|
|
177
177
|
def editor_expected_indent_for_line(line_index)
|
|
178
178
|
line = @editor_state.lines[line_index].to_s
|
|
179
179
|
code = editor_indent_code(line, editor_syntax_language).strip
|
|
180
|
-
matching_indent = editor_matching_indent_for_line(code)
|
|
180
|
+
matching_indent = editor_matching_indent_for_line(code, line_index)
|
|
181
181
|
return matching_indent if matching_indent
|
|
182
182
|
|
|
183
183
|
previous_line = previous_non_blank_editor_line(line_index)
|
|
@@ -188,11 +188,11 @@ module Kward
|
|
|
188
188
|
indent
|
|
189
189
|
end
|
|
190
190
|
|
|
191
|
-
def editor_matching_indent_for_line(code)
|
|
191
|
+
def editor_matching_indent_for_line(code, line_index = nil)
|
|
192
192
|
return nil if code.empty?
|
|
193
|
-
return editor_matching_punctuation_indent(code[0]) if editor_closing_punctuation?(code[0])
|
|
193
|
+
return editor_matching_punctuation_indent(code[0], line_index) if editor_closing_punctuation?(code[0])
|
|
194
194
|
|
|
195
|
-
editor_matching_word_indent if editor_completed_word_closer?(code, editor_syntax_language)
|
|
195
|
+
editor_matching_word_indent(line_index) if editor_completed_word_closer?(code, editor_syntax_language)
|
|
196
196
|
end
|
|
197
197
|
|
|
198
198
|
def previous_non_blank_editor_line(line_index)
|
|
@@ -371,7 +371,8 @@ module Kward
|
|
|
371
371
|
def editor_reindent_for_closing_punctuation(text)
|
|
372
372
|
return unless editor_cursor_in_leading_indent?
|
|
373
373
|
|
|
374
|
-
|
|
374
|
+
line_index, = @editor_state.cursor_line_and_column
|
|
375
|
+
indent = editor_matching_punctuation_indent(text, line_index)
|
|
375
376
|
editor_reindent_current_line(indent) if indent
|
|
376
377
|
end
|
|
377
378
|
|
|
@@ -381,7 +382,7 @@ module Kward
|
|
|
381
382
|
before_cursor = line[0...column].to_s
|
|
382
383
|
return unless editor_completed_word_closer?(before_cursor, editor_syntax_language)
|
|
383
384
|
|
|
384
|
-
indent = editor_matching_word_indent
|
|
385
|
+
indent = editor_matching_word_indent(line_index)
|
|
385
386
|
editor_reindent_current_line(indent) if indent
|
|
386
387
|
end
|
|
387
388
|
|
|
@@ -394,14 +395,21 @@ module Kward
|
|
|
394
395
|
def editor_reindent_current_line(indent)
|
|
395
396
|
line_index, column = @editor_state.cursor_line_and_column
|
|
396
397
|
line_start = @editor_state.line_start_offset(line_index)
|
|
397
|
-
|
|
398
|
-
|
|
398
|
+
old_indent = @editor_state.lines[line_index].to_s[/\A[ \t]*/].to_s
|
|
399
|
+
return false unless editor_reindent_line(line_index, indent)
|
|
400
|
+
|
|
401
|
+
content_column = [column - old_indent.length, 0].max
|
|
402
|
+
@editor_state.cursor = line_start + indent.to_s.length + content_column
|
|
403
|
+
true
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def editor_reindent_line(line_index, indent)
|
|
407
|
+
line_start = @editor_state.line_start_offset(line_index)
|
|
408
|
+
old_indent = @editor_state.lines[line_index].to_s[/\A[ \t]*/].to_s
|
|
399
409
|
new_indent = indent.to_s
|
|
400
410
|
return false if old_indent == new_indent
|
|
401
411
|
|
|
402
|
-
content_column = [column - old_indent.length, 0].max
|
|
403
412
|
@editor_state.replace_range(line_start, line_start + old_indent.length, new_indent)
|
|
404
|
-
@editor_state.cursor = line_start + new_indent.length + content_column
|
|
405
413
|
true
|
|
406
414
|
end
|
|
407
415
|
|
|
@@ -421,23 +429,23 @@ module Kward
|
|
|
421
429
|
end
|
|
422
430
|
end
|
|
423
431
|
|
|
424
|
-
def editor_matching_word_indent
|
|
432
|
+
def editor_matching_word_indent(line_index = nil)
|
|
425
433
|
case editor_syntax_language
|
|
426
434
|
when :ruby
|
|
427
|
-
editor_matching_keyword_indent("end", %w[end])
|
|
435
|
+
editor_matching_keyword_indent("end", %w[end], line_index)
|
|
428
436
|
when :lua
|
|
429
|
-
editor_matching_keyword_indent("end", %w[end until])
|
|
437
|
+
editor_matching_keyword_indent("end", %w[end until], line_index)
|
|
430
438
|
when :shell
|
|
431
|
-
editor_matching_keyword_indent(nil, SHELL_DEDENT_KEYWORDS)
|
|
439
|
+
editor_matching_keyword_indent(nil, SHELL_DEDENT_KEYWORDS, line_index)
|
|
432
440
|
when :html
|
|
433
|
-
editor_matching_html_indent
|
|
441
|
+
editor_matching_html_indent(line_index)
|
|
434
442
|
end
|
|
435
443
|
end
|
|
436
444
|
|
|
437
|
-
def editor_matching_punctuation_indent(text)
|
|
445
|
+
def editor_matching_punctuation_indent(text, line_index = nil)
|
|
438
446
|
opener = PUNCTUATION_PAIRS[text]
|
|
439
447
|
stack = []
|
|
440
|
-
editor_previous_code_lines.each do |line|
|
|
448
|
+
editor_previous_code_lines(line_index).each do |line|
|
|
441
449
|
editor_scan_punctuation_tokens(line[:code]).each do |token|
|
|
442
450
|
if token == opener
|
|
443
451
|
stack << line[:indent]
|
|
@@ -449,9 +457,9 @@ module Kward
|
|
|
449
457
|
stack.last || ""
|
|
450
458
|
end
|
|
451
459
|
|
|
452
|
-
def editor_matching_keyword_indent(opener = nil, closers = [])
|
|
460
|
+
def editor_matching_keyword_indent(opener = nil, closers = [], line_index = nil)
|
|
453
461
|
stack = []
|
|
454
|
-
editor_previous_code_lines.each do |line|
|
|
462
|
+
editor_previous_code_lines(line_index).each do |line|
|
|
455
463
|
code = line[:code].strip
|
|
456
464
|
next if code.empty?
|
|
457
465
|
|
|
@@ -461,9 +469,9 @@ module Kward
|
|
|
461
469
|
stack.last || ""
|
|
462
470
|
end
|
|
463
471
|
|
|
464
|
-
def editor_matching_html_indent
|
|
472
|
+
def editor_matching_html_indent(line_index = nil)
|
|
465
473
|
stack = []
|
|
466
|
-
editor_previous_code_lines.each do |line|
|
|
474
|
+
editor_previous_code_lines(line_index).each do |line|
|
|
467
475
|
code = line[:code].strip
|
|
468
476
|
next if code.empty?
|
|
469
477
|
|
|
@@ -473,8 +481,8 @@ module Kward
|
|
|
473
481
|
stack.last || ""
|
|
474
482
|
end
|
|
475
483
|
|
|
476
|
-
def editor_previous_code_lines
|
|
477
|
-
line_index
|
|
484
|
+
def editor_previous_code_lines(line_index = nil)
|
|
485
|
+
line_index ||= @editor_state.cursor_line_and_column.first
|
|
478
486
|
@editor_state.lines.first(line_index).filter_map do |line|
|
|
479
487
|
code = editor_indent_code(line, editor_syntax_language).rstrip
|
|
480
488
|
next if code.strip.empty?
|
|
@@ -452,9 +452,10 @@ module Kward
|
|
|
452
452
|
return true if command.match?(/\A\d*g\z/)
|
|
453
453
|
return true if command.match?(/\A\d*g[uU~]\z/)
|
|
454
454
|
return true if command.match?(/\A\d*z\z/)
|
|
455
|
-
return true if command.match?(/\A\d*[cdy]\d
|
|
456
|
-
return true if command.match?(/\A\d*[cdy]\d*
|
|
457
|
-
return true if command.match?(/\A\d*[cdy]\d*[
|
|
455
|
+
return true if command.match?(/\A\d*[=cdy](?:[1-9]\d*)?\z/)
|
|
456
|
+
return true if command.match?(/\A\d*[=cdy]\d*g\z/)
|
|
457
|
+
return true if command.match?(/\A\d*[=cdy]\d*[ai]\z/)
|
|
458
|
+
return true if command.match?(/\A\d*[=cdy]\d*[fFtT]\z/)
|
|
458
459
|
return true if command.match?(/\A\d*[fFtT]\z/)
|
|
459
460
|
return true if command.match?(/\A\d*r\z/)
|
|
460
461
|
return true if command.match?(/\Am\z/)
|
|
@@ -635,6 +636,10 @@ module Kward
|
|
|
635
636
|
when "<<"
|
|
636
637
|
vibe_indent_lines(count, :left)
|
|
637
638
|
vibe_remember_change(command)
|
|
639
|
+
when "=="
|
|
640
|
+
vibe_reindent_lines(count, command)
|
|
641
|
+
when /\A=(.+)\z/
|
|
642
|
+
vibe_reindent_operator(Regexp.last_match(1), count, command)
|
|
638
643
|
when "p"
|
|
639
644
|
vibe_active_register_linewise? ? vibe_paste_line(:below, original_command) : vibe_paste_after(original_command)
|
|
640
645
|
when "P"
|
|
@@ -704,6 +709,8 @@ module Kward
|
|
|
704
709
|
vibe_indent_visual_selection(:right)
|
|
705
710
|
when "<"
|
|
706
711
|
vibe_indent_visual_selection(:left)
|
|
712
|
+
when "="
|
|
713
|
+
vibe_reindent_visual_selection
|
|
707
714
|
when "J"
|
|
708
715
|
vibe_join_visual_selection
|
|
709
716
|
when "~"
|
|
@@ -1077,6 +1084,18 @@ module Kward
|
|
|
1077
1084
|
vibe_cancel_visual_mode
|
|
1078
1085
|
end
|
|
1079
1086
|
|
|
1087
|
+
def vibe_reindent_visual_selection
|
|
1088
|
+
ranges = @editor_state.selection_ranges
|
|
1089
|
+
return false if ranges.empty?
|
|
1090
|
+
|
|
1091
|
+
start_index = ranges.map(&:first).min
|
|
1092
|
+
end_index = ranges.map(&:last).max
|
|
1093
|
+
start_line, = @editor_state.cursor_line_and_column_for(start_index)
|
|
1094
|
+
end_line, = @editor_state.cursor_line_and_column_for([end_index - 1, start_index].max)
|
|
1095
|
+
vibe_reindent_line_range(start_line, end_line)
|
|
1096
|
+
vibe_cancel_visual_mode
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1080
1099
|
def vibe_count_and_body(command)
|
|
1081
1100
|
return [0, "0"] if command == "0"
|
|
1082
1101
|
|
|
@@ -1284,6 +1303,56 @@ module Kward
|
|
|
1284
1303
|
true
|
|
1285
1304
|
end
|
|
1286
1305
|
|
|
1306
|
+
def vibe_reindent_lines(count, command = nil)
|
|
1307
|
+
line, = @editor_state.cursor_line_and_column
|
|
1308
|
+
vibe_reindent_line_range(line, line + count - 1, command)
|
|
1309
|
+
end
|
|
1310
|
+
|
|
1311
|
+
def vibe_reindent_operator(motion, count, command = nil)
|
|
1312
|
+
motion_count, motion = vibe_count_and_body(motion)
|
|
1313
|
+
return vibe_reindent_to_line(motion_count, command) if motion == "G"
|
|
1314
|
+
return vibe_reindent_to_line(motion_count.positive? ? motion_count : 1, command) if motion == "gg"
|
|
1315
|
+
return vibe_reindent_lines(count, command) if motion == "=" || motion == "g_"
|
|
1316
|
+
|
|
1317
|
+
count *= motion_count if motion_count.positive?
|
|
1318
|
+
target = vibe_operator_target(motion, count)
|
|
1319
|
+
return false unless target
|
|
1320
|
+
|
|
1321
|
+
vibe_reindent_target(target, motion, command)
|
|
1322
|
+
end
|
|
1323
|
+
|
|
1324
|
+
def vibe_reindent_to_line(line_count, command)
|
|
1325
|
+
line, = @editor_state.cursor_line_and_column
|
|
1326
|
+
target_line = line_count.positive? ? line_count - 1 : @editor_state.lines.length - 1
|
|
1327
|
+
vibe_reindent_line_range(line, target_line, command)
|
|
1328
|
+
end
|
|
1329
|
+
|
|
1330
|
+
def vibe_reindent_target(target, motion, command)
|
|
1331
|
+
start_line, = @editor_state.cursor_line_and_column_for(target.start_index)
|
|
1332
|
+
end_line = if motion.match?(/\A[ai].\z/)
|
|
1333
|
+
@editor_state.cursor_line_and_column_for([target.end_index - 1, target.start_index].max).first
|
|
1334
|
+
else
|
|
1335
|
+
@editor_state.cursor_line_and_column.first
|
|
1336
|
+
end
|
|
1337
|
+
vibe_reindent_line_range(start_line, end_line, command)
|
|
1338
|
+
end
|
|
1339
|
+
|
|
1340
|
+
def vibe_reindent_line_range(first_line, last_line, command = nil)
|
|
1341
|
+
start_line, end_line = [first_line, last_line].minmax
|
|
1342
|
+
end_line = [end_line, @editor_state.lines.length - 1].min
|
|
1343
|
+
vibe_record_undo do
|
|
1344
|
+
(start_line..end_line).each do |line_index|
|
|
1345
|
+
next if @editor_state.lines[line_index].to_s.strip.empty?
|
|
1346
|
+
|
|
1347
|
+
editor_reindent_line(line_index, editor_expected_indent_for_line(line_index))
|
|
1348
|
+
end
|
|
1349
|
+
end
|
|
1350
|
+
@editor_state.move_to_line_first_non_blank(end_line)
|
|
1351
|
+
@editor_state.status = "Reindented #{end_line - start_line + 1} line#{end_line == start_line ? "" : "s"}"
|
|
1352
|
+
vibe_remember_change(command) if command
|
|
1353
|
+
true
|
|
1354
|
+
end
|
|
1355
|
+
|
|
1287
1356
|
def vibe_change_lines(count, command = nil)
|
|
1288
1357
|
start_index, end_index = vibe_linewise_change_range(count)
|
|
1289
1358
|
@editor_state.copy_range(start_index, end_index)
|
|
@@ -10,10 +10,6 @@ module Kward
|
|
|
10
10
|
@banner.rows(width, message: message)
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def banner_logo_rows
|
|
14
|
-
@banner.logo_rows(screen_width)
|
|
15
|
-
end
|
|
16
|
-
|
|
17
13
|
def transcript_redraw_row_count(height = screen_height)
|
|
18
14
|
[[@transcript_viewport_rows, transcript_bottom_row(height)].max, height].min
|
|
19
15
|
end
|
|
@@ -12,6 +12,28 @@ module Kward
|
|
|
12
12
|
PROJECT_BROWSER_ROOT = "".freeze
|
|
13
13
|
PROJECT_BROWSER_RESULT_LIMIT = 200
|
|
14
14
|
PROJECT_BROWSER_STATE_VERSION = 1
|
|
15
|
+
PROJECT_BROWSER_FILENAME_ICONS = {
|
|
16
|
+
".gitignore" => "",
|
|
17
|
+
"Gemfile" => "",
|
|
18
|
+
"Rakefile" => "",
|
|
19
|
+
"README" => "",
|
|
20
|
+
"README.md" => "",
|
|
21
|
+
"package.json" => ""
|
|
22
|
+
}.freeze
|
|
23
|
+
PROJECT_BROWSER_EXTENSION_ICONS = {
|
|
24
|
+
"css" => "",
|
|
25
|
+
"html" => "",
|
|
26
|
+
"js" => "",
|
|
27
|
+
"json" => "",
|
|
28
|
+
"md" => "",
|
|
29
|
+
"rb" => "",
|
|
30
|
+
"sh" => "",
|
|
31
|
+
"ts" => "",
|
|
32
|
+
"yaml" => "",
|
|
33
|
+
"yml" => ""
|
|
34
|
+
}.freeze
|
|
35
|
+
PROJECT_BROWSER_DIRECTORY_ICON = ""
|
|
36
|
+
PROJECT_BROWSER_FILE_ICON = ""
|
|
15
37
|
|
|
16
38
|
def open_project_browser
|
|
17
39
|
@mutex.synchronize do
|
|
@@ -399,8 +421,25 @@ module Kward
|
|
|
399
421
|
else
|
|
400
422
|
" "
|
|
401
423
|
end
|
|
424
|
+
icon = project_browser_icon(row)
|
|
402
425
|
suffix = row[:directory] ? "/" : ""
|
|
403
|
-
"#{indent}#{marker}#{row[:name]}#{suffix}"
|
|
426
|
+
"#{indent}#{marker}#{icon}#{row[:name]}#{suffix}"
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def project_browser_icon(row)
|
|
430
|
+
return "" unless @project_browser_icon_theme == "nerd-font"
|
|
431
|
+
|
|
432
|
+
icon = if row[:directory]
|
|
433
|
+
PROJECT_BROWSER_DIRECTORY_ICON
|
|
434
|
+
else
|
|
435
|
+
project_browser_file_icon(row[:path])
|
|
436
|
+
end
|
|
437
|
+
"#{icon} "
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def project_browser_file_icon(path)
|
|
441
|
+
name = File.basename(path.to_s)
|
|
442
|
+
PROJECT_BROWSER_FILENAME_ICONS[name] || PROJECT_BROWSER_EXTENSION_ICONS[File.extname(name).delete_prefix(".").downcase] || PROJECT_BROWSER_FILE_ICON
|
|
404
443
|
end
|
|
405
444
|
|
|
406
445
|
def saved_project_browser_state
|
|
@@ -337,11 +337,12 @@ module Kward
|
|
|
337
337
|
|
|
338
338
|
def question_overlay_rows(width)
|
|
339
339
|
title = "Question #{@question_state[:index]}/#{@question_state[:total]} · #{@question_state[:header]}"
|
|
340
|
-
|
|
341
|
-
|
|
340
|
+
content_width = [overlay_card_width(width) - 4, 1].max
|
|
341
|
+
lines = question_text_rows(@question_state[:question], content_width, :bold)
|
|
342
|
+
lines.concat([
|
|
342
343
|
overlay_text_line("↑/↓ select · Enter choose · Esc cancel", :muted),
|
|
343
344
|
overlay_blank_line
|
|
344
|
-
]
|
|
345
|
+
])
|
|
345
346
|
question_choices.each_with_index do |choice, index|
|
|
346
347
|
selected = index == question_selection_index
|
|
347
348
|
lines << overlay_choice_line(choice_text(choice, selected: selected), selected: selected)
|
|
@@ -349,6 +350,14 @@ module Kward
|
|
|
349
350
|
overlay_card_rows(title, lines, width)
|
|
350
351
|
end
|
|
351
352
|
|
|
353
|
+
def question_text_rows(text, width, style)
|
|
354
|
+
text.to_s.split("\n", -1).flat_map do |line|
|
|
355
|
+
wrapped = ANSI.wrap_visible(line, width)
|
|
356
|
+
wrapped = [""] if wrapped.empty?
|
|
357
|
+
wrapped.map { |row| overlay_text_line(row, style) }
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
352
361
|
def choice_text(choice, selected: false)
|
|
353
362
|
if choice[:custom]
|
|
354
363
|
selected ? "Type a custom answer below." : "Type something."
|
|
@@ -74,6 +74,10 @@ module Kward
|
|
|
74
74
|
ANSI.colorize(text, *styles, enabled: @color_enabled)
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
def normalize_project_browser_icon_theme(value)
|
|
78
|
+
value.to_s == "nerd-font" ? "nerd-font" : "off"
|
|
79
|
+
end
|
|
80
|
+
|
|
77
81
|
def normalize_tab_keybindings(value)
|
|
78
82
|
text = value.to_s.downcase
|
|
79
83
|
return "ctrl" if text == "ctrl"
|
|
@@ -40,10 +40,6 @@ module Kward
|
|
|
40
40
|
@transcript_buffer.append(text.to_s)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
def invalidate_transcript_display_rows_cache
|
|
44
|
-
@transcript_buffer.invalidate_display_rows_cache
|
|
45
|
-
end
|
|
46
|
-
|
|
47
43
|
def ensure_transcript_block_separator_locked
|
|
48
44
|
return if @transcript_buffer.empty? || @transcript_buffer.end_with?("\n\n")
|
|
49
45
|
|
|
@@ -65,10 +61,6 @@ module Kward
|
|
|
65
61
|
@output_io.print(terminal_newlines(rows.join("\n")))
|
|
66
62
|
end
|
|
67
63
|
|
|
68
|
-
def transcript_viewport_text(row_count, width)
|
|
69
|
-
transcript_viewport_rows(row_count, width).join("\n")
|
|
70
|
-
end
|
|
71
|
-
|
|
72
64
|
def transcript_viewport_rows(row_count, width)
|
|
73
65
|
return [] unless row_count.positive?
|
|
74
66
|
|
|
@@ -89,10 +81,6 @@ module Kward
|
|
|
89
81
|
@transcript_buffer.display_rows(width)
|
|
90
82
|
end
|
|
91
83
|
|
|
92
|
-
def transcript_text_display_rows(width)
|
|
93
|
-
@transcript_buffer.text_display_rows(width)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
84
|
def reset_stream_position_from_transcript_locked(width = screen_width)
|
|
97
85
|
@stream_state.reset_position_from_rows(transcript_display_rows(width), width)
|
|
98
86
|
end
|
|
@@ -25,6 +25,7 @@ require_relative "prompt_interface/file_overlay"
|
|
|
25
25
|
require_relative "prompt_interface/project_browser"
|
|
26
26
|
require_relative "prompt_interface/selection_prompt"
|
|
27
27
|
require_relative "prompt_interface/question_prompt"
|
|
28
|
+
require_relative "prompt_interface/approval_prompt"
|
|
28
29
|
require_relative "prompt_interface/git_prompt"
|
|
29
30
|
require_relative "prompt_interface/overlay_renderer"
|
|
30
31
|
require_relative "prompt_interface/editor/renderer"
|
|
@@ -75,6 +76,7 @@ module Kward
|
|
|
75
76
|
include ProjectBrowser
|
|
76
77
|
include SelectionPrompt
|
|
77
78
|
include QuestionPrompt
|
|
79
|
+
include ApprovalPrompt
|
|
78
80
|
include GitPrompt
|
|
79
81
|
include OverlayRenderer
|
|
80
82
|
include EditorRenderer
|
|
@@ -126,7 +128,7 @@ module Kward
|
|
|
126
128
|
end
|
|
127
129
|
end
|
|
128
130
|
|
|
129
|
-
def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil)
|
|
131
|
+
def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, project_browser_icon_theme: "off", footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil)
|
|
130
132
|
@input_io = input
|
|
131
133
|
@output_io = output
|
|
132
134
|
@reader = TTY::Reader.new(input: input, output: output, interrupt: :error)
|
|
@@ -186,6 +188,7 @@ module Kward
|
|
|
186
188
|
@editor_bar_cursor_active = false
|
|
187
189
|
@synchronized_output_depth = 0
|
|
188
190
|
@overlay_settings = normalize_overlay_settings(overlay_settings)
|
|
191
|
+
@project_browser_icon_theme = normalize_project_browser_icon_theme(project_browser_icon_theme)
|
|
189
192
|
@footer = footer
|
|
190
193
|
@composer_status = composer_status
|
|
191
194
|
@busy_help = busy_help
|
|
@@ -211,6 +214,15 @@ module Kward
|
|
|
211
214
|
@diff_view_source = diff_view_source
|
|
212
215
|
end
|
|
213
216
|
|
|
217
|
+
def update_slash_commands(commands)
|
|
218
|
+
@mutex.synchronize do
|
|
219
|
+
@slash_commands = normalize_slash_commands(commands)
|
|
220
|
+
reset_slash_selection
|
|
221
|
+
@slash_overlay_dismissed_input = nil
|
|
222
|
+
render_prompt_locked if @started
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
214
226
|
def start(render: true)
|
|
215
227
|
@mutex.synchronize do
|
|
216
228
|
return if @started
|
|
@@ -661,6 +673,13 @@ module Kward
|
|
|
661
673
|
end
|
|
662
674
|
end
|
|
663
675
|
|
|
676
|
+
def update_project_browser_icon_theme(theme)
|
|
677
|
+
@mutex.synchronize do
|
|
678
|
+
@project_browser_icon_theme = normalize_project_browser_icon_theme(theme)
|
|
679
|
+
render_prompt_locked if @started && @asking && project_browser_visible?
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
|
|
664
683
|
def begin_busy_input(message = "You>", activity: "streaming")
|
|
665
684
|
start
|
|
666
685
|
@mutex.synchronize do
|