kward 0.70.0 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +89 -3
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +34 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +52 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +58 -23
  11. data/doc/code-search.md +42 -2
  12. data/doc/configuration.md +102 -13
  13. data/doc/context-budgeting.md +136 -0
  14. data/doc/context-tools.md +83 -0
  15. data/doc/editor.md +394 -0
  16. data/doc/extensibility.md +16 -7
  17. data/doc/files.md +100 -0
  18. data/doc/getting-started.md +25 -18
  19. data/doc/git.md +122 -0
  20. data/doc/memory.md +24 -4
  21. data/doc/personas.md +34 -5
  22. data/doc/plugins.md +74 -3
  23. data/doc/releasing.md +45 -8
  24. data/doc/rpc.md +77 -15
  25. data/doc/session-management.md +254 -0
  26. data/doc/shell.md +286 -0
  27. data/doc/tabs.md +122 -0
  28. data/doc/troubleshooting.md +77 -1
  29. data/doc/usage.md +60 -15
  30. data/doc/web-search.md +12 -4
  31. data/doc/workspace-tools.md +144 -0
  32. data/examples/plugins/space_invaders.rb +377 -0
  33. data/lib/kward/agent.rb +1 -1
  34. data/lib/kward/cli/commands.rb +41 -2
  35. data/lib/kward/cli/git.rb +150 -0
  36. data/lib/kward/cli/interactive_turn.rb +73 -9
  37. data/lib/kward/cli/openrouter_commands.rb +55 -0
  38. data/lib/kward/cli/plugins.rb +54 -4
  39. data/lib/kward/cli/prompt_interface.rb +111 -6
  40. data/lib/kward/cli/rendering.rb +11 -6
  41. data/lib/kward/cli/runtime_helpers.rb +133 -3
  42. data/lib/kward/cli/sessions.rb +262 -13
  43. data/lib/kward/cli/settings.rb +216 -37
  44. data/lib/kward/cli/slash_commands.rb +439 -8
  45. data/lib/kward/cli/tabs.rb +695 -0
  46. data/lib/kward/cli.rb +171 -26
  47. data/lib/kward/compactor.rb +4 -1
  48. data/lib/kward/config_files.rb +125 -5
  49. data/lib/kward/context_budget_meter.rb +44 -0
  50. data/lib/kward/conversation.rb +59 -22
  51. data/lib/kward/editor_mode.rb +25 -0
  52. data/lib/kward/ekwsh.rb +362 -0
  53. data/lib/kward/model/client.rb +37 -50
  54. data/lib/kward/model/context_usage.rb +13 -6
  55. data/lib/kward/model/model_info.rb +92 -16
  56. data/lib/kward/model/payloads.rb +2 -0
  57. data/lib/kward/openrouter_model_cache.rb +120 -0
  58. data/lib/kward/plugin_registry.rb +108 -1
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +82 -0
  61. data/lib/kward/prompt_interface/banner.rb +16 -51
  62. data/lib/kward/prompt_interface/composer_controller.rb +124 -83
  63. data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
  64. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  65. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  66. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  67. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  68. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  69. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  70. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  71. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  72. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  73. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  74. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  77. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  78. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  79. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  82. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  83. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  84. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  85. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  86. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  87. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  88. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  89. data/lib/kward/prompt_interface/key_handler.rb +416 -43
  90. data/lib/kward/prompt_interface/layout.rb +2 -2
  91. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  92. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  93. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  94. data/lib/kward/prompt_interface/question_prompt.rb +122 -82
  95. data/lib/kward/prompt_interface/runtime_state.rb +49 -1
  96. data/lib/kward/prompt_interface/screen.rb +17 -0
  97. data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
  98. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  99. data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
  100. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  101. data/lib/kward/prompt_interface.rb +307 -35
  102. data/lib/kward/prompts/commands.rb +7 -1
  103. data/lib/kward/prompts.rb +4 -2
  104. data/lib/kward/rpc/server.rb +45 -11
  105. data/lib/kward/rpc/session_manager.rb +52 -53
  106. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  107. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  108. data/lib/kward/session_store.rb +67 -4
  109. data/lib/kward/session_tree_nodes.rb +136 -0
  110. data/lib/kward/session_tree_renderer.rb +9 -131
  111. data/lib/kward/tab_store.rb +47 -0
  112. data/lib/kward/telemetry/logger.rb +5 -3
  113. data/lib/kward/text_boundary.rb +25 -0
  114. data/lib/kward/tool_output_compactor.rb +127 -0
  115. data/lib/kward/tools/base.rb +8 -2
  116. data/lib/kward/tools/context_budget_stats.rb +54 -0
  117. data/lib/kward/tools/context_for_task.rb +202 -0
  118. data/lib/kward/tools/read_file.rb +8 -4
  119. data/lib/kward/tools/registry.rb +92 -15
  120. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  121. data/lib/kward/tools/search/web.rb +2 -2
  122. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  123. data/lib/kward/tools/tool_call.rb +12 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +68 -0
  126. data/lib/kward/workers/live_view.rb +49 -0
  127. data/lib/kward/workers/manager.rb +288 -0
  128. data/lib/kward/workers/store.rb +72 -0
  129. data/lib/kward/workers/tool_policy.rb +23 -0
  130. data/lib/kward/workers/worker.rb +82 -0
  131. data/lib/kward/workers/write_lock.rb +38 -0
  132. data/lib/kward/workers.rb +7 -0
  133. data/lib/kward/workspace.rb +154 -12
  134. data/templates/default/fulldoc/html/css/kward.css +362 -42
  135. data/templates/default/fulldoc/html/full_list.erb +107 -0
  136. data/templates/default/fulldoc/html/js/kward.js +161 -2
  137. data/templates/default/fulldoc/html/setup.rb +8 -0
  138. data/templates/default/kward_navigation.rb +102 -0
  139. data/templates/default/layout/html/layout.erb +43 -10
  140. data/templates/default/layout/html/setup.rb +39 -38
  141. metadata +65 -3
  142. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  143. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -10,6 +10,13 @@ module Kward
