kward 0.82.0 → 0.84.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/agent-tools.md +1 -0
  9. data/doc/api.md +4 -0
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +82 -20
  12. data/doc/editor.md +47 -22
  13. data/doc/files.md +12 -7
  14. data/doc/getting-started.md +3 -0
  15. data/doc/pan.md +19 -15
  16. data/doc/permissions.md +1 -0
  17. data/doc/platform-support.md +48 -0
  18. data/doc/releasing.md +12 -4
  19. data/doc/rpc.md +2 -2
  20. data/doc/sandboxing.md +5 -1
  21. data/doc/security.md +4 -3
  22. data/doc/shell.md +101 -60
  23. data/doc/tabs.md +3 -0
  24. data/doc/troubleshooting.md +12 -2
  25. data/doc/usage.md +7 -6
  26. data/kward.gemspec +5 -4
  27. data/lib/kward/agent.rb +7 -3
  28. data/lib/kward/ansi.rb +110 -10
  29. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  30. data/lib/kward/cli/auth_commands.rb +34 -13
  31. data/lib/kward/cli/commands.rb +83 -62
  32. data/lib/kward/cli/doctor.rb +39 -17
  33. data/lib/kward/cli/hook_commands.rb +22 -12
  34. data/lib/kward/cli/interactive_turn.rb +144 -21
  35. data/lib/kward/cli/project_skills_commands.rb +8 -4
  36. data/lib/kward/cli/prompt_interface.rb +27 -0
  37. data/lib/kward/cli/rendering.rb +15 -9
  38. data/lib/kward/cli/runtime_helpers.rb +260 -55
  39. data/lib/kward/cli/slash_commands.rb +21 -12
  40. data/lib/kward/cli/tabs.rb +91 -13
  41. data/lib/kward/cli/tool_summaries.rb +14 -0
  42. data/lib/kward/cli.rb +55 -7
  43. data/lib/kward/cli_transcript_formatter.rb +11 -4
  44. data/lib/kward/compaction/token_estimator.rb +12 -6
  45. data/lib/kward/config_files.rb +94 -63
  46. data/lib/kward/detached_run.rb +44 -0
  47. data/lib/kward/editor_prompt.rb +46 -0
  48. data/lib/kward/editor_prompt_session.rb +28 -0
  49. data/lib/kward/interactive_pty_runner.rb +102 -28
  50. data/lib/kward/{ekwsh.rb → kwsh.rb} +103 -24
  51. data/lib/kward/kwshrc.rb +233 -0
  52. data/lib/kward/markdown_code_block.rb +136 -0
  53. data/lib/kward/model/client.rb +35 -39
  54. data/lib/kward/model/model_info.rb +3 -2
  55. data/lib/kward/model/payloads.rb +0 -2
  56. data/lib/kward/model/provider_catalog.rb +5 -0
  57. data/lib/kward/model/stream_parser.rb +20 -4
  58. data/lib/kward/pan/index.html.erb +3 -3
  59. data/lib/kward/pan/server.rb +23 -3
  60. data/lib/kward/persistent_shell_session.rb +843 -0
  61. data/lib/kward/project_files.rb +19 -6
  62. data/lib/kward/prompt_interface/composer_renderer.rb +45 -41
  63. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  64. data/lib/kward/prompt_interface/editor/auto_indent.rb +27 -22
  65. data/lib/kward/prompt_interface/editor/controller.rb +99 -33
  66. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  67. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  68. data/lib/kward/prompt_interface/editor/modes/vibe.rb +298 -47
  69. data/lib/kward/prompt_interface/editor/renderer.rb +109 -6
  70. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  71. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/state.rb +21 -10
  73. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  74. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  75. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  76. data/lib/kward/prompt_interface/file_overlay.rb +92 -22
  77. data/lib/kward/prompt_interface/key_handler.rb +75 -0
  78. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  79. data/lib/kward/prompt_interface/project_browser.rb +443 -17
  80. data/lib/kward/prompt_interface/runtime_state.rb +55 -2
  81. data/lib/kward/prompt_interface/screen.rb +2 -2
  82. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  83. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  84. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  85. data/lib/kward/prompt_interface.rb +169 -18
  86. data/lib/kward/prompts/commands.rb +2 -1
  87. data/lib/kward/prompts.rb +1 -1
  88. data/lib/kward/pty_output_sink.rb +47 -0
  89. data/lib/kward/rpc/auth_manager.rb +1 -1
  90. data/lib/kward/rpc/server.rb +2 -1
  91. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  92. data/lib/kward/scratchpad_languages.rb +74 -0
  93. data/lib/kward/scratchpad_runner.rb +155 -29
  94. data/lib/kward/shell_prompt.rb +52 -0
  95. data/lib/kward/shell_prompt_session.rb +58 -0
  96. data/lib/kward/terminal_keys.rb +13 -0
  97. data/lib/kward/terminal_text.rb +121 -0
  98. data/lib/kward/text_matcher.rb +18 -0
  99. data/lib/kward/tools/open_editor.rb +41 -0
  100. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  101. data/lib/kward/tools/registry.rb +75 -8
  102. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  103. data/lib/kward/tools/run_shell_command.rb +24 -6
  104. data/lib/kward/tools/tool_call.rb +2 -1
  105. data/lib/kward/version.rb +1 -1
  106. data/templates/default/fulldoc/html/css/kward.css +0 -125
  107. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  108. data/templates/default/fulldoc/html/setup.rb +1 -1
  109. data/templates/default/kward_navigation.rb +1 -0
  110. data/templates/default/layout/html/footer.erb +10 -0
  111. data/templates/default/layout/html/headers.erb +23 -0
  112. data/templates/default/layout/html/layout.erb +6 -18
  113. data/templates/default/layout/html/setup.rb +41 -2
  114. metadata +44 -8
  115. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
