vtx-coding-agent 0.1.1__py3-none-any.whl

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 (117) hide show
  1. vtx/__init__.py +63 -0
  2. vtx/async_utils.py +40 -0
  3. vtx/builtin_skills/github/SKILL.md +139 -0
  4. vtx/builtin_skills/init/SKILL.md +74 -0
  5. vtx/builtin_skills/review/SKILL.md +73 -0
  6. vtx/builtin_skills/skill-builder/SKILL.md +133 -0
  7. vtx/cli.py +90 -0
  8. vtx/config.py +741 -0
  9. vtx/context/__init__.py +15 -0
  10. vtx/context/_xml.py +8 -0
  11. vtx/context/agent_mds.py +128 -0
  12. vtx/context/git.py +64 -0
  13. vtx/context/loader.py +41 -0
  14. vtx/context/skills.py +423 -0
  15. vtx/core/__init__.py +47 -0
  16. vtx/core/compaction.py +89 -0
  17. vtx/core/errors.py +17 -0
  18. vtx/core/handoff.py +51 -0
  19. vtx/core/scratchpad.py +54 -0
  20. vtx/core/types.py +197 -0
  21. vtx/defaults/__init__.py +0 -0
  22. vtx/defaults/config.yml +53 -0
  23. vtx/diff_display.py +12 -0
  24. vtx/events.py +224 -0
  25. vtx/gh_cli.py +82 -0
  26. vtx/git_branch.py +90 -0
  27. vtx/headless.py +127 -0
  28. vtx/llm/__init__.py +93 -0
  29. vtx/llm/base.py +217 -0
  30. vtx/llm/context_length.py +150 -0
  31. vtx/llm/dynamic_models.py +735 -0
  32. vtx/llm/model_fetcher.py +279 -0
  33. vtx/llm/models.py +78 -0
  34. vtx/llm/oauth/__init__.py +59 -0
  35. vtx/llm/oauth/copilot.py +358 -0
  36. vtx/llm/oauth/dynamic.py +236 -0
  37. vtx/llm/oauth/openai.py +400 -0
  38. vtx/llm/phase_parser.py +270 -0
  39. vtx/llm/provider.yaml +280 -0
  40. vtx/llm/provider_catalog.py +230 -0
  41. vtx/llm/providers/__init__.py +45 -0
  42. vtx/llm/providers/anthropic_sdk.py +256 -0
  43. vtx/llm/providers/mock.py +249 -0
  44. vtx/llm/providers/openai_sdk.py +246 -0
  45. vtx/llm/providers/sanitize.py +14 -0
  46. vtx/llm/sdk/__init__.py +13 -0
  47. vtx/llm/sdk/anthropic.py +382 -0
  48. vtx/llm/sdk/base.py +82 -0
  49. vtx/llm/sdk/openai.py +344 -0
  50. vtx/llm/tool_parser.py +161 -0
  51. vtx/loop.py +272 -0
  52. vtx/notify.py +109 -0
  53. vtx/permissions.py +114 -0
  54. vtx/prompts/__init__.py +45 -0
  55. vtx/prompts/builder.py +86 -0
  56. vtx/prompts/env.py +58 -0
  57. vtx/prompts/identity.py +166 -0
  58. vtx/prompts/tooling.py +36 -0
  59. vtx/py.typed +0 -0
  60. vtx/runtime.py +580 -0
  61. vtx/session.py +868 -0
  62. vtx/sounds/completion.wav +0 -0
  63. vtx/sounds/error.wav +0 -0
  64. vtx/sounds/permission.wav +0 -0
  65. vtx/themes.py +1104 -0
  66. vtx/tools/__init__.py +68 -0
  67. vtx/tools/_read_image.py +106 -0
  68. vtx/tools/_tool_utils.py +90 -0
  69. vtx/tools/base.py +36 -0
  70. vtx/tools/bash.py +371 -0
  71. vtx/tools/edit.py +261 -0
  72. vtx/tools/find.py +132 -0
  73. vtx/tools/read.py +238 -0
  74. vtx/tools/skill.py +278 -0
  75. vtx/tools/web.py +238 -0
  76. vtx/tools/write.py +88 -0
  77. vtx/tools_manager.py +216 -0
  78. vtx/turn.py +789 -0
  79. vtx/ui/__init__.py +0 -0
  80. vtx/ui/agent_runner.py +417 -0
  81. vtx/ui/app.py +665 -0
  82. vtx/ui/app_protocol.py +29 -0
  83. vtx/ui/autocomplete.py +440 -0
  84. vtx/ui/blocks.py +735 -0
  85. vtx/ui/chat.py +613 -0
  86. vtx/ui/clipboard.py +59 -0
  87. vtx/ui/commands/__init__.py +100 -0
  88. vtx/ui/commands/auth.py +306 -0
  89. vtx/ui/commands/base.py +122 -0
  90. vtx/ui/commands/models.py +144 -0
  91. vtx/ui/commands/sessions.py +388 -0
  92. vtx/ui/commands/settings.py +286 -0
  93. vtx/ui/completion_ui.py +313 -0
  94. vtx/ui/export.py +703 -0
  95. vtx/ui/floating_list.py +370 -0
  96. vtx/ui/formatting.py +287 -0
  97. vtx/ui/input.py +760 -0
  98. vtx/ui/latex.py +349 -0
  99. vtx/ui/launch.py +108 -0
  100. vtx/ui/path_complete.py +228 -0
  101. vtx/ui/prompt_history.py +102 -0
  102. vtx/ui/queue_ui.py +141 -0
  103. vtx/ui/selection_mode.py +18 -0
  104. vtx/ui/session_ui.py +235 -0
  105. vtx/ui/startup.py +124 -0
  106. vtx/ui/styles.py +327 -0
  107. vtx/ui/tool_output.py +34 -0
  108. vtx/ui/tree.py +437 -0
  109. vtx/ui/welcome.py +51 -0
  110. vtx/ui/widgets.py +558 -0
  111. vtx/update_check.py +49 -0
  112. vtx/version.py +22 -0
  113. vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
  114. vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
  115. vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
  116. vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
  117. vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