10
10
  reset
11
11
  end
12
12
 
13
+ def initialize_copy(source)
14
+ super
15
+ @block = source.block&.dup
16
+ @col = source.col
17
+ @pending_wrap = source.pending_wrap?
18
+ end
19
+
13
20
  def reset
14
21
  @block = nil
15
22
  @col = 0
@@ -10,10 +10,15 @@ module Kward
10
10
  @limit = limit
11
11
  @text = +""
12
12
  @display_rows_cache_width = nil
13
- @display_rows_cache_banner_count = nil
14
13
  @display_rows_cache = nil
15
14
  end
16
15
 
16
+ def initialize_copy(source)
17
+ super
18
+ @text = source.text.dup
19
+ invalidate_display_rows_cache
20
+ end
21
+
17
22
  def to_s
18
23
  @text
19
24
  end
@@ -42,30 +47,23 @@ module Kward
42
47
  @text
43
48
  end
44
49
 
45
- def viewport_text(row_count, width, visual_banner_count:, banner_rows:)
46
- viewport_rows(row_count, width, visual_banner_count: visual_banner_count, banner_rows: banner_rows).join("\n")
50
+ def viewport_text(row_count, width)
51
+ viewport_rows(row_count, width).join("\n")
47
52
  end
48
53
 
49
- def viewport_rows(row_count, width, visual_banner_count:, banner_rows:)
54
+ def viewport_rows(row_count, width)
50
55
  return [] unless row_count.positive?
51
56
 
52
- rows = display_rows(width, visual_banner_count: visual_banner_count, banner_rows: banner_rows).last(row_count)
57
+ rows = display_rows(width).last(row_count)
53
58
  rows = ([""] * (row_count - rows.length)) + rows if rows.length < row_count
54
59
  rows
55
60
  end
56
61
 
57
- def display_rows(width, visual_banner_count:, banner_rows:)
58
- if @display_rows_cache_width == width && @display_rows_cache_banner_count == visual_banner_count && @display_rows_cache
59
- return @display_rows_cache
60
- end
62
+ def display_rows(width)
63
+ return @display_rows_cache if @display_rows_cache_width == width && @display_rows_cache
61
64
 
62
- rows = []
63
- visual_banner_count.times { rows.concat(banner_rows.call(width)) }
64
- rows << "" if visual_banner_count.positive? && @text.empty?
65
- rows.concat(text_display_rows(width))
66
65
  @display_rows_cache_width = width
67
- @display_rows_cache_banner_count = visual_banner_count
68
- @display_rows_cache = rows
66
+ @display_rows_cache = text_display_rows(width)
69
67
  end
70
68
 
71
69
  def text_display_rows(width)
@@ -77,7 +75,6 @@ module Kward
77
75
 
78
76
  def invalidate_display_rows_cache
79
77
  @display_rows_cache_width = nil
80
- @display_rows_cache_banner_count = nil
81
78
  @display_rows_cache = nil
82
79
  end
83
80
  end
@@ -11,7 +11,7 @@ module Kward
11
11
  prepare_transcript_output_locked unless @restoring_transcript
12
12
  if label && @stream_state.block != label
13
13
  ensure_transcript_block_separator_locked