@@ -0,0 +1,124 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Interactive terminal UI used by the CLI frontend.
4
+ class PromptInterface
5
+ # Current-buffer word completion for the built-in composer file editor.
6
+ module EditorWordCompletion
7
+ EDITOR_COMPLETION_WORD_PATTERN = /[[:alnum:]_]+/.freeze
8
+ EDITOR_COMPLETION_PREFIX_PATTERN = /[[:alnum:]_]+\z/.freeze
9
+ EDITOR_COMPLETION_SUFFIX_PATTERN = /\A[[:alnum:]_]*/.freeze
10
+
11
+ private
12
+
13
+ def editor_complete_buffer_word
14
+ if (cycle = matching_editor_completion_cycle)
15
+ return cycle_editor_word_completion(cycle) if cycle[:candidates].length > 1
16
+
17
+ reset_editor_word_completion
18
+ end
19
+
20
+ context = editor_word_completion_context
21
+ return false unless context
22
+
23
+ candidates = editor_word_completion_candidates(context)
24
+ return false if candidates.empty?
25
+
26
+ @editor_word_completion = context.merge(
27
+ editor_state: @editor_state,
28
+ candidates: candidates,
29
+ index: 0,
30
+ previous_status: @editor_state.status
31
+ )
32
+ apply_editor_word_completion(@editor_word_completion)
33
+ true
34
+ end
35
+
36
+ def editor_word_completion_context
37
+ buffer = @editor_state.buffer
38
+ cursor = @editor_state.cursor
39
+ prefix_match = buffer[0...cursor].to_s.match(EDITOR_COMPLETION_PREFIX_PATTERN)
40
+ return nil unless prefix_match
41
+
42
+ suffix = buffer[cursor..].to_s.match(EDITOR_COMPLETION_SUFFIX_PATTERN).to_s
43
+ {
44
+ prefix: prefix_match[0],
45
+ range_start: prefix_match.begin(0),
46
+ range_end: cursor + suffix.length
47
+ }
48
+ end
49
+
50
+ def editor_word_completion_candidates(context)
51
+ matches = []
52
+ @editor_state.buffer.to_enum(:scan, EDITOR_COMPLETION_WORD_PATTERN).each do
53
+ match = Regexp.last_match
54
+ next if editor_completion_match_overlaps_context?(match, context)
55
+
56
+ word = match[0]
57
+ next unless word.length > context[:prefix].length
58
+ next unless word.start_with?(context[:prefix])
59
+
60
+ matches << [word, match.begin(0)]
61
+ end
62
+
63
+ matches
64
+ .sort_by { |_word, offset| editor_completion_candidate_order(offset, context) }
65
+ .map(&:first)
66
+ .uniq
67
+ end
68
+
69
+ def editor_completion_match_overlaps_context?(match, context)
70
+ match.begin(0) < context[:range_end] && match.end(0) > context[:range_start]
71
+ end
72
+
73
+ def editor_completion_candidate_order(offset, context)
74
+ if offset < context[:range_start]
75
+ [0, context[:range_start] - offset]
76
+ else
77
+ [1, offset - context[:range_end]]
78
+ end
79
+ end
80
+
81
+ def matching_editor_completion_cycle
82
+ cycle = @editor_word_completion
83
+ return nil unless cycle
84
+
85
+ same_editor = cycle[:editor_state].equal?(@editor_state)
86
+ same_buffer = cycle[:buffer] == @editor_state.buffer
87
+ same_cursor = cycle[:cursor] == @editor_state.cursor
88
+ return cycle if same_editor && same_buffer && same_cursor
89
+
90
+ reset_editor_word_completion
91
+ nil
92
+ end
93
+
94
+ def cycle_editor_word_completion(cycle)
95
+ cycle[:index] = (cycle[:index] + 1) % cycle[:candidates].length
96
+ apply_editor_word_completion(cycle)
97
+ true
98
+ end
99
+
100
+ def apply_editor_word_completion(cycle)
101
+ candidate = cycle[:candidates][cycle[:index]]
102
+ @editor_state.replace_range(cycle[:range_start], cycle[:range_end], candidate)
103
+ @editor_state.cursor = cycle[:range_start] + candidate.length
104
+ cycle[:range_end] = @editor_state.cursor
105
+ cycle[:buffer] = @editor_state.buffer
106
+ cycle[:cursor] = @editor_state.cursor
107
+ cycle[:status] = "Completion #{cycle[:index] + 1}/#{cycle[:candidates].length}: #{candidate}"
108
+ @editor_state.status = cycle[:status]
109
+ end
110
+
111
+ def reset_editor_word_completion
112
+ cycle = @editor_word_completion
113
+ if cycle && cycle[:editor_state].status == cycle[:status]
114
+ cycle[:editor_state].status = cycle[:previous_status]
115
+ end
116
+ @editor_word_completion = nil
117
+ end
118
+
119
+ def editor_word_completion_tab_key?(key)
120
+ !editor_tab_sequence_for(key).nil?
121
+ end
122
+ end
123
+ end
124
+ end
@@ -1,3 +1,5 @@
1
+ require_relative "../text_matcher"
2
+
1
3
  # Namespace for the Kward CLI agent runtime.
