kward 0.66.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 (93) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +90 -0
  6. data/LICENSE +21 -0
  7. data/README.md +101 -0
  8. data/Rakefile +20 -0
  9. data/doc/authentication.md +105 -0
  10. data/doc/code-search.md +56 -0
  11. data/doc/configuration.md +310 -0
  12. data/doc/extensibility.md +186 -0
  13. data/doc/getting-started.md +127 -0
  14. data/doc/memory.md +192 -0
  15. data/doc/plugins.md +223 -0
  16. data/doc/releasing.md +36 -0
  17. data/doc/rpc.md +635 -0
  18. data/doc/usage.md +179 -0
  19. data/doc/web-search.md +28 -0
  20. data/exe/kward +5 -0
  21. data/kward.gemspec +33 -0
  22. data/lib/kward/agent.rb +234 -0
  23. data/lib/kward/ansi.rb +276 -0
  24. data/lib/kward/auth/file.rb +11 -0
  25. data/lib/kward/auth/github_oauth.rb +222 -0
  26. data/lib/kward/auth/openai_oauth.rb +323 -0
  27. data/lib/kward/auth/openrouter_api_key.rb +40 -0
  28. data/lib/kward/cancellation.rb +54 -0
  29. data/lib/kward/cli.rb +2122 -0
  30. data/lib/kward/clipboard.rb +84 -0
  31. data/lib/kward/compactor.rb +998 -0
  32. data/lib/kward/config_files.rb +564 -0
  33. data/lib/kward/conversation.rb +148 -0
  34. data/lib/kward/events.rb +13 -0
  35. data/lib/kward/export_path.rb +28 -0
  36. data/lib/kward/image_attachments.rb +331 -0
  37. data/lib/kward/markdown_transcript.rb +72 -0
  38. data/lib/kward/memory/manager.rb +652 -0
  39. data/lib/kward/message_access.rb +42 -0
  40. data/lib/kward/model/chat_invocation.rb +23 -0
  41. data/lib/kward/model/client.rb +875 -0
  42. data/lib/kward/model/context_overflow.rb +55 -0
  43. data/lib/kward/model/context_usage.rb +104 -0
  44. data/lib/kward/model/model_info.rb +188 -0
  45. data/lib/kward/model/retry_message.rb +11 -0
  46. data/lib/kward/model/stream_parser.rb +205 -0
  47. data/lib/kward/pan/index.html.erb +143 -0
  48. data/lib/kward/pan/server.rb +397 -0
  49. data/lib/kward/plugin_registry.rb +327 -0
  50. data/lib/kward/private_file.rb +18 -0
  51. data/lib/kward/prompt_interface.rb +2437 -0
  52. data/lib/kward/prompts/commands.rb +50 -0
  53. data/lib/kward/prompts/templates.rb +60 -0
  54. data/lib/kward/prompts.rb +58 -0
  55. data/lib/kward/resources/avatar_kward_logo.rb +48 -0
  56. data/lib/kward/resources/pixel_logo.rb +230 -0
  57. data/lib/kward/rpc/auth_manager.rb +265 -0
  58. data/lib/kward/rpc/config_manager.rb +58 -0
  59. data/lib/kward/rpc/prompt_bridge.rb +104 -0
  60. data/lib/kward/rpc/redactor.rb +47 -0
  61. data/lib/kward/rpc/server.rb +639 -0
  62. data/lib/kward/rpc/session_manager.rb +1122 -0
  63. data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
  64. data/lib/kward/rpc/tool_metadata.rb +80 -0
  65. data/lib/kward/rpc/transcript_normalizer.rb +307 -0
  66. data/lib/kward/rpc/transport.rb +58 -0
  67. data/lib/kward/session_diff.rb +125 -0
  68. data/lib/kward/session_store.rb +493 -0
  69. data/lib/kward/skills/registry.rb +76 -0
  70. data/lib/kward/starter_pack_installer.rb +110 -0
  71. data/lib/kward/steering.rb +56 -0
  72. data/lib/kward/telemetry/logger.rb +195 -0
  73. data/lib/kward/telemetry/stats.rb +466 -0
  74. data/lib/kward/tools/ask_user_question.rb +107 -0
  75. data/lib/kward/tools/base.rb +45 -0
  76. data/lib/kward/tools/code_search.rb +65 -0
  77. data/lib/kward/tools/edit_file.rb +41 -0
  78. data/lib/kward/tools/list_directory.rb +21 -0
  79. data/lib/kward/tools/read_file.rb +30 -0
  80. data/lib/kward/tools/read_skill.rb +27 -0
  81. data/lib/kward/tools/registry.rb +117 -0
  82. data/lib/kward/tools/run_shell_command.rb +28 -0
  83. data/lib/kward/tools/search/code.rb +445 -0
  84. data/lib/kward/tools/search/web.rb +747 -0
  85. data/lib/kward/tools/tool_call.rb +87 -0
  86. data/lib/kward/tools/web_search.rb +48 -0
  87. data/lib/kward/tools/write_file.rb +29 -0
  88. data/lib/kward/transcript_export.rb +40 -0
  89. data/lib/kward/version.rb +4 -0
  90. data/lib/kward/workspace.rb +377 -0
  91. data/lib/kward.rb +6 -0
  92. data/lib/main.rb +3 -0
  93. metadata +232 -0