14
- write_transcript_text_locked("#{colored("#{transcript_label(label)}>", *label_styles(label))}\n")
14
+ write_transcript_text_locked("#{colored("#{transcript_label(label)}>", *label_styles(label))} ")
15
15
  @stream_state.start_block(label)
16
16
  end
17
17
  write_transcript_text_locked(delta) unless delta.empty?
@@ -82,11 +82,11 @@ module Kward
82
82
  end
83
83
 
84
84
  def transcript_renderable?
85
- @visual_banner_count.positive? || !@transcript_buffer.empty?
85
+ !@transcript_buffer.empty?
86
86
  end
87
87
 
88
88
  def transcript_display_rows(width)
89
- @transcript_buffer.display_rows(width, visual_banner_count: @visual_banner_count, banner_rows: method(:banner_rows))
89
+ @transcript_buffer.display_rows(width)
90
90
  end
91
91
 
92
92
  def transcript_text_display_rows(width)
@@ -1,21 +1,44 @@
1
+ require "base64"
2
+ require "find"
1
3
  require "io/console"
4
+ require "pathname"
5
+ require "rbconfig"
2
6
  require "thread"
7
+ require_relative "project_files"
8
+ require_relative "prompt_history"
3
9
  require "tty-cursor"
4
10
  require "tty-reader"
5
11
  require "tty-screen"
6
12
  require_relative "ansi"
13
+ require_relative "editor_mode"
7
14
  require_relative "prompt_interface/banner"
8
15
  require_relative "prompt_interface/composer_state"
16
+ require_relative "prompt_interface/editor/state"
9
17
  require_relative "prompt_interface/transcript_buffer"
10
18
  require_relative "prompt_interface/transcript_renderer"
11
19
  require_relative "prompt_interface/prompt_renderer"
12
20
  require_relative "prompt_interface/stream_state"
13
21
  require_relative "prompt_interface/slash_overlay"
22
+ require_relative "prompt_interface/file_overlay"
23
+ require_relative "prompt_interface/project_browser"
14
24
  require_relative "prompt_interface/selection_prompt"
15
25
  require_relative "prompt_interface/question_prompt"
26
+ require_relative "prompt_interface/git_prompt"
16
27
  require_relative "prompt_interface/overlay_renderer"
28
+ require_relative "prompt_interface/editor/renderer"
29
+ require_relative "prompt_interface/editor/syntax_highlighter"
30
+ require_relative "prompt_interface/editor/auto_close_pairs"
31
+ require_relative "prompt_interface/editor/endwise"
32
+ require_relative "prompt_interface/editor/auto_indent"
17
33
  require_relative "prompt_interface/composer_renderer"
18
34
  require_relative "prompt_interface/composer_controller"
35
+ require_relative "prompt_interface/editor/modes/modern"
36
+ require_relative "prompt_interface/editor/modes/emacs"
37
+ require_relative "prompt_interface/editor/modes/vibe"
38
+ require_relative "prompt_interface/editor/controller"
39
+ require_relative "prompt_interface/interactive/controller"
40
+ require_relative "prompt_interface/interactive/renderer"
41
+ require_relative "prompt_interface/interactive/state"
19
42
  require_relative "prompt_interface/layout"
20
43
  require_relative "prompt_interface/screen"
21
44
  require_relative "prompt_interface/key_handler"
@@ -39,24 +62,38 @@ module Kward
39
62
  SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
40
63
  SPINNER_INTERVAL = 0.1
41
64
  FOOTER_REFRESH_INTERVAL = 1.0
65
+ COMPOSER_STATUS_REFRESH_INTERVAL = 1.0
42
66
  COMPOSER_MAX_INPUT_ROWS = 6
43
67
  TRANSCRIPT_BUFFER_LIMIT = 200_000
44
- BANNER_LOGO_PIXELS = Banner::LOGO_PIXELS
45
68
  BANNER_MESSAGE = Banner::MESSAGE
46
69
 
47
70
  include SlashOverlay
71
+ include FileOverlay
72
+ include ProjectBrowser
48
73
  include SelectionPrompt
49
74
  include QuestionPrompt
75
+ include GitPrompt
50
76
  include OverlayRenderer
77
+ include EditorRenderer
78
+ include EditorSyntaxHighlighter
79
+ include EditorAutoClosePairs
80
+ include EditorEndwise
81
+ include EditorAutoIndent
51
82
  include ComposerRenderer
52
83
  include ComposerController
84
+ include ModernEditorMode
85
+ include EmacsEditorMode
86
+ include VibeEditorMode
87
+ include EditorController
88
+ include InteractiveRenderer
89
+ include InteractiveState
53
90
  include Layout
54
91
  include Screen
55
92
  include KeyHandler
56
93
  include RuntimeState
57
94
  include TranscriptRenderer
58
95
  include PromptRenderer
59
- KEYBOARD_PROTOCOL_ENABLE = "\e[>1u".freeze
96
+ KEYBOARD_PROTOCOL_ENABLE = "\e[>25u".freeze
60
97
  KEYBOARD_PROTOCOL_RESTORE = "\e[<u".freeze
61
98
  BRACKETED_PASTE_ENABLE = "\e[?2004h".freeze
62
99
  BRACKETED_PASTE_RESTORE = "\e[?2004l".freeze
@@ -66,10 +103,14 @@ module Kward
66
103
  SYNCHRONIZED_OUTPUT_DISABLE = "\e[?2026l".freeze
67
104
  CURSOR_SHOW = "\e[?25h".freeze
68
105
  CURSOR_HIDE = "\e[?25l".freeze
106
+ CURSOR_SHAPE_DEFAULT = "\e[0 q".freeze
107
+ CURSOR_SHAPE_BAR = "\e[6 q".freeze
69
108
  SHIFT_ENTER_SEQUENCES = ["\e[13;2u", "\e[13;2~", "\e[27;2;13~", "\e\r", "\e\n"].freeze
70
109
  EXIT_INPUT = :exit_input
71
110
  CANCEL_INPUT = :cancel_input
72
111
  SELECT_CANCEL = :select_cancel
112
+ SELECT_CONTINUE = :select_continue
113
+ SELECT_ACTION_MINIMUM_BUSY_SECONDS = 1.0
73
114
 
74
115
  # Submitted input string carrying optional display text for transcripts.
75
116
  class SubmittedInput < String
@@ -81,12 +122,14 @@ module Kward
81
122
  end
82
123
  end
83
124
 
84
- 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_pixels: nil, banner_message: nil)
125
+ 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)
85
126
  @input_io = input