2
4
  module Kward
3
5
  # File-mention completion overlay behavior.
@@ -86,36 +88,86 @@ module Kward
86
88
  def file_overlay_matches
87
89
  token = active_file_open || active_file_mention
88
90
  return [] unless token
91
+ unless @file_mention_paths
92
+ start_file_mention_discovery_locked
93
+ return []
94
+ end
89
95
 
90
96
  query = token[:query].downcase
97
+ entries = project_file_path_entries
98
+ cache = @file_overlay_match_cache
99
+ return cache[:matches] if cache && cache[:query] == query && cache[:entries].equal?(entries)
100
+
101
+ pattern = TextMatcher.subsequence_pattern(query)
91
102
  matches = []
92
- project_file_path_entries.each do |entry|
93
- next unless file_mention_match?(entry[:downcase], query)
103
+ entries.each do |entry|
104
+ next unless TextMatcher.subsequence?(entry[:downcase], query, pattern)
94
105
 
95
106
  matches << entry[:path]
96
107
  break if matches.length >= FILE_MENTION_RESULT_LIMIT
97
108
  end
109
+ @file_overlay_match_cache = { query: query, entries: entries, matches: matches }
98
110
  matches
99
111
  end
100
112
 
101
- def file_mention_match?(path, query)
102
- return true if query.empty?
103
- return true if path.include?(query)
104
-
105
- query_chars = query.chars
106
- query_chars.all? do |char|
107
- index = path.index(char)
108
- if index
109
- path = path[(index + 1)..].to_s
110
- true
111
- else
112
- false
113
+ def start_file_mention_discovery_locked
114
+ root = prompt_workspace_root
115
+ active_thread = @file_mention_discovery_thread
116
+ return if active_thread&.alive? && @file_mention_discovery_root == root
117
+
118
+ token = Object.new
119
+ start = Queue.new
120
+ @file_mention_discovery_root = root
121
+ @file_mention_discovery_token = token
122
+ worker = Thread.new do
123
+ start.pop
124
+ paths = discover_file_mention_paths(root)
125
+ entries = build_file_path_entries(paths)
126
+ @mutex.synchronize do
127
+ next unless @file_mention_discovery_token.equal?(token)
128
+
129
+ @file_mention_discovery_thread = nil
130
+ @file_mention_discovery_root = nil
131
+ @file_mention_discovery_token = nil
132
+ next unless root == prompt_workspace_root
133
+
134
+ @file_mention_paths = paths
135
+ @file_mention_path_entries_paths = paths
136
+ @file_mention_path_entries = entries
137
+ @file_overlay_match_cache = nil
138
+ render_prompt_locked if @started && @asking && file_overlay_visible?
113
139
  end
