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.
- checksums.yaml +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +90 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/Rakefile +20 -0
- data/doc/authentication.md +105 -0
- data/doc/code-search.md +56 -0
- data/doc/configuration.md +310 -0
- data/doc/extensibility.md +186 -0
- data/doc/getting-started.md +127 -0
- data/doc/memory.md +192 -0
- data/doc/plugins.md +223 -0
- data/doc/releasing.md +36 -0
- data/doc/rpc.md +635 -0
- data/doc/usage.md +179 -0
- data/doc/web-search.md +28 -0
- data/exe/kward +5 -0
- data/kward.gemspec +33 -0
- data/lib/kward/agent.rb +234 -0
- data/lib/kward/ansi.rb +276 -0
- data/lib/kward/auth/file.rb +11 -0
- data/lib/kward/auth/github_oauth.rb +222 -0
- data/lib/kward/auth/openai_oauth.rb +323 -0
- data/lib/kward/auth/openrouter_api_key.rb +40 -0
- data/lib/kward/cancellation.rb +54 -0
- data/lib/kward/cli.rb +2122 -0
- data/lib/kward/clipboard.rb +84 -0
- data/lib/kward/compactor.rb +998 -0
- data/lib/kward/config_files.rb +564 -0
- data/lib/kward/conversation.rb +148 -0
- data/lib/kward/events.rb +13 -0
- data/lib/kward/export_path.rb +28 -0
- data/lib/kward/image_attachments.rb +331 -0
- data/lib/kward/markdown_transcript.rb +72 -0
- data/lib/kward/memory/manager.rb +652 -0
- data/lib/kward/message_access.rb +42 -0
- data/lib/kward/model/chat_invocation.rb +23 -0
- data/lib/kward/model/client.rb +875 -0
- data/lib/kward/model/context_overflow.rb +55 -0
- data/lib/kward/model/context_usage.rb +104 -0
- data/lib/kward/model/model_info.rb +188 -0
- data/lib/kward/model/retry_message.rb +11 -0
- data/lib/kward/model/stream_parser.rb +205 -0
- data/lib/kward/pan/index.html.erb +143 -0
- data/lib/kward/pan/server.rb +397 -0
- data/lib/kward/plugin_registry.rb +327 -0
- data/lib/kward/private_file.rb +18 -0
- data/lib/kward/prompt_interface.rb +2437 -0
- data/lib/kward/prompts/commands.rb +50 -0
- data/lib/kward/prompts/templates.rb +60 -0
- data/lib/kward/prompts.rb +58 -0
- data/lib/kward/resources/avatar_kward_logo.rb +48 -0
- data/lib/kward/resources/pixel_logo.rb +230 -0
- data/lib/kward/rpc/auth_manager.rb +265 -0
- data/lib/kward/rpc/config_manager.rb +58 -0
- data/lib/kward/rpc/prompt_bridge.rb +104 -0
- data/lib/kward/rpc/redactor.rb +47 -0
- data/lib/kward/rpc/server.rb +639 -0
- data/lib/kward/rpc/session_manager.rb +1122 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
- data/lib/kward/rpc/tool_metadata.rb +80 -0
- data/lib/kward/rpc/transcript_normalizer.rb +307 -0
- data/lib/kward/rpc/transport.rb +58 -0
- data/lib/kward/session_diff.rb +125 -0
- data/lib/kward/session_store.rb +493 -0
- data/lib/kward/skills/registry.rb +76 -0
- data/lib/kward/starter_pack_installer.rb +110 -0
- data/lib/kward/steering.rb +56 -0
- data/lib/kward/telemetry/logger.rb +195 -0
- data/lib/kward/telemetry/stats.rb +466 -0
- data/lib/kward/tools/ask_user_question.rb +107 -0
- data/lib/kward/tools/base.rb +45 -0
- data/lib/kward/tools/code_search.rb +65 -0
- data/lib/kward/tools/edit_file.rb +41 -0
- data/lib/kward/tools/list_directory.rb +21 -0
- data/lib/kward/tools/read_file.rb +30 -0
- data/lib/kward/tools/read_skill.rb +27 -0
- data/lib/kward/tools/registry.rb +117 -0
- data/lib/kward/tools/run_shell_command.rb +28 -0
- data/lib/kward/tools/search/code.rb +445 -0
- data/lib/kward/tools/search/web.rb +747 -0
- data/lib/kward/tools/tool_call.rb +87 -0
- data/lib/kward/tools/web_search.rb +48 -0
- data/lib/kward/tools/write_file.rb +29 -0
- data/lib/kward/transcript_export.rb +40 -0
- data/lib/kward/version.rb +4 -0
- data/lib/kward/workspace.rb +377 -0
- data/lib/kward.rb +6 -0
- data/lib/main.rb +3 -0
- 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
|