86
127
  @output_io = output
87
128
  @reader = TTY::Reader.new(input: input, output: output, interrupt: :error)
88
129
  @mutex = Mutex.new
130
+ @prompt_history = prompt_history
89
131
  @composer = ComposerState.new
132
+ load_history(@prompt_history.values) if @prompt_history
90
133
  self.composer_input = @composer.input
91
134
  self.composer_cursor = @composer.cursor
92
135
  @started = false
@@ -98,6 +141,8 @@ module Kward
98
141
  @spinner_frame_index = 0
99
142
  @last_spinner_tick = monotonic_now
100
143
  @last_footer_refresh = monotonic_now
144
+ @last_composer_status_refresh = 0.0
145
+ @cached_composer_status_text = nil
101
146
  @prompt_label = "You>"
102
147
  @assistant_label = "Assistant"
103
148
  @stream_state = StreamState.new
@@ -105,22 +150,35 @@ module Kward
105
150
  @last_composer_rows = []
106
151
  @cursor_rendered_row = 0
107
152
  @transcript_buffer = TranscriptBuffer.new(limit: TRANSCRIPT_BUFFER_LIMIT)
108
- @visual_banner_count = 0
109
153
  @transcript_viewport_rows = 0
110
154
  @restoring_transcript = false
111
155
  @pending_keys = []
156
+ @completion_provider = nil
112
157
  @original_console_mode = nil
113
158
  @raw_mode_active = false
114
159
  @slash_commands = normalize_slash_commands(slash_commands)
115
160
  @slash_selection_index = 0
116
161
  @slash_overlay_dismissed_input = nil
162
+ @file_selection_index = 0
163
+ @file_overlay_dismissed_token = nil
164
+ @file_open_dismissed_token = nil
165
+ @file_editor_open_status = nil
166
+ @file_mention_paths = nil
167
+ @project_browser_state = nil
168
+ @project_browser_restore_after_editor = false
169
+ @editor_state = nil
170
+ @interactive_state = nil
171
+ @last_interactive_tick = monotonic_now
117
172
  @select_state = nil
118
173
  @question_state = nil
174
+ @question_prompt_active = false
175
+ @git_state = nil
119
176
  @last_width = screen_width
120
177
  @last_height = screen_height
121
178
  @reserved_rows = 0
122
179
  @color_enabled = ANSI.enabled?(output)
123
180
  @cursor_visible = true
181
+ @editor_bar_cursor_active = false
124
182
  @synchronized_output_depth = 0
125
183
  @overlay_settings = normalize_overlay_settings(overlay_settings)
126
184
  @footer = footer
@@ -128,7 +186,22 @@ module Kward
128
186
  @busy_help = busy_help
129
187
  @attachment_badges = attachment_badges
130
188
  @attachment_parser = attachment_parser
