kward 0.71.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -0
  5. data/doc/agent-tools.md +15 -6
  6. data/doc/authentication.md +22 -1
  7. data/doc/code-search.md +42 -2
  8. data/doc/configuration.md +106 -3
  9. data/doc/context-budgeting.md +136 -0
  10. data/doc/context-tools.md +16 -3
  11. data/doc/editor.md +394 -0
  12. data/doc/extensibility.md +16 -7
  13. data/doc/files.md +100 -0
  14. data/doc/getting-started.md +25 -18
  15. data/doc/git.md +122 -0
  16. data/doc/memory.md +24 -4
  17. data/doc/personas.md +34 -5
  18. data/doc/plugins.md +72 -1
  19. data/doc/releasing.md +37 -9
  20. data/doc/rpc.md +74 -4
  21. data/doc/session-management.md +35 -1
  22. data/doc/shell.md +286 -0
  23. data/doc/tabs.md +122 -0
  24. data/doc/troubleshooting.md +77 -1
  25. data/doc/usage.md +53 -7
  26. data/doc/web-search.md +12 -4
  27. data/doc/workspace-tools.md +51 -12
  28. data/examples/plugins/space_invaders.rb +377 -0
  29. data/lib/kward/agent.rb +1 -1
  30. data/lib/kward/cli/commands.rb +33 -2
  31. data/lib/kward/cli/git.rb +150 -0
  32. data/lib/kward/cli/interactive_turn.rb +73 -9
  33. data/lib/kward/cli/plugins.rb +54 -4
  34. data/lib/kward/cli/prompt_interface.rb +32 -1
  35. data/lib/kward/cli/runtime_helpers.rb +133 -3
  36. data/lib/kward/cli/sessions.rb +2 -2
  37. data/lib/kward/cli/settings.rb +218 -9
  38. data/lib/kward/cli/slash_commands.rb +415 -2
  39. data/lib/kward/cli/tabs.rb +695 -0
  40. data/lib/kward/cli.rb +158 -26
  41. data/lib/kward/config_files.rb +123 -1
  42. data/lib/kward/context_budget_meter.rb +44 -0
  43. data/lib/kward/conversation.rb +12 -4
  44. data/lib/kward/editor_mode.rb +25 -0
  45. data/lib/kward/ekwsh.rb +362 -0
  46. data/lib/kward/plugin_registry.rb +61 -0
  47. data/lib/kward/project_files.rb +52 -0
  48. data/lib/kward/prompt_history.rb +82 -0
  49. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  50. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  51. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  52. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  53. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  54. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  55. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  56. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  57. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  58. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  59. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  60. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  61. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  62. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  63. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  64. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  65. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  66. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  67. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  68. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  69. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  70. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  71. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  72. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  73. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  74. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  75. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  76. data/lib/kward/prompt_interface/key_handler.rb +387 -35
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  78. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +98 -50
  80. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  81. data/lib/kward/prompt_interface/screen.rb +16 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
  83. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  84. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  85. data/lib/kward/prompt_interface.rb +286 -8
  86. data/lib/kward/prompts/commands.rb +5 -0
  87. data/lib/kward/prompts.rb +2 -0
  88. data/lib/kward/rpc/server.rb +42 -3
  89. data/lib/kward/rpc/session_manager.rb +35 -47
  90. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  91. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  92. data/lib/kward/session_store.rb +44 -0
  93. data/lib/kward/session_tree_nodes.rb +136 -0
  94. data/lib/kward/session_tree_renderer.rb +9 -131
  95. data/lib/kward/tab_store.rb +47 -0
  96. data/lib/kward/text_boundary.rb +25 -0
  97. data/lib/kward/tools/context_budget_stats.rb +54 -0
  98. data/lib/kward/tools/context_for_task.rb +202 -0
  99. data/lib/kward/tools/read_file.rb +8 -4
  100. data/lib/kward/tools/registry.rb +62 -16
  101. data/lib/kward/tools/tool_call.rb +10 -0
  102. data/lib/kward/version.rb +1 -1
  103. data/lib/kward/workers/git_guard.rb +68 -0
  104. data/lib/kward/workers/live_view.rb +49 -0
  105. data/lib/kward/workers/manager.rb +288 -0
  106. data/lib/kward/workers/store.rb +72 -0
  107. data/lib/kward/workers/tool_policy.rb +23 -0
  108. data/lib/kward/workers/worker.rb +82 -0
  109. data/lib/kward/workers/write_lock.rb +38 -0
  110. data/lib/kward/workers.rb +7 -0
  111. data/lib/kward/workspace.rb +110 -24
  112. data/templates/default/fulldoc/html/css/kward.css +107 -36
  113. data/templates/default/kward_navigation.rb +12 -1
  114. data/templates/default/layout/html/layout.erb +4 -2
  115. data/templates/default/layout/html/setup.rb +6 -0
  116. metadata +53 -1