114
140
  end
141
+ worker.report_on_exception = false
142
+ @file_mention_discovery_thread = worker
143
+ start << true
144
+ end
145
+
146
+ def discover_file_mention_paths(root)
147
+ ProjectFiles.list(root: root)
148
+ end
149
+
150
+ def finish_file_mention_discovery_locked
151
+ root = prompt_workspace_root
152
+ @file_mention_discovery_thread = nil
153
+ @file_mention_discovery_root = nil
154
+ @file_mention_discovery_token = nil
155
+ @file_mention_paths = discover_file_mention_paths(root)
156
+ @file_mention_path_entries_paths = @file_mention_paths
157
+ @file_mention_path_entries = build_file_path_entries(@file_mention_paths)
158
+ @file_overlay_match_cache = nil
159
+ end
160
+
161
+ def file_mention_discovery_loading?
162
+ @file_mention_discovery_thread&.alive? && @file_mention_discovery_root == prompt_workspace_root
115
163
  end
116
164
 
117
- def project_file_paths
118
- @file_mention_paths ||= discover_project_file_paths
165
+ def project_file_paths(include_ignored: false)
166
+ if include_ignored
167
+ @project_browser_file_paths ||= discover_project_file_paths(include_ignored: true)
168
+ else
169
+ @file_mention_paths ||= discover_project_file_paths
170
+ end
119
171
  end
120
172
 
121
173
  def project_file_path_entries
@@ -123,17 +175,31 @@ module Kward
123
175
  return @file_mention_path_entries if @file_mention_path_entries_paths.equal?(paths) && @file_mention_path_entries
124
176
 
125
177
  @file_mention_path_entries_paths = paths
126
- @file_mention_path_entries = paths.map { |path| { path: path, downcase: path.downcase } }
178
+ @file_mention_path_entries = build_file_path_entries(paths)
179
+ end
180
+
181
+ def build_file_path_entries(paths)
182
+ paths.map { |path| { path: path, downcase: path.downcase } }
127
183
  end