131
- @banner = Banner.new(message: banner_message, pixels: banner_pixels, screen_height: method(:screen_height))
189
+ @banner = Banner.new(message: banner_message, screen_height: method(:screen_height))
190
+ @tabs = []
191
+ @active_tab_index = 0
192
+ @tab_keybindings = normalize_tab_keybindings(tab_keybindings)
193
+ @editor_mode = normalize_editor_mode(editor_mode)
194
+ @editor_mode_source = editor_mode_source
195
+ @editor_auto_indent = editor_auto_indent != false
196
+ @editor_auto_indent_source = editor_auto_indent_source
197
+ @editor_auto_close_pairs = editor_auto_close_pairs != false
198
+ @editor_auto_close_pairs_source = editor_auto_close_pairs_source
199
+ @editor_soft_wrap = editor_soft_wrap != false
200
+ @editor_soft_wrap_source = editor_soft_wrap_source
201
+ @editor_bar_cursor = editor_bar_cursor != false
202
+ @editor_bar_cursor_source = editor_bar_cursor_source
203
+ @editor_line_numbers = normalize_editor_line_numbers(editor_line_numbers)
204
+ @editor_line_numbers_source = editor_line_numbers_source
132
205
  end
133
206
 
134
207
  def start(render: true)
@@ -138,6 +211,7 @@ module Kward
138
211
  enter_raw_mode_locked
139
212
  @started = true
140
213
  @asking = true
214
+ disable_editor_mouse_reporting(force: true) unless editor_active?
141
215
  @output_io.print(KEYBOARD_PROTOCOL_ENABLE)
142
216
  @output_io.print(BRACKETED_PASTE_ENABLE)
143
217
  render_prompt_locked if render
@@ -150,8 +224,10 @@ module Kward
150
224
 
151
225
  clear_prompt_for_output_locked
152
226
  restore_scroll_region_locked
227
+ disable_editor_mouse_reporting(force: true)
153
228
  @output_io.print(BRACKETED_PASTE_RESTORE)
154
229
  @output_io.print(KEYBOARD_PROTOCOL_RESTORE)
230
+ restore_editor_cursor_shape_locked
155
231
  set_cursor_visible_locked(true, force: true)
156
232
  @output_io.puts
157
233
  @output_io.flush
@@ -203,7 +279,6 @@ module Kward
203
279
  @output_io.print(SYNCHRONIZED_OUTPUT_ENABLE)
204
280
  clear_prompt_for_output_locked unless @rendered_rows.zero? && @last_composer_rows.empty?
205
281
  @transcript_buffer.clear
206
- @visual_banner_count = 0
207
282
  @transcript_viewport_rows = 0
208
283
  @stream_state.finish_block
209
284
  @stream_state.reset
@@ -221,6 +296,54 @@ module Kward
221
296
  end
222
297
  end
223
298
 
299
+ def with_completion_provider(provider)
300
+ previous = @completion_provider
301
+ @completion_provider = provider
302
+ yield
303
+ ensure
304
+ @completion_provider = previous
305
+ end
306
+
307
+ def editing_file?
308
+ @mutex.synchronize { editor_active? }
309
+ end
310
+
311
+ def edit_file(path, base_dir: Dir.pwd, allow_new: true)
312
+ start(render: false)
313
+ opened = @mutex.synchronize do
314
+ open_editor(path, allow_new: allow_new, base_dir: base_dir, restrict_to_workspace: false).tap do
315
+ render_prompt_locked
316
+ end
317
+ end
318
+ return false unless opened
319
+
320
+ run_editor
321
+ end
322
+
323
+ def run_editor
324
+ loop do
325
+ key = read_key(nonblock: true)
326
+ action = nil
327
+ editor_open = @mutex.synchronize do
328
+ if key.nil?
329
+ resized = handle_resize_locked
330
+ footer_refreshed = tick_footer_locked
331
+ render_prompt_locked if resized || footer_refreshed
332
+ else
333
+ result = handle_key(key)
334
+ action = result if prompt_action_result?(result)
335
+ render_prompt_locked unless result.is_a?(String) || result == EXIT_INPUT || prompt_action_result?(result)
336
+ end
337
+ editor_active?
338
+ end
339
+ return action if action
340
+ break unless editor_open
341
+
342
+ sleep 0.02 if key.nil?
343
+ end
344
+ true
345
+ end
346
+
224
347
  def ask(message = "You>")
225
348
  was_composing = @started && @asking
226
349
  start
@@ -251,10 +374,10 @@ module Kward
251
374
  render_prompt_locked if resized || footer_refreshed
252
375
  else
253
376
  result = handle_key(key)