@@ -0,0 +1,82 @@
1
+ require "securerandom"
2
+ require "time"
3
+ require_relative "../config_files"
4
+ require_relative "../cancellation"
5
+
6
+ module Kward
7
+ module Workers
8
+ # Runtime record for one independent unit of agent work.
9
+ class Worker
10
+ STATUSES = %w[idle queued running ready failed cancelled archived].freeze
11
+
12
+ def initialize(id: SecureRandom.hex(4), title:, role:, workspace_root: Dir.pwd, status: "idle", prompt: nil, conversation: nil, session: nil, cancellation: Cancellation.new, created_at: Time.now.utc)
13
+ @id = id
14
+ @title = title.to_s
15
+ @role = role.to_s
16
+ @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
17
+ @status = status.to_s
18
+ @prompt = prompt.to_s
19
+ @conversation = conversation
20
+ @session = session
21
+ @cancellation = cancellation
22
+ @created_at = created_at
23
+ @updated_at = created_at
24
+ @started_at = nil
25
+ @finished_at = nil
26
+ @report = nil
27
+ @error = nil
28
+ @thread = nil
29
+ @event_history = []
30
+ @event_queue = Queue.new
31
+ end
32
+
33
+ attr_reader :id, :title, :role, :workspace_root, :prompt, :conversation, :session, :cancellation, :created_at, :updated_at, :started_at, :finished_at, :report, :error, :thread, :event_history, :event_queue
34
+ attr_writer :conversation, :session, :thread
35
+
36
+ def status
37
+ @status
38
+ end
39
+
40
+ def update_status(status, error: nil, report: nil)
41
+ @status = status.to_s
42
+ @error = error unless error.nil?
43
+ @report = report unless report.nil?
44
+ now = Time.now.utc
45
+ @updated_at = now
46
+ @started_at ||= now if @status == "running"
47
+ @finished_at = now if %w[ready failed cancelled archived].include?(@status)
48
+ self
49
+ end
50
+
51
+ def record_event(event)
52
+ @event_history << event
53
+ @event_queue << event
54
+ end
55
+
56
+ def to_h
57
+ {
58
+ "id" => id,
59
+ "title" => title,
60
+ "role" => role,
61
+ "status" => status,
62
+ "prompt" => prompt,
63
+ "workspace_root" => workspace_root,
64
+ "session_id" => session&.id,
65
+ "session_path" => session&.path,
66
+ "created_at" => timestamp(created_at),
67
+ "updated_at" => timestamp(updated_at),
68
+ "started_at" => timestamp(started_at),
69
+ "finished_at" => timestamp(finished_at),
70
+ "report" => report,
71
+ "error" => error
72
+ }
73
+ end
74
+
75
+ private
76
+
77
+ def timestamp(value)
78
+ value&.utc&.iso8601(3)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,38 @@
1
+ require "thread"
2
+
3
+ module Kward
4
+ module Workers
5
+ # Cooperative ownership guard for workspace-mutating worker tools.
6
+ class WriteLock
7
+ def initialize
8
+ @owner_id = nil
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ attr_reader :owner_id
13
+
14
+ def acquire(owner_id)
15
+ owner = owner_id.to_s
16
+ return false if owner.empty?
17
+
18
+ @mutex.synchronize do
19
+ return true if @owner_id == owner
20
+ return false if @owner_id
21
+
22
+ @owner_id = owner
23
+ true
24
+ end
25
+ end
26
+
27
+ def owned_by?(owner_id)
28
+ @mutex.synchronize { @owner_id == owner_id.to_s }
29
+ end
30
+
31
+ def release(owner_id)
32
+ @mutex.synchronize do
33
+ @owner_id = nil if @owner_id == owner_id.to_s
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,7 @@
1
+ require_relative "workers/git_guard"
2
+ require_relative "workers/live_view"
3
+ require_relative "workers/manager"
4
+ require_relative "workers/store"
5
+ require_relative "workers/tool_policy"
6
+ require_relative "workers/write_lock"
7
+ require_relative "workers/worker"
@@ -54,7 +54,7 @@ module Kward
54
54
  # The returned string is user/model-facing and includes continuation notices