vtx/core/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ from .errors import format_error
2
+ from .scratchpad import get_scratchpad_dir, init_scratchpad, is_scratchpad_path
3
+ from .types import (
4
+ AssistantMessage,
5
+ ImageContent,
6
+ Message,
7
+ StopReason,
8
+ StreamDone,
9
+ StreamError,
10
+ StreamPart,
11
+ TextContent,
12
+ TextPart,
13
+ ThinkingContent,
14
+ ThinkPart,
15
+ ToolCall,
16
+ ToolCallDelta,
17
+ ToolCallStart,
18
+ ToolDefinition,
19
+ ToolResultMessage,
20
+ Usage,
21
+ UserMessage,
22
+ )
23
+
24
+ __all__ = [
25
+ "AssistantMessage",
26
+ "ImageContent",
27
+ "Message",
28
+ "StopReason",
29
+ "StreamDone",
30
+ "StreamError",
31
+ "StreamPart",
32
+ "TextContent",
33
+ "TextPart",
34
+ "ThinkPart",
35
+ "ThinkingContent",
36
+ "ToolCall",
37
+ "ToolCallDelta",
38
+ "ToolCallStart",
39
+ "ToolDefinition",
40
+ "ToolResultMessage",
41
+ "Usage",
42
+ "UserMessage",
43
+ "format_error",
44
+ "get_scratchpad_dir",
45
+ "init_scratchpad",
46
+ "is_scratchpad_path",
47
+ ]
vtx/core/compaction.py ADDED
@@ -0,0 +1,89 @@
1
+ """
2
+ Context compaction for long sessions.
3
+
4
+ When token usage exceeds the usable context window, send the full conversation
5
+ to the LLM with a summarization prompt, then store the summary as a
6
+ CompactionEntry. The session.messages property filters to only show messages
7
+ after the compaction point.
8
+
9
+ Overflow formula:
10
+ total_tokens >= context_window - min(buffer_tokens, max_output_tokens)
11
+ """
12
+
13
+ from ..core.types import Message, TextPart, Usage, UserMessage
14
+ from ..llm.base import BaseProvider
15
+
16
+ SUMMARIZATION_PROMPT = """Provide a detailed prompt for continuing our \
17
+ conversation above. Focus on information that would be helpful for \
18
+ continuing the conversation, including what we did, what we're doing, \
19
+ which files we're working on, and what we're going to do next. \
20
+ The summary that you construct will be used so that another agent \
21
+ can read it and continue the work.
22
+
23
+ When constructing the summary, try to stick to this template:
24
+ ---
25
+ ## Goal
26
+
27
+ [What goal(s) is the user trying to accomplish?]
28
+
29
+ ## Instructions
30
+
31
+ - [What important instructions did the user give you that are relevant]
32
+ - [If there is a plan or spec, include information about it
33
+ so next agent can continue using it]
34
+
35
+ ## Discoveries
36
+
37
+ [What notable things were learned during this conversation that would
38
+ be useful for the next agent to know when continuing the work]
39
+
40
+ ## Accomplished
41
+
42
+ [What work has been completed, what work is still in progress,
43
+ and what work is left?]
44
+
45
+ ## Relevant files / directories
46
+
47
+ [Construct a structured list of relevant files that have been read,
48
+ edited, or created that pertain to the task at hand. If all the files
49
+ in a directory are relevant, include the path to the directory.]
50
+ ---"""
51
+
52
+
53
+ def is_overflow(
54
+ usage: Usage, context_window: int, max_output_tokens: int, buffer_tokens: int
55
+ ) -> bool:
56
+ count = (
57
+ usage.input_tokens
58
+ + usage.output_tokens
59
+ + usage.cache_read_tokens
60
+ + usage.cache_write_tokens
61
+ )
62
+ reserved = min(buffer_tokens, max_output_tokens)
63
+ usable = context_window - reserved
64
+ return count >= usable
65
+
66
+
67
+ def _calculate_context_tokens(usage: Usage) -> int:
68
+ return (
69
+ usage.input_tokens
70
+ + usage.output_tokens
71
+ + usage.cache_read_tokens
72
+ + usage.cache_write_tokens
73
+ )
74
+
75
+
76
+ async def generate_summary(
77
+ messages: list[Message], provider: BaseProvider, system_prompt: str | None = None
78
+ ) -> str:
79
+ """Send the full conversation + summarization prompt to the LLM, return summary text."""
80
+ summary_messages: list[Message] = [*messages, UserMessage(content=SUMMARIZATION_PROMPT)]
81
+
82
+ stream = await provider.stream(summary_messages, system_prompt=system_prompt, tools=None)
83
+
84
+ text_parts: list[str] = []
85
+ async for part in stream:
86
+ if isinstance(part, TextPart):
87
+ text_parts.append(part.text)
88
+
89
+ return "".join(text_parts)
vtx/core/errors.py ADDED
@@ -0,0 +1,17 @@
1
+ """Shared error formatting for provider/agent error surfaces.
2
+
3
+ Errors crossing the event boundary (StreamError, ErrorEvent, compaction
4
+ failures) are reduced to strings, so the exception type must be baked into
5
+ the message or it is lost to the user.
6
+ """
7
+
8
+
9
+ def format_error(error: BaseException) -> str:
10
+ name = type(error).__name__
11
+ message = str(error).strip()
12
+ if not message:
13
+ return f"{name}: failed without an error message"
14
+ # Some SDK errors already stringify with their type name; don't repeat it.
15
+ if message.startswith(name):
16
+ return message
17
+ return f"{name}: {message}"
vtx/core/handoff.py ADDED
@@ -0,0 +1,51 @@
1
+ from ..core.types import Message, TextPart, UserMessage
2
+ from ..llm.base import BaseProvider
3
+
4
+ HANDOFF_PROMPT_TEMPLATE = """You are creating a handoff to a NEW focused thread.
5
+
6
+ New thread goal (from user):
7
+ {query}
8
+
9
+ Based on the conversation above, write the exact opening user prompt for the new thread.
10
+
11
+ Requirements:
12
+ - Focus ONLY on context relevant to the new goal.
13
+ - Preserve critical decisions, constraints, and assumptions.
14
+ - Include concrete file paths and why they matter (only if relevant).
15
+ - Include current status: done, in progress, and next action.
16
+ - Do not invent facts; if unknown, say "Unknown".
17
+ - Do not include backlinks, UI notes, or any metadata.
18
+ - Do not mention "handoff", "summary", or "conversation above".
19
+ - Output must be ready to send as-is by the user in the new thread.
20
+
21
+ Output format (plain text, no markdown code fences):
22
+ Task: <clear goal>
23
+
24
+ Context to keep:
25
+ - ...
26
+
27
+ Relevant files:
28
+ - <path> — <why it matters>
29
+
30
+ Constraints:
31
+ - ...
32
+
33
+ Next steps:
34
+ 1. ...
35
+ 2. ..."""
36
+
37
+
38
+ async def generate_handoff_prompt(
39
+ messages: list[Message], provider: BaseProvider, system_prompt: str | None, query: str
40
+ ) -> str:
41
+ handoff_prompt = HANDOFF_PROMPT_TEMPLATE.format(query=query.strip())
42
+ handoff_messages: list[Message] = [*messages, UserMessage(content=handoff_prompt)]
43
+
44
+ stream = await provider.stream(handoff_messages, system_prompt=system_prompt, tools=None)
45
+
46
+ text_parts: list[str] = []
47
+ async for part in stream:
48
+ if isinstance(part, TextPart):
49
+ text_parts.append(part.text)
50
+
51
+ return "".join(text_parts).strip()
vtx/core/scratchpad.py ADDED
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from vtx import get_config_dir
6
+
7
+ _active_scratchpads: dict[str, Path] = {}
8
+
9
+
10
+ def init_scratchpad(session_id: str) -> Path | None:
11
+ """Create a session-scoped scratchpad directory under VTX config dir.
12
+
13
+ Each session gets its own scratchpad directory. Idempotent per session_id.
14
+ """
15
+ if session_id in _active_scratchpads:
16
+ return _active_scratchpads[session_id]
17
+
18
+ try:
19
+ config_dir = get_config_dir()
20
+ dir_path = config_dir / "scratchpads" / f"vtx-scratchpad-{session_id[:8]}"
21
+ dir_path.mkdir(parents=True, exist_ok=True)
22
+ _active_scratchpads[session_id] = dir_path
23
+ return dir_path
24
+ except OSError:
25
+ return None
26
+
27
+
28
+ def get_scratchpad_dir(session_id: str) -> Path | None:
29
+ """Return the scratchpad directory for a given session, or None."""
30
+ return _active_scratchpads.get(session_id)
31
+
32
+
33
+ def is_scratchpad_path(path_str: str) -> bool:
34
+ """Return True if the resolved path is inside any active scratchpad.
35
+
36
+ Uses Path.resolve() to defeat path traversal and symlink attacks.
37
+ """
38
+ if not _active_scratchpads:
39
+ return False
40
+ try:
41
+ resolved = Path(path_str).expanduser().resolve()
42
+ return any(
43
+ _is_subpath(resolved, sp_dir.resolve()) for sp_dir in _active_scratchpads.values()
44
+ )
45
+ except (ValueError, OSError):
46
+ return False
47
+
48
+
49
+ def _is_subpath(path: Path, parent: Path) -> bool:
50
+ try:
51
+ path.relative_to(parent)
52
+ return True
53
+ except ValueError:
54
+ return False
vtx/core/types.py ADDED
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+ from typing import Any, Literal
5
+
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class StopReason(StrEnum):
10
+ STOP = "stop"
11
+ LENGTH = "length"
12
+ TOOL_USE = "tool_use"
13
+ ERROR = "error"
14
+ INTERRUPTED = "interrupted"
15
+ STEER = "steer"
16
+
17
+
18
+ class Usage(BaseModel):
19
+ input_tokens: int = 0
20
+ output_tokens: int = 0
21
+ cache_read_tokens: int = 0
22
+ cache_write_tokens: int = 0
23
+
24
+ @property
25
+ def total_tokens(self) -> int:
26
+ return (
27
+ self.input_tokens
28
+ + self.output_tokens
29
+ + self.cache_read_tokens
30
+ + self.cache_write_tokens
31
+ )
32
+
33
+
34
+ # =================================================================================================
35
+ # Stream Parts - yielded by providers during streaming
36
+ # =================================================================================================
37
+
38
+
39
+ class TextPart(BaseModel):
40
+ type: Literal["text"] = "text"
41
+ text: str
42
+
43
+ def merge(self, other: TextPart) -> TextPart:
44
+ return TextPart(text=self.text + other.text)
45
+
46
+
47
+ class ThinkPart(BaseModel):
48
+ type: Literal["think"] = "think"
49
+ think: str
50
+ signature: str | None = None
51
+
52
+ def merge(self, other: ThinkPart) -> ThinkPart:
53
+ signature = self.signature or other.signature
54
+ return ThinkPart(think=self.think + other.think, signature=signature)
55
+
56
+
57
+ class ToolCallStart(BaseModel):
58
+ type: Literal["tool_call_start"] = "tool_call_start"
59
+ id: str
60
+ name: str
61
+ index: int # Tool call index for correlating deltas
62
+ arguments: dict[str, Any] | None = None
63
+
64
+
65
+ class ToolCallDelta(BaseModel):
66
+ type: Literal["tool_call_delta"] = "tool_call_delta"
67
+ index: int # Correlates with ToolCallStart.index
68
+ arguments_delta: str
69
+ replace: bool = False
70
+
71
+
72
+ class StreamDone(BaseModel):
73
+ type: Literal["done"] = "done"
74
+ stop_reason: StopReason
75
+
76
+
77
+ class StreamError(BaseModel):
78
+ type: Literal["error"] = "error"
79
+ error: str
80
+
81
+
82
+ StreamPart = TextPart | ThinkPart | ToolCallStart | ToolCallDelta | StreamDone | StreamError
83
+
84
+
85
+ # =================================================================================================
86
+ # Message Types - canonical provider-agnostic conversation interface
87
+ # Used for conversation history and cross-provider normalization
88
+ # =================================================================================================
89
+
90
+
91
+ class TextContent(BaseModel):
92
+ type: Literal["text"] = "text"
93
+ text: str
94
+
95
+
96
+ class ThinkingContent(BaseModel):
97
+ type: Literal["thinking"] = "thinking"
98
+ thinking: str
99
+ signature: str | None = None
100
+
101
+
102
+ class ImageContent(BaseModel):
103
+ type: Literal["image"] = "image"
104
+ data: str # base64 encoded
105
+ mime_type: str
106
+
107
+
108
+ class ToolCall(BaseModel):
109
+ type: Literal["tool_call"] = "tool_call"
110
+ id: str
111
+ name: str
112
+ arguments: dict[str, Any]
113
+
114
+
115
+ class UserMessage(BaseModel):
116
+ role: Literal["user"] = "user"
117
+ content: str | list[TextContent | ImageContent]
118
+
119
+
120
+ class AssistantMessage(BaseModel):
121
+ role: Literal["assistant"] = "assistant"
122
+ content: list[TextContent | ThinkingContent | ToolCall]
123
+ usage: Usage | None = None
124
+ stop_reason: StopReason | None = None
125
+
126
+
127
+ class ToolResultMessage(BaseModel):
128
+ role: Literal["tool_result"] = "tool_result"
129
+ tool_call_id: str
130
+ tool_name: str
131
+ content: list[TextContent | ImageContent]
132
+ ui_summary: str | None = None # One-line UI text rendered on tool header line
133
+ ui_details: str | None = None # Collapsed multiline UI text rendered below the header
134
+ ui_details_full: str | None = None # Expanded multiline UI text rendered below the header
135
+ is_error: bool = False
136
+ file_changes: FileChanges | None = None
137
+
138
+
139
+ Message = UserMessage | AssistantMessage | ToolResultMessage
140
+
141
+
142
+ # =================================================================================================
143
+ # Tool Definition
144
+ # =================================================================================================
145
+
146
+
147
+ class ToolParameter(BaseModel):
148
+ type: str
149
+ description: str | None = None
150
+ enum: list[str] | None = None
151
+
152
+
153
+ class ToolDefinition(BaseModel):
154
+ name: str
155
+ description: str
156
+ parameters: dict[str, Any] # JSON Schema
157
+
158
+
159
+ class FileChanges(BaseModel):
160
+ path: str
161
+ added: int = 0
162
+ removed: int = 0
163
+
164
+
165
+ class ToolResult(BaseModel):
166
+ success: bool
167
+ result: str | None = None # Raw result (sent to LLM)
168
+ images: list[ImageContent] | None = None # Images to include in result
169
+ ui_summary: str | None = None # One-line result text appended to the tool header
170
+ ui_details: str | None = None # Collapsed multiline result body rendered below the header
171
+ ui_details_full: str | None = None # Expanded multiline result body rendered below the header
172
+ file_changes: FileChanges | None = None # Track +/- lines for edit/write tools
173
+
174
+
175
+ # UI rendering model:
176
+ #
177
+ # format_call is defined for each tool like Read tool and the result they
178
+ # return contains further details (along with the resulf for llm) to help paint
179
+ # the coomplete picture (or as close to it as possible without polluting) in the ui
180
+ #
181
+ # - format_call(params): short call text shown on the header line
182
+ # - ui_summary: one-line result summary appended to the same header line
183
+ # - ui_details: multiline result body shown below the header
184
+ #
185
+ # Example (read):
186
+ # → Read ~/src/vtx/turn.py:150-204 (55 lines)
187
+ # - format_call -> "~/src/vtx/turn.py:150-204"
188
+ # - ui_summary -> "(55 lines)"
189
+ # - ui_details -> None
190
+ #
191
+ # Example (edit):
192
+ # + Edit ~/src/vtx/tools/base.py +3 -1
193
+ # -12 old line
194
+ # +12 new line
195
+ # - format_call -> "~/src/vtx/tools/base.py"
196
+ # - ui_summary -> "+3 -1"
197
+ # - ui_details -> formatted diff
File without changes
@@ -0,0 +1,53 @@
1
+ meta:
2
+ config_version: 6
3
+
4
+ llm:
5
+ default_provider: "openai-codex"
6
+ default_model: "gpt-5.5"
7
+ default_base_url: ""
8
+ default_thinking_level: "low"
9
+ tool_call_idle_timeout_seconds: 180
10
+ request_timeout_seconds: 600
11
+
12
+ auth:
13
+ openai_compat: "auto"
14
+ anthropic_compat: "auto"
15
+
16
+ tls:
17
+ insecure_skip_verify: false
18
+
19
+ system_prompt:
20
+ git_context: true
21
+ # Base identity + general rules. Defaults to vtx.prompts.identity.DEFAULT_VTX_BASE
22
+ # (sourced from Python). Set a custom string here to override the base prompt;
23
+ # extra sections (tool guidelines, AGENTS.md, skills, git, env) are still
24
+ # appended automatically.
25
+ content: ""
26
+
27
+ compaction:
28
+ on_overflow: "continue"
29
+ buffer_tokens: 20000
30
+
31
+ agent:
32
+ max_turns: 500
33
+ default_context_window: 200000
34
+
35
+ ui:
36
+ theme: "gruvbox-dark"
37
+ collapse_thinking: true
38
+ show_welcome_shortcuts: true
39
+ hidden_models: []
40
+
41
+ permissions:
42
+ mode: "prompt"
43
+
44
+ notifications:
45
+ enabled: false
46
+ volume: 0.5
47
+
48
+ # Example: DeepSeek provider
49
+ # export DEEPSEEK_API_KEY="sk-your-api-key"
50
+ #
51
+ # Then set in [llm]:
52
+ # default_provider = "deepseek"
53
+ # default_model = "deepseek-v4-flash"
vtx/diff_display.py ADDED
@@ -0,0 +1,12 @@
1
+ DIFF_BG_PAD_MARKER = "__VTX_DIFF_BG_PAD__"
2
+
3
+
4
+ def blend_hex(fg_hex: str, bg_hex: str, alpha: float = 0.15) -> str:
5
+ fg_hex = fg_hex.lstrip("#")
6
+ bg_hex = bg_hex.lstrip("#")
7
+ fr, fg, fb = int(fg_hex[:2], 16), int(fg_hex[2:4], 16), int(fg_hex[4:6], 16)
8
+ br, bg_c, bb = int(bg_hex[:2], 16), int(bg_hex[2:4], 16), int(bg_hex[4:6], 16)
9
+ r = int(fr * alpha + br * (1 - alpha))
10
+ g = int(fg * alpha + bg_c * (1 - alpha))
11
+ b = int(fb * alpha + bb * (1 - alpha))
12
+ return f"#{r:02x}{g:02x}{b:02x}"