254
- render_prompt_locked unless result.is_a?(String) || result == EXIT_INPUT
377
+ render_prompt_locked unless result.is_a?(String) || result == EXIT_INPUT || prompt_action_result?(result)
255
378
  end
256
379
  end
257
- return result if result.is_a?(String)
380
+ return result if result.is_a?(String) || prompt_action_result?(result)
258
381
  return nil if result == EXIT_INPUT
259
382
 
260
383
  sleep 0.02 if key.nil?
@@ -275,23 +398,15 @@ module Kward
275
398
  [overlay_card_width(screen_width) - 6, 1].max
276
399
  end
277
400
 
278
- def select(message, choices, title: "Sessions", custom: false, initial_index: 0)
401
+ def select(message, choices, title: "Sessions", custom: false, initial_index: 0, action_keys: {}, action_handlers: {})
279
402
  return nil if choices.empty? && !custom
280
403
 
281
404
  start
282
405
  @mutex.synchronize do
283
- @prompt_label = message.to_s
284
- self.composer_input = ""
285
- self.composer_cursor = 0
286
- @composer.clear_attachments
287
- @pending_keys.clear
288
- @asking = true
289
- @busy = false
290
- @queued_count = 0
406
+ prepare_modal_input_locked(message, clear_attachments: true)
291
407
  choice_labels = choices.map(&:to_s)
292
408
  selection_index = choice_labels.empty? ? 0 : [[initial_index.to_i, 0].max, choice_labels.length - 1].min
293
- @select_state = { choices: choice_labels, selection_index: selection_index, title: title.to_s, custom: custom }
294
- reset_history_navigation
409
+ @select_state = { choices: choice_labels, selection_index: selection_index, title: title.to_s, custom: custom, action_keys: normalized_select_action_keys(action_keys), search_active: false }
295
410
  render_prompt_locked
296
411
  end
297
412
 
@@ -305,12 +420,20 @@ module Kward
305
420
  render_prompt_locked if resized || footer_refreshed
306
421
  else
307
422
  result = handle_select_key(key)
308
- render_prompt_locked unless result.is_a?(String) || result == SELECT_CANCEL
423
+ result = drain_pending_select_keys_locked(result)
424
+ render_prompt_locked unless result.is_a?(String) || select_action_result?(result) || result == SELECT_CANCEL
309
425
  end
310
426
  end
311
427
 
312
- if result.is_a?(String) || result == SELECT_CANCEL
313
- finish_select_prompt
428
+ if select_action_result?(result) && select_action_handler(result, action_handlers)
429
+ action_result = run_select_action(result, select_action_handler(result, action_handlers))
430
+ next if action_result == SELECT_CONTINUE
431
+
432
+ return action_result
433
+ end
434
+
435
+ if result.is_a?(String) || select_action_result?(result) || result == SELECT_CANCEL
436
+ finish_select_prompt(render: !select_deferred_finish_render?(result))
314
437
  return result == SELECT_CANCEL ? nil : result
315
438
  end
316
439
 
@@ -324,7 +447,10 @@ module Kward
324
447
  start
325
448
  saved_state = nil
326
449
  answers = []
327
- @mutex.synchronize { saved_state = begin_question_prompt_state }
450
+ @mutex.synchronize do
451
+ @question_prompt_active = true
452
+ saved_state = begin_question_prompt_state
453
+ end
328
454
 
329
455
  questions.each_with_index do |question, index|
330
456
  answer = ask_single_user_question(question, index + 1, questions.length)
@@ -343,7 +469,127 @@ module Kward
343
469
  end
344
470
 
345
471
  def modal_active?