55
55
  # when output is truncated. Errors are returned as `"Error: ..."` strings so
56
56
  # tool calls can be persisted in the conversation without raising.
57
- def read_file(path, offset: nil, limit: nil)
57
+ def read_file(path, offset: nil, limit: nil, mode: nil, max_bytes: nil)
58
58
  resolved = workspace_path(path)
59
59
  return "Error: not a file: #{path}" unless File.file?(resolved)
60
60
 
@@ -64,7 +64,24 @@ module Kward
64
64
  content = File.read(resolved)
65
65
  return "Error: not a text file: #{path}" if binary_content?(content)
66
66
 
67
- large_file_outline_response(path, content, offset: offset, limit: limit) || read_file_slice(content, offset: offset, limit: limit)
67
+ read_mode = normalize_read_mode(mode)
68
+ return read_mode if read_mode.is_a?(String)
69
+
70
+ output_budget = read_output_budget(max_bytes)
71
+ return output_budget if output_budget.is_a?(String)
72
+
73
+ case read_mode
74
+ when :outline
75
+ file_structure_summary(path, content)
76
+ when :preview
77
+ read_file_slice(content, offset: offset, limit: limit || 120, max_bytes: output_budget)
78
+ when :range
79
+ read_file_slice(content, offset: offset, limit: limit, max_bytes: output_budget)
80
+ when :full
81
+ read_file_slice(content, offset: offset, limit: limit, max_bytes: output_budget)
82
+ else
83
+ large_file_outline_response(path, content, offset: offset, limit: limit) || read_file_slice(content, offset: offset, limit: limit, max_bytes: output_budget)
84
+ end
68
85
  rescue SecurityError, Errno::ENOENT => e
69
86
  "Error: #{e.message}"
70
87
  end
@@ -80,11 +97,7 @@ module Kward
80
97
  content = File.read(resolved)
81
98
  return "Error: not a text file: #{path}" if binary_content?(content)
82
99
 
83
- lines = content.split("\n", -1)
84
- outline = source_outline(lines)
85
- return "No recognizable source structure found in #{path}." if outline.empty?
86
-
87
- (["# File structure: #{path}", "- Lines: #{lines.length}", "- Bytes: #{content.bytesize}", "", "## Outline"] + outline).join("\n")
100
+ file_structure_summary(path, content)
88
101
  rescue SecurityError, Errno::ENOENT => e
89
102
  "Error: #{e.message}"
90
103
  end
@@ -235,24 +248,96 @@ module Kward
235
248
  "First #{preview_limit} lines:",
236
249
  preview,
237
250
  "",
238
- "[Use read_file with offset=#{preview_limit + 1} and limit to continue, or request a specific section from the outline.]"
251
+ "[Use read_file with mode=\"range\", offset=#{preview_limit + 1}, and limit to continue; mode=\"outline\" for only the outline; or request a specific section from the outline.]"
239
252
  ].join("\n")
240
253
  end
241
254
 
255
+ def file_structure_summary(path, content)
256
+ lines = content.split("\n", -1)
257
+ outline = source_outline(lines)
258
+ return "No recognizable source structure found in #{path}." if outline.empty?
259
+
260
+ (["# File structure: #{path}", "- Lines: #{lines.length}", "- Bytes: #{content.bytesize}", "", "## Outline"] + outline).join("\n")
261
+ end
262
+
242
263
  def source_outline(lines)
243
- outline = []
264
+ entries = source_outline_entries(lines)
265
+ entries.first(80).map do |entry|
266
+ range = entry[:end_line] && entry[:end_line] != entry[:line] ? " (range #{entry[:line]}-#{entry[:end_line]}, #{entry[:kind]})" : " (#{entry[:kind]})"
267
+ "line #{entry[:line]}: #{' ' * [entry[:indent] / 2, 6].min}#{entry[:signature]}#{range}"
268
+ end
269
+ end
270
+
271
+ def source_outline_entries(lines)
272
+ candidates = []
244
273
  lines.each_with_index do |line, index|
245
- stripped = line.strip
246
- next unless stripped.match?(/\A(class|module|def)\s+/) || stripped.match?(/\A(function|async function)\s+/) || stripped.match?(/\A(export\s+)?(class|interface|type)\s+/)
274
+ declaration = source_declaration(line.strip)
275
+ next unless declaration
276
+
277
+ candidates << declaration.merge(line: index + 1, indent: line[/\A\s*/].to_s.length)
278
+ end
279
+ candidates.each_with_index do |entry, index|
280
+ following = candidates[(index + 1)..]&.find { |candidate| candidate[:indent] <= entry[:indent] }
281
+ entry[:end_line] = following ? following[:line] - 1 : last_content_line(lines)
282
+ end
283
+ candidates
284
+ end
247
285
 