@@ -0,0 +1,50 @@
1
+ require_relative "../config_files"
2
+
3
+ module Kward
4
+ module PromptCommands
5
+ BUILTIN_COMMANDS = [
6
+ { name: "exit", description: "Exit the interactive session.", argument_hint: "" },
7
+ { name: "quit", description: "Exit the interactive session.", argument_hint: "" },
8
+ { name: "new", description: "Start a new session.", argument_hint: "" },
9
+ { name: "resume", description: "Resume a saved session.", argument_hint: "[path]" },
10
+ { name: "name", description: "Name or clear the current session.", argument_hint: "[name]" },
11
+ { name: "clone", description: "Clone the current session.", argument_hint: "" },
12
+ { name: "copy", description: "Copy clean session text to the clipboard.", argument_hint: "[last|transcript]" },
13
+ { name: "export", description: "Export the current session as Markdown.", argument_hint: "[path]" },
14
+ { name: "compact", description: "Compact the current conversation context.", argument_hint: "[instructions]" },
15
+ { name: "redraw", description: "Refresh the visible terminal.", argument_hint: "" },
16
+ { name: "settings", description: "Configure prompt overlays.", argument_hint: "" },
17
+ { name: "login", description: "Log in with an OAuth provider.", argument_hint: "" },
18
+ { name: "model", description: "Select the default model.", argument_hint: "" },
19
+ { name: "openrouter/catalog", description: "List the full OpenRouter model catalog.", argument_hint: "" },
20
+ { name: "reasoning", description: "Select reasoning effort.", argument_hint: "" },
21
+ { name: "status", description: "Show the current status message.", argument_hint: "" },
22
+ { name: "stats", description: "Show telemetry logging stats.", argument_hint: "[range]" },
23
+ { name: "crew", description: "Reserved for future crew commands.", argument_hint: "" },
24
+ { name: "memory", description: "Inspect and manage Kward memory.", argument_hint: "[enable|disable|auto-summary|core|add|list|forget|promote|inspect|why|summarize]" }
25
+ ].freeze
26
+ BUILTIN_RESERVED_COMMAND_NAMES = BUILTIN_COMMANDS.map { |command| command[:name] }.freeze
27
+ SLASH_COMMAND_PATTERN = %r{\A/(\S+)(?:\s+(.*))?\z}m
28
+
29
+ module_function
30
+
31
+ def parse(input)
32
+ match = input.to_s.match(SLASH_COMMAND_PATTERN)
33
+ return nil unless match
34
+
35
+ [match[1], match[2].to_s]
36
+ end
37
+
38
+ def expand(input, templates: nil, reserved_commands: BUILTIN_RESERVED_COMMAND_NAMES)
39
+ parsed = parse(input)
40
+ return nil unless parsed
41
+
42
+ command, arguments = parsed
43
+ templates ||= ConfigFiles.prompt_templates(reserved_commands: reserved_commands)
44
+ template = templates.find { |candidate| candidate.command == command }
45
+ return nil unless template
46
+
47
+ template.expand(arguments)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,60 @@
1
+ module Kward
2
+ module Prompts
3
+ class Templates
4
+ def initialize(config_dir:, template_class:, markdown_parser:)
5
+ @config_dir = config_dir
6
+ @template_class = template_class
7
+ @markdown_parser = markdown_parser
8
+ end
9
+
10
+ def prompt_templates(reserved_commands: [])
11
+ prompts_root = File.join(@config_dir, "prompts")
12
+ return [] unless Dir.exist?(prompts_root)
13
+
14
+ reserved = reserved_commands.map(&:to_s)
15
+ seen = {}
16
+ Dir.glob(File.join(prompts_root, "*.md")).sort.filter_map do |path|
17
+ template = parse_prompt_template(path)
18
+ next unless template
19
+
20
+ if reserved.include?(template.command)
21
+ warn "Warning: skipping Kward prompt command /#{template.command}: reserved command"
22
+ next
23
+ end
24
+ if seen[template.command]
25
+ warn "Warning: skipping duplicate Kward prompt command /#{template.command}: #{path}"
26
+ next
27
+ end
28
+
29
+ seen[template.command] = true
30
+ template
31
+ end
32
+ rescue StandardError => e
33
+ warn "Warning: skipping Kward prompt templates in #{prompts_root}: #{e.message}"
34
+ []
35
+ end
36
+
37
+ private
38
+
39
+ def parse_prompt_template(path)
40
+ command = File.basename(path, ".md")
41
+ unless command.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/)
42
+ warn "Warning: skipping Kward prompt template #{path}: invalid command name"
43
+ return nil
44
+ end
45
+
46
+ frontmatter, body = @markdown_parser.call(path)
47
+ @template_class.new(
48
+ command: command,
49
+ description: frontmatter.fetch("description", "").to_s.strip,
50
+ argument_hint: frontmatter.fetch("argument-hint", "").to_s.strip,
51
+ body: body,
52
+ path: path
53
+ )
54
+ rescue StandardError => e
55
+ warn "Warning: skipping Kward prompt template #{path}: #{e.message}"
56
+ nil
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,58 @@
1
+ require_relative "config_files"
2
+
3
+ module Kward
4
+ module Prompts
5
+ module_function
6
+
7
+ def system_message(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
8
+ {
9
+ role: "system",
10
+ content: prompt_parts(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context).compact.join("\n\n")
11
+ }
12
+ end
13
+
14
+ def prompt_parts(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
15
+ parts = [base_prompt, config_agents_prompt]
16
+ parts << memory_context unless memory_context.to_s.empty?
17
+ parts << persona_prompt(workspace_root, model: model, reasoning_effort: reasoning_effort, now: now) if include_workspace_personality
18
+ parts << plugin_context unless plugin_context.to_s.empty? || !include_workspace_personality
19
+ parts << skills_prompt
20
+ parts << workspace_agents_prompt(workspace_root)
21
+ parts
22
+ end
23
+
24
+ def base_prompt
25
+ <<~PROMPT.strip
26
+ You are Kward, a concise practical CLI coding agent. You are allowed to use the tools. Help users understand and modify software projects. Inspect files before changing them, make the smallest correct change, preserve existing style, and summarize what changed. Be honest about limitations.
27
+ PROMPT
28
+ end
29
+
30
+ def config_agents_prompt
31
+ ConfigFiles.agents_prompt
32
+ end
33
+
34
+ def persona_prompt(workspace_root = Dir.pwd, model: nil, reasoning_effort: nil, now: Time.now)
35
+ ConfigFiles.persona_prompt(workspace_root, model: model, reasoning_effort: reasoning_effort, now: now)
36
+ end
37
+
38
+ def workspace_agents_prompt(workspace_root = Dir.pwd)
39
+ ConfigFiles.workspace_agents_prompt(workspace_root)
40
+ end
41
+
42
+ def skills_prompt
43
+ skills = ConfigFiles.skills
44
+ return nil if skills.empty?
45
+
46
+ lines = [
47
+ "Configured skills are available in the Kward config directory.",
48
+ "When a task matches a skill, use read_skill to load its instructions before proceeding.",
49
+ "Available skills:"
50
+ ]
51
+ skills.each do |skill|
52
+ description = skill.description.empty? ? "No description provided." : skill.description
53
+ lines << "- #{skill.name}: #{description}"
54
+ end
55
+ lines.join("\n")
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,48 @@
1
+ # Generated from avatar_kward_32x32.png as RGB terminal cells.
2
+ # The interactive banner uses this data instead of decoding a PNG at runtime.
3
+ module Kward
4
+ module Resources
5
+ module AvatarKwardLogo
6
+ PIXELS = [
7
+ [nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil],
8
+ [nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, [36, 40, 24], [39, 42, 25], [39, 42, 25], [33, 36, 21], nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil],
9
+ [nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, [33, 36, 23], [78, 86, 52], [85, 93, 56], [84, 93, 55], [65, 72, 43], [31, 33, 19], nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil],
10
+ [nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, [49, 54, 33], [78, 86, 52], [85, 93, 56], [85, 93, 56], [85, 93, 56], [84, 93, 55], [75, 83, 49], [35, 38, 23], nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil],
11
+ [nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, [61, 68, 40], [80, 88, 53], [85, 93, 56], [75, 83, 49], [72, 80, 48], [70, 78, 46], [78, 86, 52], [84, 93, 55], [76, 85, 51], [50, 55, 34], nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil],
12
+ [nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, [42, 46, 27], [80, 88, 53], [84, 93, 55], [70, 78, 46], [23, 25, 15], [37, 38, 29], [34, 35, 26], [33, 37, 22], [74, 81, 48], [83, 92, 55], [78, 86, 52], [24, 26, 15], nil, nil, nil, nil, nil, nil, nil, nil, nil, nil],
13
+ [nil, nil, nil, nil, nil, nil, nil, nil, nil, [34, 37, 22], [83, 92, 55], [85, 93, 56], [69, 77, 46], [18, 19, 12], [53, 53, 45], [135, 132, 115], [128, 125, 111], [40, 40, 36], [21, 23, 14], [72, 80, 48], [80, 88, 53], [39, 44, 26], nil, nil, nil, nil, nil, nil, nil, nil, nil, nil],
14
+ [nil, nil, nil, nil, nil, nil, nil, nil, [18, 20, 13], [58, 63, 38], [85, 93, 56], [55, 61, 36], [23, 25, 15], [43, 42, 35], [146, 142, 129], [176, 173, 156], [179, 175, 159], [146, 142, 129], [67, 65, 58], [27, 30, 19], [62, 68, 41], [80, 88, 53], [54, 60, 35], nil, nil, nil, nil, nil, nil, nil, nil, nil],
15
+ [nil, nil, nil, nil, nil, nil, nil, nil, [23, 26, 16], [74, 81, 48], [69, 77, 46], [21, 23, 14], [31, 31, 25], [108, 105, 91], [153, 150, 134], [134, 132, 118], [173, 169, 154], [183, 179, 163], [134, 132, 118], [27, 26, 21], [25, 28, 16], [70, 78, 46], [72, 80, 48], [21, 23, 14], nil, nil, nil, nil, nil, nil, nil, nil],
16
+ [nil, nil, nil, nil, nil, nil, nil, [8, 10, 6], [66, 73, 45], [58, 63, 37], [14, 15, 9], [3, 3, 2], [87, 85, 74], [160, 157, 139], [160, 157, 139], [134, 132, 118], [80, 78, 69], [173, 169, 154], [175, 171, 155], [76, 74, 65], [5, 5, 4], [14, 15, 9], [63, 70, 42], [66, 73, 45], [2, 3, 2], nil, nil, nil, nil, nil, nil, nil],
17
+ [nil, nil, nil, nil, nil, nil, nil, [18, 19, 12], [59, 65, 40], [23, 23, 21], [0, 0, 0], [100, 98, 86], [160, 157, 139], [163, 160, 143], [138, 136, 121], [70, 68, 58], [173, 169, 154], [175, 171, 155], [183, 179, 163], [179, 175, 159], [92, 90, 82], [1, 1, 1], [27, 30, 19], [62, 68, 41], [16, 17, 11], nil, nil, nil, nil, nil, nil, nil],
18
+ [nil, nil, nil, nil, nil, nil, [15, 15, 11], [62, 68, 42], [72, 70, 61], [87, 85, 74], [44, 43, 36], [77, 76, 65], [108, 105, 91], [149, 146, 130], [123, 119, 105], [158, 155, 140], [168, 164, 149], [144, 140, 126], [160, 156, 142], [118, 115, 104], [77, 76, 65], [53, 53, 45], [103, 102, 85], [76, 74, 65], [66, 72, 43], [20, 20, 13], nil, nil, nil, nil, nil, nil],
19
+ [nil, nil, nil, nil, nil, nil, [35, 38, 23], [78, 86, 52], [135, 132, 115], [113, 109, 94], [129, 128, 109], [23, 25, 15], [33, 55, 8], [96, 108, 72], [87, 85, 74], [168, 164, 149], [158, 155, 140], [97, 94, 83], [96, 108, 72], [24, 40, 6], [30, 33, 20], [142, 141, 122], [104, 102, 90], [153, 150, 134], [75, 83, 49], [37, 41, 25], nil, nil, nil, nil, nil, nil],
20
+ [nil, nil, nil, nil, nil, [2, 2, 1], [63, 70, 42], [87, 95, 60], [134, 132, 118], [77, 76, 65], [104, 102, 90], [5, 5, 4], [72, 133, 6], [100, 184, 10], [39, 44, 26], [160, 156, 142], [146, 142, 129], [47, 70, 19], [103, 188, 10], [56, 105, 5], [10, 10, 8], [128, 125, 111], [72, 70, 61], [149, 146, 130], [84, 93, 55], [54, 60, 36], [2, 3, 2], nil, nil, nil, nil, nil],
21
+ [nil, nil, nil, nil, nil, [46, 51, 30], [67, 74, 44], [44, 48, 30], [114, 111, 97], [80, 78, 69], [103, 102, 85], [34, 35, 26], [33, 55, 8], [59, 97, 13], [82, 84, 65], [166, 162, 147], [156, 152, 138], [76, 85, 51], [59, 97, 13], [29, 46, 8], [43, 42, 35], [134, 132, 118], [92, 90, 82], [128, 125, 111], [39, 44, 26], [65, 72, 43], [43, 48, 29], nil, nil, nil, nil, nil],
22
+ [nil, nil, nil, nil, [17, 19, 12], [63, 70, 42], [31, 35, 21], [5, 5, 4], [39, 39, 33], [70, 68, 58], [53, 53, 45], [103, 102, 85], [58, 56, 48], [77, 76, 65], [113, 109, 94], [171, 167, 151], [163, 160, 143], [108, 105, 91], [72, 70, 61], [64, 62, 54], [108, 105, 91], [59, 58, 50], [84, 82, 71], [39, 39, 33], [3, 3, 2], [39, 44, 26], [58, 64, 39], nil, nil, nil, nil, nil],
23
+ [nil, nil, nil, [2, 3, 2], [43, 47, 28], [68, 75, 45], [15, 17, 11], [0, 0, 0], [0, 0, 0], [8, 8, 7], [12, 12, 11], [59, 58, 50], [92, 89, 75], [27, 26, 21], [100, 96, 82], [171, 167, 151], [163, 160, 143], [70, 68, 58], [44, 43, 36], [114, 111, 97], [58, 56, 48], [15, 14, 13], [5, 5, 4], [0, 0, 0], [0, 0, 0], [23, 25, 15], [69, 77, 46], [30, 33, 20], nil, nil, nil, nil],
24
+ [nil, nil, nil, [37, 41, 26], [63, 70, 42], [27, 30, 19], [1, 1, 1], [0, 0, 0], [8, 8, 7], [32, 33, 30], [20, 20, 18], [6, 6, 5], [64, 62, 54], [31, 31, 25], [100, 96, 82], [171, 167, 151], [163, 160, 143], [70, 68, 58], [51, 49, 43], [70, 68, 58], [6, 6, 5], [23, 23, 21], [35, 35, 33], [6, 6, 5], [0, 0, 0], [1, 1, 1], [33, 37, 22], [58, 63, 37], [32, 35, 21], nil, nil, nil],
25
+ [nil, nil, nil, [52, 58, 35], [80, 88, 53], [52, 58, 34], [10, 10, 8], [5, 5, 4], [32, 33, 30], [23, 23, 21], [0, 0, 0], [23, 23, 21], [59, 58, 50], [70, 68, 58], [72, 70, 61], [166, 162, 147], [153, 150, 134], [67, 65, 58], [76, 74, 65], [62, 61, 57], [19, 19, 17], [0, 0, 0], [31, 31, 25], [32, 33, 30], [3, 3, 2], [15, 17, 11], [55, 61, 36], [80, 88, 53], [39, 42, 25], nil, nil, nil],
26
+ [nil, nil, nil, [23, 24, 16], [54, 60, 36], [83, 92, 55], [27, 30, 19], [44, 44, 40], [19, 19, 17], [1, 1, 1], [1, 1, 1], [49, 48, 45], [44, 44, 40], [103, 102, 85], [44, 44, 40], [158, 155, 140], [144, 140, 126], [64, 62, 54], [92, 89, 75], [49, 48, 45], [40, 40, 36], [0, 0, 0], [3, 3, 2], [19, 19, 17], [49, 48, 45], [36, 40, 24], [83, 92, 55], [44, 48, 30], [23, 25, 16], nil, nil, nil],
27
+ [nil, nil, nil, [50, 55, 34], [49, 54, 33], [62, 68, 41], [67, 74, 44], [30, 33, 20], [12, 12, 11], [30, 30, 28], [20, 20, 18], [30, 30, 28], [12, 12, 11], [92, 89, 75], [51, 49, 43], [160, 156, 142], [144, 140, 126], [64, 62, 54], [77, 76, 65], [12, 12, 11], [35, 35, 33], [17, 17, 16], [35, 35, 33], [10, 10, 8], [34, 35, 26], [68, 75, 45], [58, 63, 37], [51, 56, 34], [43, 48, 29], nil, nil, nil],
28
+ [nil, nil, [30, 33, 21], [68, 75, 45], [70, 78, 46], [39, 44, 26], [59, 65, 40], [67, 74, 44], [21, 23, 14], [8, 8, 7], [19, 19, 17], [32, 33, 30], [10, 10, 8], [15, 14, 13], [92, 89, 75], [168, 164, 149], [160, 157, 139], [70, 68, 58], [12, 12, 11], [10, 10, 8], [35, 35, 33], [17, 17, 16], [10, 10, 8], [21, 23, 14], [72, 80, 48], [55, 61, 36], [47, 52, 31], [72, 80, 48], [66, 72, 43], [25, 26, 18], nil, nil],
29
+ [nil, nil, [22, 24, 14], [27, 30, 18], [55, 61, 36], [84, 93, 55], [44, 48, 30], [54, 60, 36], [74, 81, 48], [25, 28, 16], [1, 1, 1], [49, 48, 45], [32, 33, 30], [1, 1, 1], [92, 89, 75], [123, 119, 105], [118, 115, 104], [67, 63, 54], [1, 1, 1], [44, 44, 40], [40, 40, 36], [1, 1, 1], [30, 33, 20], [76, 85, 51], [52, 58, 34], [51, 56, 34], [81, 90, 54], [47, 52, 31], [26, 28, 17], nil, nil, nil],
30
+ [nil, [30, 33, 21], [66, 72, 43], [80, 88, 53], [80, 88, 53], [45, 50, 29], [33, 37, 22], [51, 56, 34], [42, 47, 27], [68, 75, 45], [38, 42, 25], [19, 19, 17], [35, 35, 33], [12, 12, 11], [37, 35, 30], [70, 68, 58], [67, 63, 54], [27, 26, 21], [17, 17, 16], [35, 35, 33], [17, 17, 16], [39, 44, 26], [70, 78, 46], [42, 47, 27], [54, 60, 36], [31, 35, 21], [31, 35, 21], [63, 70, 42], [78, 86, 52], [65, 71, 42], nil, nil],
31
+ [nil, [14, 15, 9], [36, 39, 23], [63, 70, 42], [81, 90, 54], [62, 68, 41], [51, 56, 34], [39, 44, 26], [14, 15, 9], [49, 54, 33], [69, 77, 46], [25, 28, 16], [20, 20, 18], [8, 8, 7], [6, 6, 5], [43, 42, 35], [37, 35, 30], [3, 3, 2], [12, 12, 11], [19, 19, 17], [30, 33, 20], [70, 78, 46], [44, 48, 30], [14, 15, 9], [44, 48, 30], [49, 54, 33], [52, 58, 34], [72, 80, 48], [54, 60, 36], [36, 39, 23], [12, 14, 11], nil],
32
+ [nil, [5, 5, 5], nil, [53, 58, 35], [84, 93, 55], [84, 93, 55], [81, 90, 54], [69, 77, 46], [55, 61, 36], [15, 17, 11], [39, 44, 26], [68, 75, 45], [59, 65, 40], [18, 19, 12], [10, 10, 8], [62, 61, 57], [56, 55, 53], [6, 6, 5], [23, 25, 15], [63, 70, 42], [69, 77, 46], [36, 40, 24], [15, 17, 11], [58, 63, 37], [67, 74, 44], [80, 88, 53], [84, 93, 55], [83, 92, 55], [39, 43, 26], nil, [2, 2, 1], nil],
33
+ [nil, nil, nil, [25, 29, 18], [45, 50, 29], [27, 30, 19], [22, 25, 15], [75, 83, 50], [84, 93, 55], [54, 60, 36], [15, 17, 11], [18, 19, 12], [42, 47, 27], [74, 81, 48], [12, 12, 11], [23, 23, 21], [19, 19, 17], [30, 33, 20], [74, 81, 48], [38, 42, 25], [15, 17, 11], [15, 17, 11], [62, 68, 41], [83, 92, 55], [21, 23, 14], [33, 37, 22], [49, 54, 33], [21, 23, 14], nil, nil, nil, [4, 4, 4]],
34
+ [nil, nil, nil, [64, 71, 42], [43, 47, 28], [7, 7, 4], nil, [51, 56, 35], [83, 92, 55], [70, 78, 46], [47, 52, 31], [38, 42, 25], [31, 35, 21], [54, 60, 36], [17, 17, 16], [51, 51, 48], [46, 46, 43], [27, 30, 19], [52, 58, 34], [30, 33, 20], [39, 44, 26], [49, 54, 33], [76, 85, 51], [85, 93, 56], [43, 48, 29], [14, 14, 10], [8, 8, 7], [49, 54, 33], [62, 67, 41], nil, nil, nil],
35
+ [nil, nil, nil, [34, 38, 24], [37, 39, 24], nil, nil, [34, 37, 22], [75, 83, 50], [35, 39, 24], [1, 1, 1], [43, 48, 29], [66, 72, 43], [20, 21, 12], [3, 1, 1], [2, 2, 2], [2, 2, 2], [3, 3, 3], [27, 30, 18], [66, 72, 43], [35, 38, 23], [2, 1, 1], [42, 46, 28], [76, 85, 51], [5, 5, 4], nil, [2, 3, 2], [36, 40, 24], [22, 25, 15], nil, nil, nil],
36
+ [nil, nil, nil, nil, nil, nil, [14, 15, 10], [67, 74, 44], [29, 32, 19], nil, nil, nil, [25, 29, 18], nil, nil, nil, nil, nil, nil, [23, 25, 16], nil, nil, nil, [29, 32, 19], [52, 58, 35], nil, nil, nil, nil, nil, nil, nil],
37
+ [nil, nil, nil, nil, nil, nil, nil, [22, 24, 15], nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, [23, 26, 16], nil, nil, nil, nil, nil, nil, nil],
38
+ [nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil],
39
+ ]
40
+
41
+ PIXELS.each do |row|
42
+ row.each { |color| color.freeze if color }
43
+ row.freeze
44
+ end
45
+ PIXELS.freeze
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,230 @@
1
+ require "zlib"
2
+
3
+ module Kward
4
+ module PixelLogo
5
+ PNG_SIGNATURE = "\x89PNG\r\n\x1a\n".b.freeze
6
+ TRANSPARENT_ALPHA = 128
7
+
8
+ module_function
9
+
10
+ def rows_from_png(path, width:, height:)
11
+ rows_from_pixels(indexed_png_pixels(path), width: width, height: height)
12
+ rescue StandardError
13
+ []
14
+ end
15
+
16
+ def rows_from_pixels(pixels, width:, height:)
17
+ scaled = scale_pixels(pixels, width: width, height: height)
18
+ render_rows(scaled)
19
+ rescue StandardError
20
+ []
21
+ end
22
+
23
+ def half_block_rows_from_png(path, width:, pixel_height:)
24
+ half_block_rows_from_pixels(indexed_png_pixels(path), width: width, pixel_height: pixel_height)
25
+ rescue StandardError
26
+ []
27
+ end
28
+
29
+ def half_block_rows_from_pixels(pixels, width:, pixel_height:)
30
+ scaled = scale_pixels(pixels, width: width, height: pixel_height)
31
+ render_half_block_rows(scaled)
32
+ rescue StandardError
33
+ []
34
+ end
35
+
36
+ def indexed_png_pixels(path)
37
+ data = File.binread(path)
38
+ raise "Invalid PNG" unless data.start_with?(PNG_SIGNATURE)
39
+
40
+ png_width = nil
41
+ png_height = nil
42
+ bit_depth = nil
43
+ color_type = nil
44
+ interlace = nil
45
+ palette = nil
46
+ transparency = []
47
+ idat = +"".b
48
+
49
+ each_chunk(data) do |type, chunk|
50
+ case type
51
+ when "IHDR"
52
+ png_width, png_height, bit_depth, color_type, _compression, _filter, interlace = chunk.unpack("NNCCCCC")
53
+ when "PLTE"
54
+ palette = chunk.bytes.each_slice(3).map { |red, green, blue| [red, green, blue, 255] }
55
+ when "tRNS"
56
+ transparency = chunk.bytes
57
+ when "IDAT"
58
+ idat << chunk
59
+ end
60
+ end
61
+
62
+ raise "Unsupported PNG" unless png_width && png_height && palette
63
+ raise "Unsupported PNG" unless bit_depth == 8 && color_type == 3 && interlace == 0
64
+
65
+ transparency.each_with_index do |alpha, index|
66
+ palette[index] = palette[index][0, 3] + [alpha] if palette[index]
67
+ end
68
+
69
+ unfilter_indexed_rows(Zlib::Inflate.inflate(idat), png_width, png_height).map do |row|
70
+ row.map { |index| palette[index] || [0, 0, 0, 0] }
71
+ end
72
+ end
73
+
74
+ def each_chunk(data)
75
+ offset = PNG_SIGNATURE.bytesize
76
+ while offset < data.bytesize
77
+ length = data[offset, 4].unpack1("N")
78
+ type = data[offset + 4, 4]
79
+ chunk = data[offset + 8, length]
80
+ yield type, chunk
81
+ offset += length + 12
82
+ end
83
+ end
84
+
85
+ def unfilter_indexed_rows(raw, width, height)
86
+ rows = []
87
+ previous = Array.new(width, 0)
88
+ offset = 0
89
+
90
+ height.times do
91
+ filter = raw.getbyte(offset)
92
+ offset += 1
93
+ scanline = raw.byteslice(offset, width).bytes
94
+ offset += width
95
+ row = unfilter_scanline(scanline, previous, filter)
96
+ rows << row
97
+ previous = row
98
+ end
99
+
100
+ rows
101
+ end
102
+
103
+ def unfilter_scanline(scanline, previous, filter)
104
+ row = []
105
+ scanline.each_with_index do |byte, index|
106
+ left = index.zero? ? 0 : row[index - 1]
107
+ up = previous[index] || 0
108
+ up_left = index.zero? ? 0 : previous[index - 1]
109
+ row << case filter
110
+ when 0 then byte
111
+ when 1 then (byte + left) & 0xff
112
+ when 2 then (byte + up) & 0xff
113
+ when 3 then (byte + ((left + up) / 2)) & 0xff
114
+ when 4 then (byte + paeth(left, up, up_left)) & 0xff
115
+ else byte
116
+ end
117
+ end
118
+ row
119
+ end
120
+
121
+ def paeth(left, up, up_left)
122
+ estimate = left + up - up_left
123
+ left_distance = (estimate - left).abs
124
+ up_distance = (estimate - up).abs
125
+ up_left_distance = (estimate - up_left).abs
126
+ return left if left_distance <= up_distance && left_distance <= up_left_distance
127
+ return up if up_distance <= up_left_distance
128
+
129
+ up_left
130
+ end
131
+
132
+ def scale_pixels(pixels, width:, height:)
133
+ source_height = pixels.length
134
+ source_width = pixels.first.length
135
+
136
+ height.times.map do |target_y|
137
+ source_y_start = target_y * source_height / height
138
+ source_y_end = [(target_y + 1) * source_height / height, source_y_start + 1].max
139
+ width.times.map do |target_x|
140
+ source_x_start = target_x * source_width / width
141
+ source_x_end = [(target_x + 1) * source_width / width, source_x_start + 1].max
142
+ dominant_color(pixels, source_x_start...source_x_end, source_y_start...source_y_end)
143
+ end
144
+ end
145
+ end
146
+
147
+ def dominant_color(pixels, x_range, y_range)
148
+ counts = Hash.new(0)
149
+ y_range.each do |y|
150
+ x_range.each do |x|
151
+ counts[visible_color(pixels[y][x])] += 1
152
+ end
153
+ end
154
+ counts.max_by { |_color, count| count }&.first
155
+ end
156
+
157
+ def visible_color(color)
158
+ return nil unless color
159
+
160
+ red, green, blue, alpha = color
161
+ return nil if !alpha.nil? && alpha.to_i < TRANSPARENT_ALPHA
162
+
163
+ [red, green, blue]
164
+ end
165
+
166
+ def render_rows(rows)
167
+ rows.map do |row|
168
+ current = nil
169
+ rendered = +""
170
+ row.each do |color|
171
+ if color != current
172
+ rendered << background_sgr(color)
173
+ current = color
174
+ end
175
+ rendered << " "
176
+ end
177
+ rendered << reset_sgr if current
178
+ rendered
179
+ end
180
+ end
181
+
182
+ def render_half_block_rows(rows)
183
+ rows.each_slice(2).map do |top_row, bottom_row|
184
+ bottom_row ||= Array.new(top_row.length)
185
+ current_foreground = nil
186
+ current_background = nil
187
+ rendered = +""
188
+ top_row.each_with_index do |top, index|
189
+ bottom = bottom_row[index]
190
+ foreground, background, glyph = half_block_cell(top, bottom)
191
+ if background != current_background
192
+ rendered << background_sgr(background)
193
+ current_background = background
194
+ end
195
+ if foreground != current_foreground
196
+ rendered << foreground_sgr(foreground)
197
+ current_foreground = foreground
198
+ end
199
+ rendered << glyph
200
+ end
201
+ rendered << reset_sgr if current_foreground || current_background
202
+ rendered
203
+ end
204
+ end
205
+
206
+ def half_block_cell(top, bottom)
207
+ if top && bottom
208
+ [top, bottom, "▀"]
209
+ elsif top
210
+ [top, nil, "▀"]
211
+ elsif bottom
212
+ [bottom, nil, "▄"]
213
+ else
214
+ [nil, nil, " "]
215
+ end
216
+ end
217
+
218
+ def foreground_sgr(color)
219
+ color ? "\e[38;2;#{color.join(";")}m" : "\e[39m"
220
+ end
221
+
222
+ def background_sgr(color)
223
+ color ? "\e[48;2;#{color.join(";")}m" : "\e[49m"
224
+ end
225
+
226
+ def reset_sgr
227
+ "\e[0m"
228
+ end
229
+ end
230
+ end