346
- @mutex.synchronize { !@question_state.nil? || !@select_state.nil? }
472
+ @mutex.synchronize { modal_active_locked? }
473
+ end
474
+
475
+ def interactive_active?
476
+ @mutex.synchronize { interactive_active_locked? }
477
+ end
478
+
479
+ def interactive_exited?
480
+ @mutex.synchronize do
481
+ return false unless @interactive_state
482
+
483
+ @interactive_state[:controller].exited?
484
+ end
485
+ end
486
+
487
+ def finish_interactive
488
+ @mutex.synchronize do
489
+ return unless @interactive_state
490
+
491
+ snapshot = @interactive_state[:snapshot]
492
+ @interactive_state = nil
493
+ restore_composer_snapshot_locked(snapshot)
494
+ redraw_screen_locked if @started
495
+ @output_io.flush
496
+ end
497
+ end
498
+
499
+ def start_interactive(title:, rows:, fps:)
500
+ snapshot = composer_snapshot
501
+ controller = InteractiveController.new(width: interactive_canvas_width, height: rows, fps: fps)
502
+ start
503
+ @mutex.synchronize do
504
+ @interactive_state = {
505
+ title: title.to_s,
506
+ rows: rows,
507
+ controller: controller,
508
+ snapshot: snapshot
509
+ }
510
+ @last_interactive_tick = monotonic_now
511
+ @asking = true
512
+ @busy = false
513
+ @last_composer_rows = []
514
+ render_prompt_locked
515
+ end
516
+ controller
517
+ end
518
+
519
+ def update_tabs(labels:, active_index: 0)
520
+ @mutex.synchronize do
521
+ @tabs = Array(labels).map { |label| normalize_tab_label(label) }
522
+ @active_tab_index = active_index.to_i
523
+ render_prompt_locked if @started && @asking
524
+ end
525
+ end
526
+
527
+ def composer_snapshot
528
+ @mutex.synchronize do
529
+ {
530
+ composer: @composer,
531
+ prompt_label: @prompt_label
532
+ }
533
+ end
534
+ end
535
+
536
+ def tab_view_snapshot
537
+ @mutex.synchronize do
538
+ {
539
+ composer: @composer.dup,
540
+ prompt_label: @prompt_label.dup,
541
+ editor_state: @editor_state&.dup,
542
+ transcript_buffer: @transcript_buffer.dup,
543
+ transcript_viewport_rows: @transcript_viewport_rows,
544
+ stream_state: @stream_state.dup
545
+ }
546
+ end
547
+ end
548
+
549
+ def restore_composer_snapshot(snapshot)
550
+ @mutex.synchronize do
551
+ restore_composer_snapshot_locked(snapshot)
552
+ restore_editor_snapshot_locked(snapshot)
553
+ redraw_screen_locked if @started
554
+ end
555
+ end
556
+
557
+ def restore_tab_view_snapshot(snapshot)
558
+ @mutex.synchronize do
559
+ restore_composer_snapshot_locked(snapshot)
560
+ restore_editor_snapshot_locked(snapshot)
561
+ @transcript_buffer = snapshot[:transcript_buffer] || TranscriptBuffer.new(limit: TRANSCRIPT_BUFFER_LIMIT)
562
+ @transcript_viewport_rows = snapshot[:transcript_viewport_rows].to_i
563
+ @stream_state = snapshot[:stream_state] || StreamState.new
564
+ @last_composer_rows = []
565
+ redraw_screen_locked if @started
566
+ end
567
+ end
568
+
569
+ def restore_composer_snapshot_locked(snapshot)
570
+ @composer = snapshot[:composer] || new_composer_state_with_history
571
+ @prompt_label = snapshot[:prompt_label].to_s.empty? ? "You>" : snapshot[:prompt_label].to_s
572
+ self.composer_input = @composer.input
573
+ self.composer_cursor = @composer.cursor
574
+ @last_composer_rows = []
575
+ end
576
+
577
+ def new_composer_state_with_history
578
+ composer = ComposerState.new
579
+ composer.load_history(@prompt_history.values) if @prompt_history
580
+ composer
581
+ end
582
+
583
+ def restore_editor_snapshot_locked(snapshot)
584
+ editor_was_active = editor_active?
585
+ @editor_state = snapshot[:editor_state]&.dup
586
+ editor_is_active = editor_active?
587
+
588
+ if editor_is_active
589
+ enable_editor_mouse_reporting unless editor_was_active
590
+ else
591
+ disable_editor_mouse_reporting(force: true)
592
+ end
347
593
  end
348
594
 
349
595
  def update_overlay_settings(settings)
@@ -410,6 +656,22 @@ module Kward
410
656
  def poll_input
411
657
  key = read_key(nonblock: true)
412
658
  @mutex.synchronize do
659
+ if interactive_active_locked?
660
+ if key.nil?
661
+ resized = handle_resize_locked
662
+ ticked = tick_interactive_locked
663
+ render_prompt_locked if resized || ticked
664
+ return :interactive_exited if @interactive_state[:controller].exited?
665
+ return nil
666
+ end
667
+
668
+ route_interactive_key(key)
669
+ ticked = tick_interactive_locked
670
+ render_prompt_locked if ticked
671
+ return :interactive_exited if @interactive_state[:controller].exited?
672
+ return nil
673
+ end
674
+
413
675
  if key.nil?
414
676
  resized = handle_resize_locked
415
677
  spun = tick_spinner_locked
@@ -418,8 +680,13 @@ module Kward
418
680
  return nil
419
681
  end
420
682
 
683
+ if modal_active_locked?
684
+ queue_pending_keys(key)
685
+ return nil
686
+ end
687
+
421
688
  result = handle_key(key)