248
- indent = line[/\A\s*/].to_s.length
249
- outline << "line #{index + 1}: #{' ' * [indent / 2, 6].min}#{stripped}"
250
- break if outline.length >= 80
286
+ def source_declaration(stripped)
287
+ case stripped
288
+ when /\A(module)\s+(.+)/
289
+ { kind: "module", signature: stripped }
290
+ when /\A(class)\s+(.+)/
291
+ { kind: "class", signature: stripped }
292
+ when /\A(async\s+def|def)\s+(.+)/
293
+ { kind: "function", signature: stripped }
294
+ when /\A(export\s+)?(async\s+)?function\s+(.+)/
295
+ { kind: "function", signature: stripped }
296
+ when /\A(async\s+)?(?:get\s+|set\s+)?(?:constructor|[A-Za-z_$][\w$]*)\s*\([^;]*\)\s*(?::\s*[^{}]+)?\s*(?:\{\}|\{|=>)?\z/
297
+ { kind: "method", signature: stripped } unless stripped.match?(/\A(if|for|while|switch|catch)\b/)
298
+ when /\A(export\s+)?(class|interface|type|enum)\s+(.+)/
299
+ { kind: Regexp.last_match(2), signature: stripped }
300
+ when /\A(?:export\s+)?(?:const|let|var)\s+\w+\s*=.*=>/
301
+ { kind: "function", signature: stripped }
302
+ when /\Afunc\s+(.+)/
303
+ { kind: "function", signature: stripped }
304
+ when /\Atype\s+\w+\s+(struct|interface)\b/
305
+ { kind: Regexp.last_match(1), signature: stripped }
306
+ when /\A(pub\s+)?(async\s+)?fn\s+(.+)/
307
+ { kind: "function", signature: stripped }
308
+ when /\A(pub\s+)?(struct|enum|trait|impl)\b(.+)?/
309
+ { kind: Regexp.last_match(2), signature: stripped }
310
+ when /\A(?:public|private|protected|internal|static|final|abstract|async|override|virtual|sealed|readonly|partial|\s)+\s*(class|interface|enum|record)\s+(.+)/
311
+ { kind: Regexp.last_match(1), signature: stripped }
312
+ when /\A(?:public|private|protected|internal|static|final|abstract|async|override|virtual|sealed|readonly|partial|\s)+\s*\S[^{;=]*\w+\s*\([^;]*\)\s*(?:\{|=>)?\z/
313
+ { kind: "method", signature: stripped }
251
314
  end
252
- outline
253
315
  end
254
316
 
255
- def read_file_slice(content, offset:, limit:)
317
+ def last_content_line(lines)
318
+ index = lines.rindex { |line| !line.strip.empty? }
319
+ index ? index + 1 : lines.length
320
+ end
321
+
322
+ def normalize_read_mode(mode)
323
+ return nil if mode.nil? || mode.to_s.empty?
324
+
325
+ value = mode.to_s.downcase
326
+ return value.to_sym if %w[preview outline range full].include?(value)
327
+
328
+ "Error: mode must be one of preview, outline, range, full"
329
+ end
330
+
331
+ def read_output_budget(max_bytes)
332
+ return @max_read_output_bytes if max_bytes.nil?
333
+
334
+ value = max_bytes.to_i
335
+ return "Error: max_bytes must be positive" unless value.positive?
336
+
337
+ [value, @max_read_output_bytes].min
338
+ end
339
+
340
+ def read_file_slice(content, offset:, limit:, max_bytes: @max_read_output_bytes)
256
341
  lines = content.split("\n", -1)
257
342
  lines = [""] if lines.empty?
258
343
  start_index = read_start_index(offset)
@@ -263,7 +348,7 @@ module Kward
263
348
 
264
349
  selected_end = user_limit ? [start_index + user_limit, lines.length].min : lines.length
265
350
  selected_lines = lines[start_index...selected_end]
266
- truncated = truncate_read_lines(selected_lines)
351
+ truncated = truncate_read_lines(selected_lines, max_bytes: max_bytes)
267
352
  return truncated[:error] if truncated[:error]
268
353
 
269
354
  output = truncated[:content]
@@ -272,7 +357,8 @@ module Kward
272
357
  start_index: start_index,
273
358
  output_lines: truncated[:line_count],
274
359
  total_lines: lines.length,
275
- truncated_by: truncated[:truncated_by]
360
+ truncated_by: truncated[:truncated_by],
361
+ max_bytes: max_bytes
276
362
  )
277
363
  elsif user_limit && selected_end < lines.length
278
364
  output << "\n\n[#{lines.length - selected_end} more lines in file. Use offset=#{selected_end + 1} to continue.]"
@@ -300,11 +386,11 @@ module Kward
300
386
  value
301
387
  end
302
388
 
303
- def truncate_read_lines(lines)
389
+ def truncate_read_lines(lines, max_bytes: @max_read_output_bytes)
304
390
  first_line = lines.first.to_s
305
- if first_line.bytesize > @max_read_output_bytes
391
+ if first_line.bytesize > max_bytes
306
392
  return {
307
- error: "Error: first line is #{first_line.bytesize} bytes, exceeds #{@max_read_output_bytes} byte read limit. Use run_shell_command with sed/head to inspect smaller chunks."
393
+ error: "Error: first line is #{first_line.bytesize} bytes, exceeds #{max_bytes} byte read limit. Use run_shell_command with sed/head to inspect smaller chunks."
308
394
  }
309
395
  end
310
396
 
@@ -319,7 +405,7 @@ module Kward
319
405
 
320
406
  separator_bytes = output_lines.empty? ? 0 : 1
321
407
  next_bytes = line.bytesize + separator_bytes
322
- if bytes + next_bytes > @max_read_output_bytes
408
+ if bytes + next_bytes > max_bytes
323
409
  truncated_by = "bytes"
324
410
  break
325
411
  end
@@ -336,10 +422,10 @@ module Kward
336
422
  }
337
423
  end
338
424
 
339
- def read_truncation_notice(start_index:, output_lines:, total_lines:, truncated_by:)
425
+ def read_truncation_notice(start_index:, output_lines:, total_lines:, truncated_by:, max_bytes: @max_read_output_bytes)
340
426
  end_line = start_index + output_lines
341
427
  next_offset = end_line + 1
342
- detail = truncated_by == "lines" ? "#{@max_read_output_lines} line limit" : "#{@max_read_output_bytes} byte limit"
428
+ detail = truncated_by == "lines" ? "#{@max_read_output_lines} line limit" : "#{max_bytes} byte limit"
343
429
  "\n\n[Showing lines #{start_index + 1}-#{end_line} of #{total_lines} (#{detail}). Use offset=#{next_offset} to continue.]"
344
430
  end
345
431
 
@@ -341,7 +341,41 @@ body.kward-docs a {
341
341
  color: var(--kward-accent-bright);
342
342
  }
343
343
 
344
- body.kward-docs pre {
344
+ body.kward-docs #content quote,
345
+ body.kward-docs #filecontents quote {
346
+ border-left: 3px solid var(--kward-accent);
347
+ color: var(--kward-ink);
348
+ display: block;
349
+ font-style: italic;
350
+ font-size: 18px;
351
+ line-height: 1.8;
352
+ margin: 28px 0;
353
+ padding: 8px 0 8px 46px;
354
+ position: relative;
355
+ }
356
+
357
+ body.kward-docs #content quote::before,
358
+ body.kward-docs #filecontents quote::before {
359
+ color: var(--kward-accent);
360
+ content: "\201C";
361
+ font-family: Georgia, "Times New Roman", serif;
362
+ font-size: 80px;
363
+ font-style: normal;
364
+ left: 4px;
365
+ line-height: 1;
366
+ opacity: 0.55;
367
+ position: absolute;
368
+ top: -10px;
369
+ }
370
+
371
+ body.kward-docs #content quote br,
372
+ body.kward-docs #filecontents quote br {
373
+ content: "";
374
+ display: block;
375
+ margin-bottom: 4px;
376
+ }
377
+
378
+ body.kward-docs #content pre {
345
379
  background: var(--kward-code);
