kward 0.76.0 → 0.77.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +8 -1
  3. data/CHANGELOG.md +36 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +12 -4
  6. data/doc/agent-tools.md +0 -3
  7. data/doc/api.md +4 -0
  8. data/doc/composer.md +219 -0
  9. data/doc/configuration.md +75 -27
  10. data/doc/editor.md +18 -2
  11. data/doc/files.md +1 -1
  12. data/doc/getting-started.md +4 -0
  13. data/doc/git.md +2 -2
  14. data/doc/lifecycle-hooks.md +2 -14
  15. data/doc/pan.md +167 -0
  16. data/doc/rpc.md +0 -22
  17. data/doc/security.md +180 -0
  18. data/doc/usage.md +15 -32
  19. data/lib/kward/ansi.rb +2 -2
  20. data/lib/kward/cancellation.rb +2 -2
  21. data/lib/kward/cli/commands.rb +1 -3
  22. data/lib/kward/cli/interactive_turn.rb +0 -17
  23. data/lib/kward/cli/plugins.rb +2 -7
  24. data/lib/kward/cli/prompt_interface.rb +1 -0
  25. data/lib/kward/cli/runtime_helpers.rb +0 -38
  26. data/lib/kward/cli/sessions.rb +14 -5
  27. data/lib/kward/cli/settings.rb +22 -0
  28. data/lib/kward/cli/slash_commands.rb +1 -561
  29. data/lib/kward/cli/tabs.rb +14 -2
  30. data/lib/kward/cli.rb +1 -17
  31. data/lib/kward/compactor.rb +3 -3
  32. data/lib/kward/config_files.rb +18 -0
  33. data/lib/kward/ekwsh.rb +0 -4
  34. data/lib/kward/hooks/catalog.rb +0 -5
  35. data/lib/kward/image_attachments.rb +1 -1
  36. data/lib/kward/pan/index.html.erb +459 -64
  37. data/lib/kward/pan/kward_logo.png +0 -0
  38. data/lib/kward/pan/server.rb +248 -23
  39. data/lib/kward/plugin_registry.rb +18 -0
  40. data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
  41. data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
  42. data/lib/kward/prompt_interface/layout.rb +0 -4
  43. data/lib/kward/prompt_interface/project_browser.rb +40 -1
  44. data/lib/kward/prompt_interface/runtime_state.rb +4 -0
  45. data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
  46. data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
  47. data/lib/kward/prompt_interface.rb +18 -1
  48. data/lib/kward/prompts/commands.rb +1 -3
  49. data/lib/kward/prompts.rb +20 -0
  50. data/lib/kward/rpc/server.rb +13 -41
  51. data/lib/kward/rpc/session_manager.rb +75 -25
  52. data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
  53. data/lib/kward/rpc/tool_metadata.rb +23 -0
  54. data/lib/kward/rpc/transcript_normalizer.rb +4 -0
  55. data/lib/kward/session_store.rb +55 -16
  56. data/lib/kward/skills/registry.rb +21 -1
  57. data/lib/kward/tools/base.rb +14 -0
  58. data/lib/kward/tools/registry.rb +11 -16
  59. data/lib/kward/tools/search/code.rb +1 -1
  60. data/lib/kward/tools/search/web.rb +1 -1
  61. data/lib/kward/tools/tool_call.rb +0 -4
  62. data/lib/kward/transcript_export.rb +1 -1
  63. data/lib/kward/version.rb +1 -1
  64. data/templates/default/kward_navigation.rb +5 -1
  65. data/templates/default/layout/html/layout.erb +0 -2
  66. metadata +5 -12
  67. data/lib/kward/workers/git_guard.rb +0 -93
  68. data/lib/kward/workers/job.rb +0 -99
  69. data/lib/kward/workers/live_view.rb +0 -49
  70. data/lib/kward/workers/manager.rb +0 -328
  71. data/lib/kward/workers/queue_runner.rb +0 -166
  72. data/lib/kward/workers/queue_store.rb +0 -112
  73. data/lib/kward/workers/store.rb +0 -72
  74. data/lib/kward/workers/tool_policy.rb +0 -23
  75. data/lib/kward/workers/worker.rb +0 -82
  76. data/lib/kward/workers/write_lock.rb +0 -38
  77. data/lib/kward/workers.rb +0 -10