128
184
 
129
- def discover_project_file_paths
130
- paths = git_project_file_paths
131
- paths = scanned_project_file_paths if paths.empty?
185
+ def discover_project_file_paths(include_ignored: false)
186
+ paths = if include_ignored
187
+ git_project_file_paths(include_ignored: true)
188
+ else
189
+ git_project_file_paths
190
+ end
191
+ paths = scanned_project_file_paths if paths.empty? && !git_project_repository?
132
192
  paths.reject { |path| path.empty? || path.end_with?("/") }.uniq.sort
133
193
  end
134
194
 
135
- def git_project_file_paths
136
- ProjectFiles.git_paths(prompt_workspace_root)
195
+ def git_project_file_paths(include_ignored: false)
196
+ return ProjectFiles.git_paths(prompt_workspace_root) unless include_ignored
197
+
198
+ ProjectFiles.git_paths(prompt_workspace_root, include_ignored: true)
199
+ end
200
+
201
+ def git_project_repository?
202
+ ProjectFiles.git_repository?(prompt_workspace_root)
137
203
  end
138
204
 
139
205
  def scanned_project_file_paths
@@ -151,6 +217,7 @@ module Kward
151
217
  def selected_file_overlay_path
152
218
  return nil unless file_overlay_visible?
153
219
 
220
+ finish_file_mention_discovery_locked unless @file_mention_paths
154
221
  matches = file_overlay_matches
155
222
  return nil if matches.empty?
156
223
 
@@ -186,6 +253,9 @@ module Kward
186
253
  return [] unless file_overlay_visible?
187
254
 
188
255
  matches = file_overlay_matches
256
+ if file_mention_discovery_loading?
257
+ return overlay_card_rows("Files", [overlay_text_line("Loading project files…", :muted)], width)
258
+ end
189
259
  if matches.empty?
190
260
  return overlay_card_rows("Files", [overlay_text_line("No matching files", :muted)], width)
191
261
  end
@@ -6,6 +6,73 @@ module Kward
6
6
  module KeyHandler
7
7
  private
8
8
 
9
+ def filter_terminal_handoff_input_locked(chunk)
10
+ @handoff_input_buffer << chunk.to_s.b
11
+ forwarded = +"".b
12
+ tab_action = nil
13
+
14
+ until @handoff_input_buffer.empty?
15
+ if @handoff_input_buffer.start_with?(TerminalKeys::CTRL_T)
16
+ action = handle_tab_key_binding(TerminalKeys::CTRL_T)
17
+ if action != false
18
+ remainder = @handoff_input_buffer.byteslice(TerminalKeys::CTRL_T.bytesize..).to_s
19
+ queue_pending_keys(remainder) unless remainder.empty?
20
+ @handoff_input_buffer.clear
21
+ tab_action = action
22
+ break
23
+ end
24
+ forwarded << @handoff_input_buffer.slice!(0)
25
+ next
26
+ end
27
+
28
+ if @handoff_input_buffer.start_with?("\e")
29
+ complete = TerminalKeys::TAB_ACTION_SEQUENCES.find { |sequence| @handoff_input_buffer.start_with?(sequence) }
30
+ partial = TerminalKeys::TAB_ACTION_SEQUENCES.any? { |sequence| sequence.start_with?(@handoff_input_buffer) }
31
+ break if partial && !complete
32
+
33
+ if complete
34
+ action = handle_tab_key_binding(complete)
35
+ if action != false
36
+ remainder = @handoff_input_buffer.byteslice(complete.bytesize..).to_s
37
+ queue_pending_keys(remainder) unless remainder.empty?
38
+ @handoff_input_buffer.clear
39
+ tab_action = action
40
+ break
41
+ end
42
+ forwarded << complete
43
+ @handoff_input_buffer = @handoff_input_buffer.byteslice(complete.bytesize..).to_s.b
44
+ next
45
+ end
46
+
47
+ if (sequence = parse_csi_u_key(@handoff_input_buffer)) && @handoff_input_buffer.start_with?(sequence[:sequence])
48
+ legacy_key = legacy_handoff_key(sequence)
49
+ unless legacy_key.nil?
50
+ forwarded << legacy_key
51
+ @handoff_input_buffer = @handoff_input_buffer.byteslice(sequence[:sequence].bytesize..).to_s.b
52
+ next
53
+ end
54
+ end
55
+ end
56
+
57
+ forwarded << @handoff_input_buffer.slice!(0)
58
+ end
59
+
60
+ { input: forwarded, tab_action: tab_action }
61
+ end
62
+
63
+ def legacy_handoff_key(sequence)
64
+ event = csi_u_key_event(sequence)
65
+ return event[:text] if event[:type] == :printable
66
+ return "\r" if event[:type] == :enter
67
+ return "\b" if event[:type] == :backspace
68
+ return "\t" if event[:type] == :tab
69
+
70
+ return unless event[:type] == :modified && ctrl_modifier?(event[:modifier])
71
+ return unless event[:code].between?(32, 126)
72
+
73
+ (event[:code] & 0x1f).chr
74
+ end
75
+
9
76
  def read_key(nonblock: false)