346
380
  border: 1px solid rgba(149, 169, 52, 0.22);
347
381
  border-radius: 10px;
@@ -537,11 +571,10 @@ body.kward-docs #footer {
537
571
  }
538
572
 
539
573
  /*
540
- Hero tagline "swosh": "Your terminal." flies out to the left and is
541
- replaced by "Your RPC frontend." flying in from the right, then cycles
542
- back. The second line mirrors it from the opposite side — "Your agent."
543
- flies out to the right and is replaced by "Your engine." flying in from
544
- the left — both lines swap in sync. Pure CSS — no JS. The global
574
+ Hero tagline "swosh": cycles each line through three phrases. The first
575
+ line moves right-to-left: "Your terminal.", "Your RPC frontend.", then
576
+ "Your workflow." The second line mirrors it left-to-right: "Your agent.",
577
+ "Your LLM engine.", then "Your harness." Pure CSS no JS. The global
545
578
  @media (prefers-reduced-motion) rule above collapses the animation to
546
579
  the static original copy ("Your terminal. Your agent.") so it remains
547
580
  accessible and calm.
@@ -566,68 +599,95 @@ body.kward-docs #footer {
566
599
  will-change: transform, opacity;
567
600
  }
568
601
 
569
- /* The longer text ("Your RPC frontend.") stays in flow to size the box;
570
- the shorter "Your terminal." overlays it absolutely. */
571
- .kward-swosh-text-a {
602
+ /* The longest text ("Your RPC frontend.") stays in flow to size the box;
603
+ the shorter phrases overlay it absolutely. */
604
+ .kward-swosh-text-a,
605
+ .kward-swosh-text-e {
572
606
  left: 0.08em;
573
607
  position: absolute;
574
608
  top: 0.16em;
575
609
  }
576
610
 
577
611
  .kward-swosh-text-a {
578
- animation: kward-swosh-a 12s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
612
+ animation: kward-swosh-a 18s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
579
613
  }
580
614
 
581
615
  .kward-swosh-text-b {
582
- animation: kward-swosh-b 12s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
616
+ animation: kward-swosh-b 18s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
583
617
  position: relative;
584
618
  }
585
619
 
620
+ .kward-swosh-text-e {
621
+ animation: kward-swosh-e 18s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
622
+ }
623
+
586
624
  @keyframes kward-swosh-a {
587
- 0%, 40% { transform: translateX(0); opacity: 1; filter: none; }
588
- 50% { transform: translateX(-140%); opacity: 0; filter: blur(3px); }
589
- 51%, 88% { transform: translateX(140%); opacity: 0; filter: none; }
625
+ 0%, 25% { transform: translateX(0); opacity: 1; filter: none; }
626
+ 33% { transform: translateX(-140%); opacity: 0; filter: blur(3px); }
627
+ 34%, 92% { transform: translateX(140%); opacity: 0; filter: none; }
590
628
  100% { transform: translateX(0); opacity: 1; filter: none; }
591
629
  }
592
630
 
593
631
  @keyframes kward-swosh-b {
594
- 0%, 40% { transform: translateX(140%); opacity: 0; filter: none; }
595
- 50% { transform: translateX(0); opacity: 1; filter: none; }
596
- 88% { transform: translateX(0); opacity: 1; filter: none; }
632
+ 0%, 25% { transform: translateX(140%); opacity: 0; filter: none; }
633
+ 33% { transform: translateX(0); opacity: 1; filter: none; }
634
+ 58% { transform: translateX(0); opacity: 1; filter: none; }
635
+ 66% { transform: translateX(-140%); opacity: 0; filter: blur(3px); }
636
+ 67%, 100% { transform: translateX(140%); opacity: 0; filter: none; }
637
+ }
638
+
639
+ @keyframes kward-swosh-e {
640
+ 0%, 58% { transform: translateX(140%); opacity: 0; filter: none; }
641
+ 66% { transform: translateX(0); opacity: 1; filter: none; }
642
+ 92% { transform: translateX(0); opacity: 1; filter: none; }
597
643
  100% { transform: translateX(-140%); opacity: 0; filter: blur(3px); }
598
644
  }
599
645
 
600
646
  /*
601
- Second line, mirrored: "Your agent." exits to the right while
602
- "Your LLM engine." enters from the left (left-to-right), in sync with
603
- the first swosh. Both texts keep the accent-bright color of the
604
- original "Your agent." span.
647
+ Second line, mirrored left-to-right. All texts keep the accent-bright
648
+ color of the original "Your agent." span.
605
649
  */
606
- .kward-hero h1 .kward-swosh-text-c {
607
- animation: kward-swosh-c 12s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
650
+ .kward-hero h1 .kward-swosh-text-c,
651
+ .kward-hero h1 .kward-swosh-text-f {
608
652
  color: var(--kward-accent-bright);
609
653
  left: 0.08em;
610
654
  position: absolute;
611
655
  top: 0.16em;
612
656
  }
613
657
 
658
+ .kward-hero h1 .kward-swosh-text-c {
659
+ animation: kward-swosh-c 18s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
660
+ }
661
+
614
662
  .kward-hero h1 .kward-swosh-text-d {
615
- animation: kward-swosh-d 12s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
663
+ animation: kward-swosh-d 18s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
616
664
  color: var(--kward-accent-bright);
617
665
  position: relative;
618
666
  }
619
667
 
668
+ .kward-hero h1 .kward-swosh-text-f {
669
+ animation: kward-swosh-f 18s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
670
+ }
671
+
620
672
  @keyframes kward-swosh-c {
621
- 0%, 40% { transform: translateX(0); opacity: 1; filter: none; }
622
- 50% { transform: translateX(140%); opacity: 0; filter: blur(3px); }
623
- 51%, 88% { transform: translateX(-140%); opacity: 0; filter: none; }
673
+ 0%, 25% { transform: translateX(0); opacity: 1; filter: none; }
674
+ 33% { transform: translateX(140%); opacity: 0; filter: blur(3px); }
675
+ 34%, 92% { transform: translateX(-140%); opacity: 0; filter: none; }
624
676
  100% { transform: translateX(0); opacity: 1; filter: none; }
625
677
  }
626
678
 
627
679
  @keyframes kward-swosh-d {
628
- 0%, 40% { transform: translateX(-140%); opacity: 0; filter: none; }
629
- 50% { transform: translateX(0); opacity: 1; filter: none; }
630
- 88% { transform: translateX(0); opacity: 1; filter: none; }
680
+ 0%, 25% { transform: translateX(-140%); opacity: 0; filter: none; }
681
+ 33% { transform: translateX(0); opacity: 1; filter: none; }
682
+ 58% { transform: translateX(0); opacity: 1; filter: none; }
683
+ 66% { transform: translateX(140%); opacity: 0; filter: blur(3px); }
684
+ 67%, 100% { transform: translateX(-140%); opacity: 0; filter: none; }
685
+ }
686
+
687
+ @keyframes kward-swosh-f {
688
+ 0%, 58% { transform: translateX(-140%); opacity: 0; filter: none; }
689
+ 66% { transform: translateX(0); opacity: 1; filter: none; }
690
+ 92% { transform: translateX(0); opacity: 1; filter: none; }
631
691
  100% { transform: translateX(140%); opacity: 0; filter: blur(3px); }
632
692
  }
633
693
 
@@ -1193,7 +1253,7 @@ body.kward-docs .kward-home #footer {
1193
1253
  box-shadow: 0 24px 70px rgba(0, 0, 0, 0.48);
1194
1254
  display: none;
1195
1255
  gap: 16px;
1196
- grid-template-columns: repeat(2, minmax(160px, 220px));
1256
+ grid-template-columns: repeat(3, minmax(160px, 220px));
1197
1257
  left: 50%;
1198
1258
  max-width: calc(100vw - 48px);
1199
1259
  opacity: 0;
@@ -1549,17 +1609,14 @@ body.kward-docs #main.kward-home {
1549
1609
  justify-content: center;
1550
1610
  }
1551
1611
 
1552
- /* The generated YARD TOC is useful for API pages but too noisy on guide pages. */
1553
- .kward-guide-page #toc {
1554
- display: none;
1555
- }
1612
+ /* The generated YARD TOC is shown on all docs pages. Use the `kward-no-toc` marker to suppress it per page. */
1556
1613
 