@@ -13,22 +13,28 @@ require_relative "../plugin_registry"
13
13
  require_relative "../prompts/commands"
14
14
  require_relative "../rpc/transcript_normalizer"
15
15
  require_relative "../session_store"
16
+ require_relative "../session_trash"
16
17
  require_relative "../tools/tool_call"
17
18
  require_relative "../tools/registry"
19
+ require_relative "../version"
18
20
  require_relative "../workspace"
19
21
 
20
22
  # Namespace for the Kward CLI agent runtime.
21
23
  module Kward
22
- # Minimal local HTTP server for the experimental Pan web UI.
24
+ # Local HTTP server for the mobile-friendly Pan web UI.
23
25
  class PanServer
24
26
  DEFAULT_HOST = "0.0.0.0"
25
27
  DEFAULT_PORT = 8765
26
28
  MAX_REQUEST_BODY_BYTES = 64 * 1024
29
+ ROUTING_PROBE_ADDRESS = "8.8.8.8"
30
+ ROUTING_PROBE_PORT = 80
27
31
 
28
- def initialize(client:, working_directory:, config: ConfigFiles.read_config, config_dir: ConfigFiles.config_dir, output: $stderr)
32
+ def initialize(client:, working_directory:, config: ConfigFiles.read_config, config_dir: ConfigFiles.config_dir, output: $stderr, udp_socket_class: UDPSocket)
29
33
  @client = client
30
34
  @output = output
35
+ @udp_socket_class = udp_socket_class
31
36
  @workspace = Workspace.new(root: working_directory)
37
+ @full_config = config
32
38
  @config = pan_config(config)
33
39
  @host = @config.fetch("host", DEFAULT_HOST).to_s
34
40
  @port = positive_port(@config.fetch("port", DEFAULT_PORT))
@@ -37,28 +43,22 @@ module Kward
37
43
  raise "Pan mode requires pan_mode.username and pan_mode.password in #{ConfigFiles.config_path}" if @username.empty? || @password.empty?
38
44
 
39
45
  @session_store = SessionStore.new(config_dir: config_dir, cwd: @workspace.root.to_s)