422
- render_prompt_locked unless [EXIT_INPUT, CANCEL_INPUT].include?(result)
689
+ render_prompt_locked unless [EXIT_INPUT, CANCEL_INPUT].include?(result) || prompt_action_result?(result)
423
690
  [EXIT_INPUT, CANCEL_INPUT].include?(result) ? result : result
424
691
  end
425
692
  end
@@ -430,20 +697,16 @@ module Kward
430
697
  end
431
698
  end
432
699
 
433
- def print_visual_banner
700
+ def print_visual_banner(message = nil)
434
701
  @mutex.synchronize do
435
702
  width, height = screen_size
436
- rows = banner_rows(width)
703
+ rows = banner_rows(width, message: message)
437
704
  return if rows.empty?
438
705
 
439
706
  with_synchronized_output_locked do
440
707
  prepare_transcript_output_locked
441
- rows.each do |row|
442
- write_visual_transcript_text_locked(row)
443
- write_visual_transcript_text_locked("\n")
444
- end
445
- @visual_banner_count += 1
446
- invalidate_transcript_display_rows_cache
708
+ write_transcript_text_locked(rows.join("\n"))
709
+ write_transcript_text_locked("\n")
447
710
  remember_transcript_viewport_locked(height)
448
711
  @stream_state.finish_block
449
712
  restore_composer_cursor_locked
@@ -484,10 +747,17 @@ module Kward
484
747
  end
485
748
  end
486
749
 
750
+ def refresh_composer_status
751
+ @mutex.synchronize do
752
+ @cached_composer_status_text = nil
753
+ @last_composer_status_refresh = 0.0
754
+ render_prompt_locked if @started && @asking
755
+ end
756
+ end
757
+
487
758
  def clear_transcript
488
759
  @mutex.synchronize do
489
760
  @transcript_buffer.clear
490
- @visual_banner_count = 0
491
761
  @transcript_viewport_rows = 0
492
762
  @stream_state.finish_block
493
763
  @stream_state.reset
@@ -499,7 +769,9 @@ module Kward
499
769
 
500
770
  private
501
771
 
502
-
772
+ def modal_active_locked?
773
+ @question_prompt_active || !@question_state.nil? || !@select_state.nil? || !@git_state.nil?
774
+ end
503
775
 
504
776
 
505
777
 
@@ -11,7 +11,9 @@ module Kward
11
11
  { name: "sessions", description: "Open the saved sessions picker.", argument_hint: "[path]" },
12
12
  { name: "resume", description: "Alias for /sessions.", argument_hint: "[path]" },
13
13
  { name: "name", description: "Name or clear the current session.", argument_hint: "[name]" },
14
+ { name: "rename", description: "Rename the current session.", argument_hint: "<name>" },
14
15
  { name: "clone", description: "Clone the current session.", argument_hint: "" },
16
+ { name: "fork", description: "Fork from an earlier prompt into a new session.", argument_hint: "" },
15
17
  { name: "rewind", description: "Revisit an earlier prompt and try a different direction.", argument_hint: "" },
16
18
  { name: "tree", description: "Inspect and navigate the full technical session tree.", argument_hint: "" },
17
19
  { name: "copy", description: "Copy clean session text to the clipboard.", argument_hint: "[last|transcript]" },
@@ -21,9 +23,13 @@ module Kward
21
23
  { name: "settings", description: "Configure prompt overlays.", argument_hint: "" },
22
24
  { name: "login", description: "Log in with an OAuth provider.", argument_hint: "" },
23
25
  { name: "model", description: "Select the default model.", argument_hint: "" },
24
- { name: "openrouter/catalog", description: "List the full OpenRouter model catalog.", argument_hint: "" },
25
26
  { name: "reasoning", description: "Select reasoning effort.", argument_hint: "" },
26
27
  { name: "reload", description: "Reload installed plugins.", argument_hint: "" },
28
+ { name: "workers", description: "Open the worker pipeline.", argument_hint: "[new|do <task>]" },
29
+ { name: "git", description: "Review uncommitted changes and commit them.", argument_hint: "" },
30
+ { name: "files", description: "Browse project files.", argument_hint: "" },
31
+ { name: "shell", description: "Open the embedded Kward shell.", argument_hint: "" },
32
+ { name: "tab", description: "Manage tabs.", argument_hint: "[1-n|move|close|new|name]" },
27
33
  { name: "status", description: "Show the current status message.", argument_hint: "" },
28
34
  { name: "stats", description: "Show telemetry logging stats.", argument_hint: "[range]" },
29
35
  { name: "memory", description: "Inspect and manage Kward memory.", argument_hint: "[enable|disable|auto-summary|core|add|list|forget|promote|relax|inspect|why|summarize]" }