1557
1614
 
1558
1615
  /* Code blocks own their copy action. */
1559
1616
  body.kward-docs .code-copy-wrapper {
1617
+ display: flow-root;
1560
1618
  margin: 16px 0 24px;
1561
1619
  position: relative;
1562
- width: 100%;
1563
1620
  }
1564
1621
 
1565
1622
  body.kward-docs .code-copy-wrapper pre,
@@ -1571,11 +1628,14 @@ body.kward-docs .code-copy-wrapper pre.code {
1571
1628
  }
1572
1629
 
1573
1630
  body.kward-docs .code-copy-wrapper .copy-code-button {
1631
+ background: #020503;
1632
+ box-shadow: 0 0 0 4px #020503;
1574
1633
  float: none;
1575
1634
  margin: 0;
1576
1635
  position: absolute;
1577
1636
  right: 12px;
1578
1637
  top: 12px;
1638
+ z-index: 2;
1579
1639
  }
1580
1640
 
1581
1641
 
@@ -1986,6 +2046,17 @@ body.kward-docs ul.toplevel a:active {
1986
2046
  color: #000;
1987
2047
  }
1988
2048
 
2049
+ body.kward-docs #content quote,
2050
+ body.kward-docs #filecontents quote {
2051
+ border-left-color: #999;
2052
+ color: #000;
2053
+ }
2054
+
2055
+ body.kward-docs #content quote::before,
2056
+ body.kward-docs #filecontents quote::before {
2057
+ color: #999;
2058
+ }
2059
+
1989
2060
  .kward-page {
1990
2061
  padding: 0;
1991
2062
  max-width: none;
@@ -16,9 +16,19 @@ module KwardDocsNavigationData
16
16
  "Feature guides",
17
17
  [
18
18
  ["Sessions", "file.session-management.html"],
19
+ ["Tabs", "file.tabs.html"],
19
20
  ["Memory", "file.memory.html"],
20
21
  ["Personas", "file.personas.html"]
21
22
  ]
23
+ ],
24
+ [
25
+ "User Tools",
26
+ [
27
+ ["Project files", "file.files.html"],
28
+ ["Integrated Editor", "file.editor.html"],
29
+ ["Git", "file.git.html"],
30
+ ["Shell", "file.shell.html"]
31
+ ]
22
32
  ]