40
- @session = @session_store.create
41
- @conversation = Conversation.new(
42
- workspace_root: @workspace.root.to_s,
46
+ @session = @session_store.create(
47
+ provider: (@client.current_provider if @client.respond_to?(:current_provider)),
43
48
  model: (@client.current_model if @client.respond_to?(:current_model)),
44
49
  reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
45
50
  )
51
+ @conversation = new_conversation
46
52
  @session.attach(@conversation)
47
- hook_context = lifecycle_hook_context
48
- hook_manager = lifecycle_hook_manager
49
- @agent = Agent.new(
50
- client: @client,
51
- tool_registry: ToolRegistry.new(workspace: @workspace, ask_user_question_enabled: false, hook_manager: hook_manager, hook_context: hook_context),
52
- conversation: @conversation,
53
- hook_manager: hook_manager,
54
- hook_context: hook_context
55
- )
53
+ @agent = build_agent
56
54
  @prompt_queue = Queue.new
57
55
  @subscribers = []
58
56
  @subscribers_mutex = Mutex.new
59
57
  @worker_started = false
60
58
  @active = false
59
+ @pending_turns = 0
61
60
  @state_mutex = Mutex.new
61
+ @session_mutex = Mutex.new
62
62
  end
63
63
 
64
64
  attr_reader :host, :port, :session, :workspace
@@ -91,8 +91,11 @@ module Kward
91
91
  text = prompt.to_s
92
92
  return { ok: false, error: "Prompt is required" } if text.strip.empty?
93
93
 
94
- queued_at = Time.now.utc.iso8601(3)
95
- @prompt_queue << { prompt: text, queued_at: queued_at }
94
+ @session_mutex.synchronize do
95
+ queued_at = Time.now.utc.iso8601(3)
96
+ @state_mutex.synchronize { @pending_turns += 1 }
97
+ @prompt_queue << { prompt: text, queued_at: queued_at }
98
+ end
96
99
  broadcast("queue", queue_payload)
97
100
  { ok: true, queued: @prompt_queue.size, active: active? }
98
101
  end
@@ -103,6 +106,27 @@ module Kward
103
106
 
104
107
  private
105
108
 
109
+ def new_conversation
110
+ Conversation.new(
111
+ workspace_root: @workspace.root.to_s,
112
+ provider: (@client.current_provider if @client.respond_to?(:current_provider)),
113
+ model: (@client.current_model if @client.respond_to?(:current_model)),
114
+ reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
115
+ )
116
+ end
117
+
118
+ def build_agent
119
+ hook_context = lifecycle_hook_context
120
+ hook_manager = lifecycle_hook_manager
121
+ Agent.new(
122
+ client: @client,
123
+ tool_registry: ToolRegistry.new(workspace: @workspace, ask_user_question_enabled: false, hook_manager: hook_manager, hook_context: hook_context),
124
+ conversation: @conversation,
125
+ hook_manager: hook_manager,
126
+ hook_context: hook_context
127
+ )
128
+ end
129
+
106
130
  def lifecycle_hook_manager
107
131
  manager = Hooks::ConfigLoader.new(ConfigFiles.lifecycle_hooks_config(@workspace.root)).manager
108
132
  manager.on_result = method(:broadcast_lifecycle_hook_event)
@@ -185,7 +209,10 @@ module Kward
185
209
  broadcast("error", { message: e.message })
186
210
  broadcast("turn_finished", queue_payload.merge(status: "failed"))
187
211
  ensure
188
- set_active(false)
212
+ @state_mutex.synchronize do
213
+ @active = false
214
+ @pending_turns -= 1 if @pending_turns.positive?
215
+ end
189
216
  broadcast("queue", queue_payload)
190
217
  end
191
218
 
@@ -201,12 +228,18 @@ module Kward
201
228
  case [request[:method], request[:path]]
202
229
  when ["GET", "/"]
203
230
  write_response(socket, 200, { "Content-Type" => "text/html; charset=utf-8" }, render_index)
231
+ when ["GET", "/kward-logo.png"]
232
+ write_response(socket, 200, { "Content-Type" => "image/png", "Cache-Control" => "public, max-age=86400" }, File.binread(File.join(__dir__, "kward_logo.png")))
204
233
  when ["GET", "/transcript"]
205
- write_json(socket, 200, transcript: transcript_items, session: { id: @session.id, path: @session.path }, workspace: @workspace.root.to_s)
234
+ write_json(socket, 200, transcript: transcript_items, session: active_session_payload, workspace: @workspace.root.to_s)
235
+ when ["GET", "/sessions"]
236
+ write_json(socket, 200, sessions: session_payloads, activeSessionId: @session.id)
206
237
  when ["GET", "/events"]
207
238
  stream_events(socket)
208
239
  when ["POST", "/turn"]
209
- handle_turn(socket, request[:body])
240
+ handle_turn(socket, request[:body], request[:headers])
241
+ when ["POST", "/sessions"]
242
+ handle_session_action(socket, request[:body], request[:headers])
210
243
  else
211
244
  write_response(socket, 404, { "Content-Type" => "text/plain; charset=utf-8" }, "Not found\n")
212
245
  end
@@ -254,7 +287,9 @@ module Kward
254
287
  left.bytes.zip(right.bytes).reduce(0) { |memo, pair| memo | (pair[0] ^ pair[1]) }.zero?
255
288
  end
256
289
 
257
- def handle_turn(socket, body)
290
+ def handle_turn(socket, body, headers)
291
+ return write_json(socket, 415, ok: false, error: "Content-Type must be application/json") unless json_request?(headers)
292
+
258
293
  params = JSON.parse(body.empty? ? "{}" : body)
259
294
  result = enqueue_prompt(params["prompt"])
260
295
  status = result[:ok] ? 202 : 422
@@ -263,6 +298,155 @@ module Kward
263
298
  write_json(socket, 400, ok: false, error: "Invalid JSON")
264
299
  end
265
300
 
301
+ def handle_session_action(socket, body, headers)
302
+ return write_json(socket, 415, ok: false, error: "Content-Type must be application/json") unless json_request?(headers)
303
+
304
+ params = JSON.parse(body.empty? ? "{}" : body)
305
+ result = update_session(params)
306
+ write_json(socket, result.delete(:status), result)
307
+ rescue JSON::ParserError
308
+ write_json(socket, 400, ok: false, error: "Invalid JSON")
309
+ end
310
+
311
+ def update_session(params)
312
+ return { status: 409, ok: false, error: "Wait for queued turns to finish before changing sessions" } if turns_pending?
313
+
314
+ result = @session_mutex.synchronize do
315
+ return { status: 409, ok: false, error: "Wait for queued turns to finish before changing sessions" } if turns_pending?
316
+
317
+ case params["action"]
318
+ when "new" then create_session
319
+ when "resume" then resume_session(params["id"])
320
+ when "rename" then rename_session(params["id"], params["name"])
321
+ when "delete" then delete_session(params["id"])
322
+ else { status: 422, ok: false, error: "Unknown session action" }
323
+ end
324
+ end
325
+ broadcast("session_changed", result[:session]) if result[:ok] && result[:session]
326
+ result
327
+ rescue StandardError => e
328
+ { status: 422, ok: false, error: e.message }
329
+ end
330
+
331
+ def create_session
332
+ previous = @session
333
+ session = @session_store.create(
334
+ provider: (@client.current_provider if @client.respond_to?(:current_provider)),
335
+ model: (@client.current_model if @client.respond_to?(:current_model)),
336
+ reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
337
+ )
338
+ conversation = new_conversation
339
+ session.attach(conversation)
340
+ activate_session(session, conversation)
341
+ previous.delete_if_unused
342
+ { status: 200, ok: true, session: active_session_payload }
343
+ end
344
+
345
+ def resume_session(id)
346
+ info = find_session(id)
347
+ return { status: 404, ok: false, error: "Session not found" } unless info
348
+ return { status: 200, ok: true, session: active_session_payload } if info.id == @session.id
349
+
350
+ previous = @session
351
+ session, conversation = @session_store.load(
352
+ info.path,
353
+ workspace: @workspace,
354
+ provider: (@client.current_provider if @client.respond_to?(:current_provider)),
355
+ model: (@client.current_model if @client.respond_to?(:current_model)),
356
+ reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
357
+ )
358
+ activate_session(session, conversation)
359
+ previous.delete_if_unused
360
+ { status: 200, ok: true, session: active_session_payload }
361
+ end
362
+
363
+ def rename_session(id, name)
364
+ clean_name = name.to_s.strip
365
+ return { status: 422, ok: false, error: "Session name is too long" } if clean_name.length > 120
366
+
367
+ target = id.to_s == @session.id ? @session : find_session(id)
368
+ return { status: 404, ok: false, error: "Session not found" } unless target
369
+
370
+ session = target.respond_to?(:rename) ? target : @session_store.load(target.path, workspace: @workspace).first
371
+ session.rename(clean_name)
372
+ @session.name = session.name if session.id == @session.id
373
+ { status: 200, ok: true, session: session_payload(session) }
374
+ end
375
+
376
+ def delete_session(id)
377
+ if id.to_s == @session.id
378
+ deleted_id = @session.id
379
+ deleted_path = @session.path
380
+ create_session
381
+ SessionTrash.new.delete(deleted_path)
382
+ return { status: 200, ok: true, deletedSessionId: deleted_id, session: active_session_payload }
383
+ end
384
+
385
+ info = find_session(id)
386
+ return { status: 404, ok: false, error: "Session not found" } unless info
387
+
388
+ SessionTrash.new.delete(info.path)
389
+ { status: 200, ok: true, deletedSessionId: info.id }
390
+ end
391
+
392
+ def activate_session(session, conversation)
393
+ @session = session
394
+ @conversation = conversation
395
+ @agent = build_agent
396
+ @session_store.remember_last_session(session)
397
+ end
398
+
399
+ def find_session(id)
400
+ session_infos.find { |info| info.id.to_s == id.to_s }
401
+ end
402
+
403
+ def session_infos
404
+ @session_store.recent(limit: 50, keep_empty_path: @session.path)
405
+ end
406
+
407
+ def session_payloads
408
+ sessions = session_infos.map { |info| session_payload(info) }
409
+ sessions.unshift(active_session_payload) unless sessions.any? { |item| item[:id] == @session.id }
410
+ sessions.sort_by { |item| item[:modifiedAt].to_s }.reverse
411
+ end
412
+
413
+ def active_session_payload
414
+ info = session_infos.find { |item| item.id == @session.id }
415
+ return session_payload(info).merge(active: true, path: @session.path) if info
416
+
417
+ session_payload(@session).merge(
418
+ active: true,
419
+ path: @session.path,
420
+ modifiedAt: File.mtime(@session.path).utc.iso8601(3),
421
+ preview: transcript_items.find { |item| item[:role] == "user" }&.fetch(:text, "") || "",
422
+ messageCount: transcript_items.length
423
+ )
424
+ end
425
+
426
+ def session_payload(session)
427
+ name = session.name.to_s.strip
428
+ preview = session.respond_to?(:first_message) ? session.first_message.to_s : ""
429
+ {
430
+ id: session.id,
431
+ name: name.empty? ? nil : name,
432
+ title: name.empty? ? (preview.empty? ? "New session" : preview) : name,
433
+ preview: preview,
434
+ createdAt: session.created_at&.utc&.iso8601(3),
435
+ modifiedAt: (session.respond_to?(:modified_at) ? session.modified_at&.utc&.iso8601(3) : nil),
436
+ messageCount: (session.respond_to?(:message_count) ? session.message_count.to_i : 0),
437
+ model: (session.respond_to?(:model) ? session.model : @conversation.model),
438
+ active: session.id == @session.id
439
+ }
440
+ end
441
+
442
+ def turns_pending?
443
+ @state_mutex.synchronize { @pending_turns.positive? }
444
+ end
445
+
446
+ def json_request?(headers)
447
+ headers["content-type"].to_s.downcase.start_with?("application/json")
448
+ end
449
+
266
450
  def stream_events(socket)
267
451
  subscriber = Queue.new
268
452
  subscribe(subscriber)
@@ -316,11 +500,38 @@ module Kward
316
500
 
317
501
  def render_index
318
502
  @workspace_root = @workspace.root.to_s
503
+ @workspace_label = pan_workspace_label
504
+ @assistant_label = assistant_label
319
505
  @session_path = @session.path
506
+ @version = Kward::VERSION
320
507
  template = File.read(File.join(__dir__, "index.html.erb"))
321
508
  ERB.new(template).result(binding)
322
509
  end
323
510
 
511
+ def assistant_label
512
+ ConfigFiles.active_persona_label(
513
+ workspace_root: @conversation.workspace_root,
514
+ model: @conversation.model,
515
+ config: @full_config
516
+ ) || "Assistant"
517
+ rescue StandardError
518
+ "Assistant"
519
+ end
520
+
521
+ def pan_workspace_label
522
+ root = File.expand_path(@workspace.root.to_s)
523
+ home = Dir.home
524
+ if root == home || root.start_with?("#{home}/")
525
+ relative = root.delete_prefix(home).delete_prefix("/")
526
+ return "~" if relative.empty?
527
+ return "~/#{relative}" unless relative.include?("/")
528
+ end
529
+
530
+ [File.basename(File.dirname(root)), File.basename(root)].reject(&:empty?).join("/")
531
+ rescue StandardError
532
+ @workspace.root.to_s
533
+ end
534
+
324
535
  def pan_config(config)
325
536
  values = config["pan_mode"]
326
537
  values.is_a?(Hash) ? values : {}
@@ -332,7 +543,19 @@ module Kward
332
543
  end
333
544
 
334
545
  def display_host
335
- @host == "0.0.0.0" ? "<lan-address>" : @host
546
+ return @host unless @host == "0.0.0.0"
547
+
548
+ lan_address || "<lan-address>"
549
+ end
550
+
551
+ def lan_address
552
+ socket = @udp_socket_class.new
553
+ socket.connect(ROUTING_PROBE_ADDRESS, ROUTING_PROBE_PORT)
554
+ socket.addr.last
555
+ rescue SocketError, SystemCallError
556
+ nil
557
+ ensure
558
+ socket&.close
336
559
  end
337
560
 
338
561
  def set_active(value)
@@ -385,9 +608,9 @@ module Kward
385
608
  text.to_s.empty? ? nil : { role: "reasoning", label: "Reasoning", text: text }
386
609
  when "text"
387
610
  text = part[:text] || part["text"]
388
- text.to_s.empty? ? nil : { role: "assistant", label: "Assistant", text: text }
611
+ text.to_s.empty? ? nil : { role: "assistant", label: assistant_label, text: text }
389
612
  when "image"
390
- { role: "assistant", label: "Assistant", text: image_part_text(part) }
613
+ { role: "assistant", label: assistant_label, text: image_part_text(part) }
391
614
  when "toolCall"
392
615
  { role: "tool", label: "Tool", text: tool_call_part_text(part) }
393
616
  end
@@ -442,6 +665,8 @@ module Kward
442
665
  when 400 then "Bad Request"
443
666
  when 401 then "Unauthorized"
444
667
  when 404 then "Not Found"
668
+ when 409 then "Conflict"
669
+ when 415 then "Unsupported Media Type"
445
670
  when 422 then "Unprocessable Content"
446
671
  else "Internal Server Error"
447
672
  end
@@ -87,14 +87,17 @@ module Kward
87
87
  nil
88
88
  end
89
89
 
90
+ # @return [String, nil] active session identifier
90
91
  def session_id
91
92
  @session&.id
92
93
  end
93
94
 
95
+ # @return [String, nil] human-readable active session name
94
96
  def session_name
95
97
  @session&.name
96
98
  end
97
99
 
100
+ # @return [String, nil] saved active session path
98
101
  def session_path
99
102
  @session&.path
100
103
  end
@@ -108,30 +111,45 @@ module Kward
108
111
  nil
109
112
  end
110
113
 
114
+ # Allows the current lifecycle event to continue.
115
+ # @return [Hooks::Decision]
111
116
  def allow(message = nil, metadata: nil)
112
117
  Hooks::Decision.allow(message, metadata: metadata)
113
118
  end
114
119
 
120
+ # Denies the current lifecycle event.
121
+ # @return [Hooks::Decision]
115
122
  def deny(message = nil, metadata: nil)
116
123
  Hooks::Decision.deny(message, metadata: metadata)
117
124
  end
118
125
 
126
+ # Requests frontend approval for the current lifecycle event.
127
+ # @return [Hooks::Decision]
119
128
  def ask(message = nil, metadata: nil)
120
129
  Hooks::Decision.ask(message, metadata: metadata)
121
130
  end
122
131
 
132
+ # Continues with an event-specific payload replacement.
133
+ # @param payload [Hash] replacement fields supported by the event
134
+ # @return [Hooks::Decision]
123
135
  def modify(payload, message: nil, metadata: nil)
124
136
  Hooks::Decision.modify(payload, message: message, metadata: metadata)
125
137
  end
126
138
 
139
+ # Allows the event while recording a warning.
140
+ # @return [Hooks::Decision]
127
141
  def warn(message = nil, metadata: nil)
128
142
  Hooks::Decision.warn(message, metadata: metadata)
129
143
  end
130
144
 
145
+ # Requests a retry when the current event supports it.
146
+ # @return [Hooks::Decision]
131
147
  def retry(message = nil, payload: nil, metadata: nil)
132
148
  Hooks::Decision.retry(message, payload: payload, metadata: metadata)
133
149
  end
134
150
 
151
+ # Defers the event when the current workflow supports it.
152
+ # @return [Hooks::Decision]
135
153
  def defer(message = nil, payload: nil, metadata: nil)
136
154
  Hooks::Decision.defer(message, payload: payload, metadata: metadata)
137
155
  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
- indent = editor_matching_punctuation_indent(text)
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
- line = @editor_state.lines[line_index].to_s
398
- old_indent = line[/\A[ \t]*/].to_s
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, = @editor_state.cursor_line_and_column
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*\z/)
456
- return true if command.match?(/\A\d*[cdy]\d*[ai]\z/)
457
- return true if command.match?(/\A\d*[cdy]\d*[fFtT]\z/)
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)