10
77
  pending = @pending_keys.shift unless @pending_keys.empty?
11
78
  return pending if pending
@@ -20,6 +87,14 @@ module Kward
20
87
  end
21
88
 
22
89
  def handle_editor_input_key(key)
90
+ if @editor_agent_suspended
91
+ return CANCEL_INPUT if @busy && key == TerminalKeys::CTRL_C
92
+
93
+ return true
94
+ end
95
+
96
+ reset_editor_word_completion unless editor_word_completion_tab_key?(key)
97
+
23
98
  tab_result = handle_tab_key_binding(key)
24
99
  return tab_result unless tab_result == false
25
100
 
@@ -1,3 +1,5 @@
1
+ require_relative "../terminal_text"
2
+
1
3
  # Namespace for the Kward CLI agent runtime.
2
4
  module Kward
3
5
  # Shared overlay drawing helpers for prompt UI popups.
@@ -63,7 +65,7 @@ module Kward
63
65
 
64
66
  def overlay_top_border(title, card_width)
65
67
  title = visible_truncate(title.to_s, [card_width - 4, 1].max)
66
- plain_length = ANSI.strip(title).length
68
+ plain_length = TerminalText.width(ANSI.strip(title))
67
69
  colored("╭", :primary_green) + " #{colored(title, :primary_green, :bold)} " + colored("─" * [card_width - plain_length - 4, 0].max, :primary_green) + colored("╮", :primary_green)
68
70
  end
69
71
 
@@ -98,7 +100,7 @@ module Kward
98
100
  end
99
101
 
100
102
  def align_overlay_row(row, width)
101
- plain_length = ANSI.strip(row).length
103
+ plain_length = TerminalText.width(ANSI.strip(row))
102
104
  padding = [width - plain_length, 0].max
103
105
  left = overlay_left_padding(width, plain_length)
104
106
  right = padding - left
@@ -152,14 +154,14 @@ module Kward
152
154
  end
153
155
 
154
156
  def visible_ljust(text, width)
155
- text.to_s + (" " * [width - ANSI.strip(text.to_s).length, 0].max)
157
+ text.to_s + (" " * [width - TerminalText.width(ANSI.strip(text.to_s)), 0].max)
156
158
  end
157
159
 
158
160
  def visible_truncate(text, width)
159
161
  plain = ANSI.strip(text.to_s)
160
- return text.to_s if plain.length <= width
162
+ return text.to_s if TerminalText.width(plain) <= width
161
163
 
162
- plain[0, width]
164
+ TerminalText.truncate(plain, width)
163
165
  end
164
166
 
165
167
  end