23
33
  ].freeze
24
34
 
@@ -44,7 +54,8 @@ module KwardDocsNavigationData
44
54
  ["Workspace tools", "file.workspace-tools.html"],
45
55
  ["Web search", "file.web-search.html"],
46
56
  ["Code search", "file.code-search.html"],
47
- ["Context tools", "file.context-tools.html"]
57
+ ["Context tools", "file.context-tools.html"],
58
+ ["Context budgeting", "file.context-budgeting.html"]
48
59
  ]
49
60
  ]
50
61
  ].freeze
@@ -82,14 +82,16 @@
82
82
  <div class="kward-hero-copy">
83
83
  <p class="kward-eyebrow">⌘ Ruby CLI Coding Agent</p>
84
84
  <h1>
85
- <span class="kward-swosh" aria-label="Your terminal.">
85
+ <span class="kward-swosh" aria-label="Your terminal. Your RPC frontend. Your workflow.">
86
86
  <span class="kward-swosh-text kward-swosh-text-a" aria-hidden="true">Your terminal.</span>
87
87
  <span class="kward-swosh-text kward-swosh-text-b" aria-hidden="true">Your RPC frontend.</span>
88
+ <span class="kward-swosh-text kward-swosh-text-e" aria-hidden="true">Your workflow.</span>
88
89
  </span>
89
90
  <br>
90
- <span class="kward-swosh" aria-label="Your agent.">
91
+ <span class="kward-swosh" aria-label="Your agent. Your LLM engine. Your harness.">
91
92
  <span class="kward-swosh-text kward-swosh-text-c" aria-hidden="true">Your agent.</span>
92
93
  <span class="kward-swosh-text kward-swosh-text-d" aria-hidden="true">Your LLM engine.</span>
94
+ <span class="kward-swosh-text kward-swosh-text-f" aria-hidden="true">Your harness.</span>
93
95
  </span>
94
96
  </h1>
95
97
  <p class="kward-lede">Kward is an extendable Ruby CLI coding agent that helps you understand your project, edit files, run commands, search the web, and automate workflows—right from your terminal.</p>
@@ -31,12 +31,18 @@ module KwardDocsNavigation
31
31
  "doc/authentication.md" => "file.authentication.html",
32
32
  "doc/troubleshooting.md" => "file.troubleshooting.html",
33
33
  "doc/session-management.md" => "file.session-management.html",
34
+ "doc/tabs.md" => "file.tabs.html",
35
+ "doc/files.md" => "file.files.html",
36
+ "doc/editor.md" => "file.editor.html",
37
+ "doc/git.md" => "file.git.html",
38
+ "doc/shell.md" => "file.shell.html",
34
39
  "doc/memory.md" => "file.memory.html",
35
40
  "doc/personas.md" => "file.personas.html",
36
41
  "doc/extensibility.md" => "file.extensibility.html",
37
42
  "doc/plugins.md" => "file.plugins.html",
38
43
  "doc/agent-tools.md" => "file.agent-tools.html",
39
44
  "doc/workspace-tools.md" => "file.workspace-tools.html",
45
+ "doc/context-budgeting.md" => "file.context-budgeting.html",
40
46
  "doc/web-search.md" => "file.web-search.html",
41
47
  "doc/code-search.md" => "file.code-search.html",
42
48
  "doc/context-tools.md" => "file.context-tools.html",