codemaster-cli 2.2.0__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.
- codemaster_cli-2.2.0.dist-info/METADATA +645 -0
- codemaster_cli-2.2.0.dist-info/RECORD +170 -0
- codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
- codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
- vibe/__init__.py +6 -0
- vibe/acp/__init__.py +0 -0
- vibe/acp/acp_agent_loop.py +746 -0
- vibe/acp/entrypoint.py +81 -0
- vibe/acp/tools/__init__.py +0 -0
- vibe/acp/tools/base.py +100 -0
- vibe/acp/tools/builtins/bash.py +134 -0
- vibe/acp/tools/builtins/read_file.py +54 -0
- vibe/acp/tools/builtins/search_replace.py +129 -0
- vibe/acp/tools/builtins/todo.py +65 -0
- vibe/acp/tools/builtins/write_file.py +98 -0
- vibe/acp/tools/session_update.py +118 -0
- vibe/acp/utils.py +213 -0
- vibe/cli/__init__.py +0 -0
- vibe/cli/autocompletion/__init__.py +0 -0
- vibe/cli/autocompletion/base.py +22 -0
- vibe/cli/autocompletion/path_completion.py +177 -0
- vibe/cli/autocompletion/slash_command.py +99 -0
- vibe/cli/cli.py +188 -0
- vibe/cli/clipboard.py +69 -0
- vibe/cli/commands.py +116 -0
- vibe/cli/entrypoint.py +163 -0
- vibe/cli/history_manager.py +91 -0
- vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
- vibe/cli/plan_offer/decide_plan_offer.py +87 -0
- vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
- vibe/cli/terminal_setup.py +323 -0
- vibe/cli/textual_ui/__init__.py +0 -0
- vibe/cli/textual_ui/ansi_markdown.py +58 -0
- vibe/cli/textual_ui/app.py +1546 -0
- vibe/cli/textual_ui/app.tcss +1020 -0
- vibe/cli/textual_ui/external_editor.py +32 -0
- vibe/cli/textual_ui/handlers/__init__.py +5 -0
- vibe/cli/textual_ui/handlers/event_handler.py +147 -0
- vibe/cli/textual_ui/widgets/__init__.py +0 -0
- vibe/cli/textual_ui/widgets/approval_app.py +192 -0
- vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
- vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
- vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
- vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
- vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
- vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
- vibe/cli/textual_ui/widgets/compact.py +41 -0
- vibe/cli/textual_ui/widgets/config_app.py +171 -0
- vibe/cli/textual_ui/widgets/context_progress.py +30 -0
- vibe/cli/textual_ui/widgets/load_more.py +43 -0
- vibe/cli/textual_ui/widgets/loading.py +201 -0
- vibe/cli/textual_ui/widgets/messages.py +277 -0
- vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
- vibe/cli/textual_ui/widgets/path_display.py +28 -0
- vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
- vibe/cli/textual_ui/widgets/question_app.py +496 -0
- vibe/cli/textual_ui/widgets/spinner.py +194 -0
- vibe/cli/textual_ui/widgets/status_message.py +76 -0
- vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
- vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
- vibe/cli/textual_ui/widgets/tools.py +201 -0
- vibe/cli/textual_ui/windowing/__init__.py +29 -0
- vibe/cli/textual_ui/windowing/history.py +105 -0
- vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
- vibe/cli/textual_ui/windowing/state.py +105 -0
- vibe/cli/update_notifier/__init__.py +47 -0
- vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
- vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
- vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
- vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
- vibe/cli/update_notifier/ports/update_gateway.py +53 -0
- vibe/cli/update_notifier/update.py +139 -0
- vibe/cli/update_notifier/whats_new.py +49 -0
- vibe/core/__init__.py +5 -0
- vibe/core/agent_loop.py +1075 -0
- vibe/core/agents/__init__.py +31 -0
- vibe/core/agents/manager.py +165 -0
- vibe/core/agents/models.py +122 -0
- vibe/core/auth/__init__.py +6 -0
- vibe/core/auth/crypto.py +137 -0
- vibe/core/auth/github.py +178 -0
- vibe/core/autocompletion/__init__.py +0 -0
- vibe/core/autocompletion/completers.py +257 -0
- vibe/core/autocompletion/file_indexer/__init__.py +10 -0
- vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
- vibe/core/autocompletion/file_indexer/indexer.py +179 -0
- vibe/core/autocompletion/file_indexer/store.py +169 -0
- vibe/core/autocompletion/file_indexer/watcher.py +71 -0
- vibe/core/autocompletion/fuzzy.py +189 -0
- vibe/core/autocompletion/path_prompt.py +108 -0
- vibe/core/autocompletion/path_prompt_adapter.py +149 -0
- vibe/core/config.py +673 -0
- vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
- vibe/core/llm/__init__.py +0 -0
- vibe/core/llm/backend/anthropic.py +630 -0
- vibe/core/llm/backend/base.py +38 -0
- vibe/core/llm/backend/factory.py +7 -0
- vibe/core/llm/backend/generic.py +425 -0
- vibe/core/llm/backend/mistral.py +381 -0
- vibe/core/llm/backend/vertex.py +115 -0
- vibe/core/llm/exceptions.py +195 -0
- vibe/core/llm/format.py +184 -0
- vibe/core/llm/message_utils.py +24 -0
- vibe/core/llm/types.py +120 -0
- vibe/core/middleware.py +209 -0
- vibe/core/output_formatters.py +85 -0
- vibe/core/paths/__init__.py +0 -0
- vibe/core/paths/config_paths.py +68 -0
- vibe/core/paths/global_paths.py +40 -0
- vibe/core/programmatic.py +56 -0
- vibe/core/prompts/__init__.py +32 -0
- vibe/core/prompts/cli.md +111 -0
- vibe/core/prompts/compact.md +48 -0
- vibe/core/prompts/dangerous_directory.md +5 -0
- vibe/core/prompts/explore.md +50 -0
- vibe/core/prompts/project_context.md +8 -0
- vibe/core/prompts/tests.md +1 -0
- vibe/core/proxy_setup.py +65 -0
- vibe/core/session/session_loader.py +222 -0
- vibe/core/session/session_logger.py +318 -0
- vibe/core/session/session_migration.py +41 -0
- vibe/core/skills/__init__.py +7 -0
- vibe/core/skills/manager.py +132 -0
- vibe/core/skills/models.py +92 -0
- vibe/core/skills/parser.py +39 -0
- vibe/core/system_prompt.py +466 -0
- vibe/core/telemetry/__init__.py +0 -0
- vibe/core/telemetry/send.py +185 -0
- vibe/core/teleport/errors.py +9 -0
- vibe/core/teleport/git.py +196 -0
- vibe/core/teleport/nuage.py +180 -0
- vibe/core/teleport/teleport.py +208 -0
- vibe/core/teleport/types.py +54 -0
- vibe/core/tools/base.py +336 -0
- vibe/core/tools/builtins/ask_user_question.py +134 -0
- vibe/core/tools/builtins/bash.py +357 -0
- vibe/core/tools/builtins/grep.py +310 -0
- vibe/core/tools/builtins/prompts/__init__.py +0 -0
- vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
- vibe/core/tools/builtins/prompts/bash.md +73 -0
- vibe/core/tools/builtins/prompts/grep.md +4 -0
- vibe/core/tools/builtins/prompts/read_file.md +13 -0
- vibe/core/tools/builtins/prompts/search_replace.md +43 -0
- vibe/core/tools/builtins/prompts/task.md +24 -0
- vibe/core/tools/builtins/prompts/todo.md +199 -0
- vibe/core/tools/builtins/prompts/write_file.md +42 -0
- vibe/core/tools/builtins/read_file.py +222 -0
- vibe/core/tools/builtins/search_replace.py +456 -0
- vibe/core/tools/builtins/task.py +154 -0
- vibe/core/tools/builtins/todo.py +134 -0
- vibe/core/tools/builtins/write_file.py +160 -0
- vibe/core/tools/manager.py +341 -0
- vibe/core/tools/mcp.py +397 -0
- vibe/core/tools/ui.py +68 -0
- vibe/core/trusted_folders.py +86 -0
- vibe/core/types.py +405 -0
- vibe/core/utils.py +396 -0
- vibe/setup/onboarding/__init__.py +39 -0
- vibe/setup/onboarding/base.py +14 -0
- vibe/setup/onboarding/onboarding.tcss +134 -0
- vibe/setup/onboarding/screens/__init__.py +5 -0
- vibe/setup/onboarding/screens/api_key.py +200 -0
- vibe/setup/onboarding/screens/provider_selection.py +87 -0
- vibe/setup/onboarding/screens/welcome.py +136 -0
- vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
- vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
- vibe/whats_new.md +5 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from vibe.cli.textual_ui.windowing.history import (
|
|
4
|
+
build_history_widgets,
|
|
5
|
+
non_system_history_messages,
|
|
6
|
+
)
|
|
7
|
+
from vibe.cli.textual_ui.windowing.history_windowing import (
|
|
8
|
+
create_resume_plan,
|
|
9
|
+
should_resume_history,
|
|
10
|
+
sync_backfill_state,
|
|
11
|
+
)
|
|
12
|
+
from vibe.cli.textual_ui.windowing.state import (
|
|
13
|
+
HISTORY_RESUME_TAIL_MESSAGES,
|
|
14
|
+
LOAD_MORE_BATCH_SIZE,
|
|
15
|
+
HistoryLoadMoreManager,
|
|
16
|
+
SessionWindowing,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"HISTORY_RESUME_TAIL_MESSAGES",
|
|
21
|
+
"LOAD_MORE_BATCH_SIZE",
|
|
22
|
+
"HistoryLoadMoreManager",
|
|
23
|
+
"SessionWindowing",
|
|
24
|
+
"build_history_widgets",
|
|
25
|
+
"create_resume_plan",
|
|
26
|
+
"non_system_history_messages",
|
|
27
|
+
"should_resume_history",
|
|
28
|
+
"sync_backfill_state",
|
|
29
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from weakref import WeakKeyDictionary
|
|
4
|
+
|
|
5
|
+
from textual.widget import Widget
|
|
6
|
+
|
|
7
|
+
from vibe.cli.textual_ui.widgets.messages import (
|
|
8
|
+
AssistantMessage,
|
|
9
|
+
ReasoningMessage,
|
|
10
|
+
UserMessage,
|
|
11
|
+
)
|
|
12
|
+
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
|
13
|
+
from vibe.core.types import LLMMessage, Role
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def non_system_history_messages(messages: list[LLMMessage]) -> list[LLMMessage]:
|
|
17
|
+
return [msg for msg in messages if msg.role != Role.system]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_tool_call_map(messages: list[LLMMessage]) -> dict[str, str]:
|
|
21
|
+
tool_call_map: dict[str, str] = {}
|
|
22
|
+
for msg in messages:
|
|
23
|
+
if msg.role != Role.assistant or not msg.tool_calls:
|
|
24
|
+
continue
|
|
25
|
+
for tool_call in msg.tool_calls:
|
|
26
|
+
if tool_call.id:
|
|
27
|
+
tool_call_map[tool_call.id] = tool_call.function.name or "unknown"
|
|
28
|
+
return tool_call_map
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_history_widgets(
|
|
32
|
+
batch: list[LLMMessage],
|
|
33
|
+
tool_call_map: dict[str, str],
|
|
34
|
+
*,
|
|
35
|
+
start_index: int,
|
|
36
|
+
tools_collapsed: bool,
|
|
37
|
+
history_widget_indices: WeakKeyDictionary[Widget, int],
|
|
38
|
+
) -> list[Widget]:
|
|
39
|
+
widgets: list[Widget] = []
|
|
40
|
+
|
|
41
|
+
for offset, msg in enumerate(batch):
|
|
42
|
+
history_index = start_index + offset
|
|
43
|
+
match msg.role:
|
|
44
|
+
case Role.user:
|
|
45
|
+
if msg.content:
|
|
46
|
+
widget = UserMessage(msg.content)
|
|
47
|
+
widgets.append(widget)
|
|
48
|
+
history_widget_indices[widget] = history_index
|
|
49
|
+
|
|
50
|
+
case Role.assistant:
|
|
51
|
+
if msg.content:
|
|
52
|
+
assistant_widget = AssistantMessage(msg.content)
|
|
53
|
+
widgets.append(assistant_widget)
|
|
54
|
+
history_widget_indices[assistant_widget] = history_index
|
|
55
|
+
|
|
56
|
+
if msg.tool_calls:
|
|
57
|
+
for tool_call in msg.tool_calls:
|
|
58
|
+
tool_name = tool_call.function.name or "unknown"
|
|
59
|
+
if tool_call.id:
|
|
60
|
+
tool_call_map[tool_call.id] = tool_name
|
|
61
|
+
widget = ToolCallMessage(tool_name=tool_name)
|
|
62
|
+
widgets.append(widget)
|
|
63
|
+
history_widget_indices[widget] = history_index
|
|
64
|
+
|
|
65
|
+
case Role.tool:
|
|
66
|
+
tool_name = msg.name or tool_call_map.get(
|
|
67
|
+
msg.tool_call_id or "", "tool"
|
|
68
|
+
)
|
|
69
|
+
widget = ToolResultMessage(
|
|
70
|
+
tool_name=tool_name, content=msg.content, collapsed=tools_collapsed
|
|
71
|
+
)
|
|
72
|
+
widgets.append(widget)
|
|
73
|
+
history_widget_indices[widget] = history_index
|
|
74
|
+
|
|
75
|
+
return widgets
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def split_history_tail(
|
|
79
|
+
history_messages: list[LLMMessage], tail_size: int
|
|
80
|
+
) -> tuple[list[LLMMessage], list[LLMMessage], int]:
|
|
81
|
+
tail_messages = history_messages[-tail_size:]
|
|
82
|
+
backfill_messages = history_messages[:-tail_size]
|
|
83
|
+
tail_start_index = len(history_messages) - len(tail_messages)
|
|
84
|
+
return tail_messages, backfill_messages, tail_start_index
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def visible_history_indices(
|
|
88
|
+
children: list[Widget], history_widget_indices: WeakKeyDictionary[Widget, int]
|
|
89
|
+
) -> list[int]:
|
|
90
|
+
return [
|
|
91
|
+
idx
|
|
92
|
+
for child in children
|
|
93
|
+
if (idx := history_widget_indices.get(child)) is not None
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def visible_history_widgets_count(children: list[Widget]) -> int:
|
|
98
|
+
history_widget_types = (
|
|
99
|
+
UserMessage,
|
|
100
|
+
AssistantMessage,
|
|
101
|
+
ReasoningMessage,
|
|
102
|
+
ToolCallMessage,
|
|
103
|
+
ToolResultMessage,
|
|
104
|
+
)
|
|
105
|
+
return sum(isinstance(child, history_widget_types) for child in children)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from weakref import WeakKeyDictionary
|
|
5
|
+
|
|
6
|
+
from textual.widget import Widget
|
|
7
|
+
|
|
8
|
+
from vibe.cli.textual_ui.widgets.messages import WhatsNewMessage
|
|
9
|
+
from vibe.cli.textual_ui.windowing.history import (
|
|
10
|
+
build_tool_call_map,
|
|
11
|
+
split_history_tail,
|
|
12
|
+
visible_history_indices,
|
|
13
|
+
visible_history_widgets_count,
|
|
14
|
+
)
|
|
15
|
+
from vibe.cli.textual_ui.windowing.state import SessionWindowing
|
|
16
|
+
from vibe.core.types import LLMMessage
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class HistoryResumePlan:
|
|
21
|
+
tool_call_map: dict[str, str]
|
|
22
|
+
tail_messages: list[LLMMessage]
|
|
23
|
+
backfill_messages: list[LLMMessage]
|
|
24
|
+
tail_start_index: int
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def has_backfill(self) -> bool:
|
|
28
|
+
return bool(self.backfill_messages)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def should_resume_history(messages_children: list[Widget]) -> bool:
|
|
32
|
+
allowed_pre_existing_types = (WhatsNewMessage,)
|
|
33
|
+
return all(
|
|
34
|
+
isinstance(child, allowed_pre_existing_types) for child in messages_children
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_resume_plan(
|
|
39
|
+
history_messages: list[LLMMessage], tail_size: int
|
|
40
|
+
) -> HistoryResumePlan | None:
|
|
41
|
+
if not history_messages:
|
|
42
|
+
return None
|
|
43
|
+
tail_messages, backfill_messages, tail_start_index = split_history_tail(
|
|
44
|
+
history_messages, tail_size
|
|
45
|
+
)
|
|
46
|
+
return HistoryResumePlan(
|
|
47
|
+
tool_call_map=build_tool_call_map(history_messages),
|
|
48
|
+
tail_messages=tail_messages,
|
|
49
|
+
backfill_messages=backfill_messages,
|
|
50
|
+
tail_start_index=tail_start_index,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sync_backfill_state(
|
|
55
|
+
*,
|
|
56
|
+
history_messages: list[LLMMessage],
|
|
57
|
+
messages_children: list[Widget],
|
|
58
|
+
history_widget_indices: WeakKeyDictionary[Widget, int],
|
|
59
|
+
windowing: SessionWindowing,
|
|
60
|
+
) -> tuple[bool, dict[str, str] | None]:
|
|
61
|
+
if not history_messages:
|
|
62
|
+
windowing.reset()
|
|
63
|
+
return False, None
|
|
64
|
+
visible_indices = visible_history_indices(messages_children, history_widget_indices)
|
|
65
|
+
visible_history_widgets = visible_history_widgets_count(messages_children)
|
|
66
|
+
has_backfill = windowing.recompute_backfill(
|
|
67
|
+
history_messages,
|
|
68
|
+
visible_indices=visible_indices,
|
|
69
|
+
visible_history_widgets_count=visible_history_widgets,
|
|
70
|
+
)
|
|
71
|
+
return has_backfill, build_tool_call_map(history_messages)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from textual.widget import Widget
|
|
6
|
+
|
|
7
|
+
from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreMessage
|
|
8
|
+
from vibe.core.types import LLMMessage
|
|
9
|
+
|
|
10
|
+
HISTORY_RESUME_TAIL_MESSAGES = 20
|
|
11
|
+
LOAD_MORE_BATCH_SIZE = 10
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class LoadMoreBatch:
|
|
16
|
+
start_index: int
|
|
17
|
+
messages: list[LLMMessage]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SessionWindowing:
|
|
21
|
+
def __init__(self, load_more_batch_size: int) -> None:
|
|
22
|
+
self.load_more_batch_size = load_more_batch_size
|
|
23
|
+
self._backfill_messages: list[LLMMessage] = []
|
|
24
|
+
self._backfill_cursor = 0
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def remaining(self) -> int:
|
|
28
|
+
return self._backfill_cursor
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def has_backfill(self) -> bool:
|
|
32
|
+
return self._backfill_cursor > 0
|
|
33
|
+
|
|
34
|
+
def reset(self) -> None:
|
|
35
|
+
self._backfill_messages = []
|
|
36
|
+
self._backfill_cursor = 0
|
|
37
|
+
|
|
38
|
+
def set_backfill(self, backfill_messages: list[LLMMessage]) -> None:
|
|
39
|
+
self._backfill_messages = backfill_messages
|
|
40
|
+
self._backfill_cursor = len(backfill_messages)
|
|
41
|
+
|
|
42
|
+
def next_load_more_batch(self) -> LoadMoreBatch | None:
|
|
43
|
+
if self._backfill_cursor == 0:
|
|
44
|
+
return None
|
|
45
|
+
start_index = max(self._backfill_cursor - self.load_more_batch_size, 0)
|
|
46
|
+
batch = self._backfill_messages[start_index : self._backfill_cursor]
|
|
47
|
+
self._backfill_cursor = start_index
|
|
48
|
+
if not batch:
|
|
49
|
+
return None
|
|
50
|
+
return LoadMoreBatch(start_index=start_index, messages=batch)
|
|
51
|
+
|
|
52
|
+
def recompute_backfill(
|
|
53
|
+
self,
|
|
54
|
+
history_messages: list[LLMMessage],
|
|
55
|
+
visible_indices: list[int],
|
|
56
|
+
visible_history_widgets_count: int,
|
|
57
|
+
) -> bool:
|
|
58
|
+
if not history_messages:
|
|
59
|
+
self._backfill_messages = []
|
|
60
|
+
self._backfill_cursor = 0
|
|
61
|
+
return False
|
|
62
|
+
if visible_indices:
|
|
63
|
+
backfill_end = min(visible_indices)
|
|
64
|
+
else:
|
|
65
|
+
backfill_end = max(len(history_messages) - visible_history_widgets_count, 0)
|
|
66
|
+
self._backfill_messages = history_messages[:backfill_end]
|
|
67
|
+
self._backfill_cursor = len(self._backfill_messages)
|
|
68
|
+
return self._backfill_cursor > 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class HistoryLoadMoreManager:
|
|
72
|
+
def __init__(self) -> None:
|
|
73
|
+
self.widget: HistoryLoadMoreMessage | None = None
|
|
74
|
+
|
|
75
|
+
async def show(self, messages_area: Widget, remaining: int) -> None:
|
|
76
|
+
if self.widget is None:
|
|
77
|
+
widget = HistoryLoadMoreMessage()
|
|
78
|
+
await messages_area.mount(widget, before=0)
|
|
79
|
+
self.widget = widget
|
|
80
|
+
self.set_remaining(remaining)
|
|
81
|
+
|
|
82
|
+
async def hide(self) -> None:
|
|
83
|
+
if self.widget is None:
|
|
84
|
+
return
|
|
85
|
+
if self.widget.parent:
|
|
86
|
+
await self.widget.remove()
|
|
87
|
+
self.widget = None
|
|
88
|
+
|
|
89
|
+
async def set_visible(
|
|
90
|
+
self, messages_area: Widget, *, visible: bool, remaining: int
|
|
91
|
+
) -> None:
|
|
92
|
+
if visible:
|
|
93
|
+
await self.show(messages_area, remaining)
|
|
94
|
+
return
|
|
95
|
+
await self.hide()
|
|
96
|
+
|
|
97
|
+
def set_enabled(self, enabled: bool) -> None:
|
|
98
|
+
if self.widget is None:
|
|
99
|
+
return
|
|
100
|
+
self.widget.set_enabled(enabled)
|
|
101
|
+
|
|
102
|
+
def set_remaining(self, remaining: int) -> None:
|
|
103
|
+
if self.widget is None:
|
|
104
|
+
return
|
|
105
|
+
self.widget.set_remaining(remaining)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from vibe.cli.update_notifier.adapters.filesystem_update_cache_repository import (
|
|
4
|
+
FileSystemUpdateCacheRepository,
|
|
5
|
+
)
|
|
6
|
+
from vibe.cli.update_notifier.adapters.github_update_gateway import GitHubUpdateGateway
|
|
7
|
+
from vibe.cli.update_notifier.adapters.pypi_update_gateway import PyPIUpdateGateway
|
|
8
|
+
from vibe.cli.update_notifier.ports.update_cache_repository import (
|
|
9
|
+
UpdateCache,
|
|
10
|
+
UpdateCacheRepository,
|
|
11
|
+
)
|
|
12
|
+
from vibe.cli.update_notifier.ports.update_gateway import (
|
|
13
|
+
DEFAULT_GATEWAY_MESSAGES,
|
|
14
|
+
Update,
|
|
15
|
+
UpdateGateway,
|
|
16
|
+
UpdateGatewayCause,
|
|
17
|
+
UpdateGatewayError,
|
|
18
|
+
)
|
|
19
|
+
from vibe.cli.update_notifier.update import (
|
|
20
|
+
UpdateAvailability,
|
|
21
|
+
UpdateError,
|
|
22
|
+
get_update_if_available,
|
|
23
|
+
)
|
|
24
|
+
from vibe.cli.update_notifier.whats_new import (
|
|
25
|
+
load_whats_new_content,
|
|
26
|
+
mark_version_as_seen,
|
|
27
|
+
should_show_whats_new,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"DEFAULT_GATEWAY_MESSAGES",
|
|
32
|
+
"FileSystemUpdateCacheRepository",
|
|
33
|
+
"GitHubUpdateGateway",
|
|
34
|
+
"PyPIUpdateGateway",
|
|
35
|
+
"Update",
|
|
36
|
+
"UpdateAvailability",
|
|
37
|
+
"UpdateCache",
|
|
38
|
+
"UpdateCacheRepository",
|
|
39
|
+
"UpdateError",
|
|
40
|
+
"UpdateGateway",
|
|
41
|
+
"UpdateGatewayCause",
|
|
42
|
+
"UpdateGatewayError",
|
|
43
|
+
"get_update_if_available",
|
|
44
|
+
"load_whats_new_content",
|
|
45
|
+
"mark_version_as_seen",
|
|
46
|
+
"should_show_whats_new",
|
|
47
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from vibe.cli.update_notifier.ports.update_cache_repository import (
|
|
8
|
+
UpdateCache,
|
|
9
|
+
UpdateCacheRepository,
|
|
10
|
+
)
|
|
11
|
+
from vibe.core.paths.global_paths import VIBE_HOME
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FileSystemUpdateCacheRepository(UpdateCacheRepository):
|
|
15
|
+
def __init__(self, base_path: Path | str | None = None) -> None:
|
|
16
|
+
self._base_path = Path(base_path) if base_path is not None else VIBE_HOME.path
|
|
17
|
+
self._cache_file = self._base_path / "update_cache.json"
|
|
18
|
+
|
|
19
|
+
async def get(self) -> UpdateCache | None:
|
|
20
|
+
try:
|
|
21
|
+
content = await asyncio.to_thread(self._cache_file.read_text)
|
|
22
|
+
except OSError:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
data = json.loads(content)
|
|
27
|
+
latest_version = data.get("latest_version")
|
|
28
|
+
stored_at_timestamp = data.get("stored_at_timestamp")
|
|
29
|
+
seen_whats_new_version = data.get("seen_whats_new_version")
|
|
30
|
+
except (TypeError, json.JSONDecodeError):
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
if not isinstance(latest_version, str) or not isinstance(
|
|
34
|
+
stored_at_timestamp, int
|
|
35
|
+
):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
if (
|
|
39
|
+
not isinstance(seen_whats_new_version, str)
|
|
40
|
+
and seen_whats_new_version is not None
|
|
41
|
+
):
|
|
42
|
+
seen_whats_new_version = None
|
|
43
|
+
|
|
44
|
+
return UpdateCache(
|
|
45
|
+
latest_version=latest_version,
|
|
46
|
+
stored_at_timestamp=stored_at_timestamp,
|
|
47
|
+
seen_whats_new_version=seen_whats_new_version,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
async def set(self, update_cache: UpdateCache) -> None:
|
|
51
|
+
try:
|
|
52
|
+
payload = json.dumps({
|
|
53
|
+
"latest_version": update_cache.latest_version,
|
|
54
|
+
"stored_at_timestamp": update_cache.stored_at_timestamp,
|
|
55
|
+
"seen_whats_new_version": update_cache.seen_whats_new_version,
|
|
56
|
+
})
|
|
57
|
+
await asyncio.to_thread(self._cache_file.write_text, payload)
|
|
58
|
+
except OSError:
|
|
59
|
+
return None
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from vibe.cli.update_notifier.ports.update_gateway import (
|
|
6
|
+
Update,
|
|
7
|
+
UpdateGateway,
|
|
8
|
+
UpdateGatewayCause,
|
|
9
|
+
UpdateGatewayError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GitHubUpdateGateway(UpdateGateway):
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
owner: str,
|
|
17
|
+
repository: str,
|
|
18
|
+
*,
|
|
19
|
+
token: str | None = None,
|
|
20
|
+
client: httpx.AsyncClient | None = None,
|
|
21
|
+
timeout: float = 5.0,
|
|
22
|
+
base_url: str = "https://api.github.com",
|
|
23
|
+
) -> None:
|
|
24
|
+
self._owner = owner
|
|
25
|
+
self._repository = repository
|
|
26
|
+
self._token = token
|
|
27
|
+
self._client = client
|
|
28
|
+
self._timeout = timeout
|
|
29
|
+
self._base_url = base_url.rstrip("/")
|
|
30
|
+
|
|
31
|
+
async def fetch_update(self) -> Update | None:
|
|
32
|
+
headers = {
|
|
33
|
+
"Accept": "application/vnd.github+json",
|
|
34
|
+
"User-Agent": "mistral-vibe-update-notifier",
|
|
35
|
+
}
|
|
36
|
+
if self._token:
|
|
37
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
38
|
+
|
|
39
|
+
request_path = f"/repos/{self._owner}/{self._repository}/releases"
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
if self._client is not None:
|
|
43
|
+
response = await self._client.get(
|
|
44
|
+
f"{self._base_url}{request_path}",
|
|
45
|
+
headers=headers,
|
|
46
|
+
timeout=self._timeout,
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
async with httpx.AsyncClient(
|
|
50
|
+
base_url=self._base_url, timeout=self._timeout
|
|
51
|
+
) as client:
|
|
52
|
+
response = await client.get(request_path, headers=headers)
|
|
53
|
+
except httpx.RequestError as exc:
|
|
54
|
+
raise UpdateGatewayError(cause=UpdateGatewayCause.REQUEST_FAILED) from exc
|
|
55
|
+
|
|
56
|
+
rate_limit_remaining = response.headers.get("X-RateLimit-Remaining")
|
|
57
|
+
if response.status_code == httpx.codes.TOO_MANY_REQUESTS or (
|
|
58
|
+
rate_limit_remaining is not None and rate_limit_remaining == "0"
|
|
59
|
+
):
|
|
60
|
+
raise UpdateGatewayError(cause=UpdateGatewayCause.TOO_MANY_REQUESTS)
|
|
61
|
+
|
|
62
|
+
if response.status_code == httpx.codes.FORBIDDEN:
|
|
63
|
+
raise UpdateGatewayError(cause=UpdateGatewayCause.FORBIDDEN)
|
|
64
|
+
|
|
65
|
+
if response.status_code == httpx.codes.NOT_FOUND:
|
|
66
|
+
raise UpdateGatewayError(
|
|
67
|
+
cause=UpdateGatewayCause.NOT_FOUND,
|
|
68
|
+
message="Unable to fetch the GitHub releases. Did you export a GITHUB_TOKEN environment variable?",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if response.is_error:
|
|
72
|
+
raise UpdateGatewayError(cause=UpdateGatewayCause.ERROR_RESPONSE)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
data = response.json()
|
|
76
|
+
except ValueError as exc:
|
|
77
|
+
raise UpdateGatewayError(cause=UpdateGatewayCause.INVALID_RESPONSE) from exc
|
|
78
|
+
|
|
79
|
+
if not data:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
# pick the most recently published non-prerelease and non-draft release
|
|
83
|
+
# github "list releases" API most likely returns ordered results, but this is not guaranteed
|
|
84
|
+
for release in sorted(
|
|
85
|
+
data, key=lambda x: x.get("published_at") or "", reverse=True
|
|
86
|
+
):
|
|
87
|
+
if release.get("prerelease") or release.get("draft"):
|
|
88
|
+
continue
|
|
89
|
+
if version := _extract_version(release.get("tag_name")):
|
|
90
|
+
return Update(latest_version=version)
|
|
91
|
+
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _extract_version(tag_name: str | None) -> str | None:
|
|
96
|
+
if not tag_name:
|
|
97
|
+
return None
|
|
98
|
+
tag = tag_name.strip()
|
|
99
|
+
if not tag:
|
|
100
|
+
return None
|
|
101
|
+
return tag[1:] if tag.startswith(("v", "V")) else tag
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from packaging.utils import parse_sdist_filename, parse_wheel_filename
|
|
5
|
+
from packaging.version import InvalidVersion, Version
|
|
6
|
+
|
|
7
|
+
from vibe.cli.update_notifier.ports.update_gateway import (
|
|
8
|
+
Update,
|
|
9
|
+
UpdateGateway,
|
|
10
|
+
UpdateGatewayCause,
|
|
11
|
+
UpdateGatewayError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_STATUS_CAUSES: dict[int, UpdateGatewayCause] = {
|
|
15
|
+
httpx.codes.NOT_FOUND: UpdateGatewayCause.NOT_FOUND,
|
|
16
|
+
httpx.codes.FORBIDDEN: UpdateGatewayCause.FORBIDDEN,
|
|
17
|
+
httpx.codes.TOO_MANY_REQUESTS: UpdateGatewayCause.TOO_MANY_REQUESTS,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PyPIUpdateGateway(UpdateGateway):
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
project_name: str,
|
|
25
|
+
*,
|
|
26
|
+
client: httpx.AsyncClient | None = None,
|
|
27
|
+
timeout: float = 5.0,
|
|
28
|
+
base_url: str = "https://pypi.org",
|
|
29
|
+
) -> None:
|
|
30
|
+
self._project_name = project_name
|
|
31
|
+
self._client = client
|
|
32
|
+
self._timeout = timeout
|
|
33
|
+
self._base_url = base_url.rstrip("/")
|
|
34
|
+
|
|
35
|
+
async def fetch_update(self) -> Update | None:
|
|
36
|
+
response = await self._fetch()
|
|
37
|
+
self._raise_gateway_error_if_any(response)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
data = response.json()
|
|
41
|
+
except ValueError as exc:
|
|
42
|
+
raise UpdateGatewayError(cause=UpdateGatewayCause.INVALID_RESPONSE) from exc
|
|
43
|
+
|
|
44
|
+
versions = data.get("versions") or []
|
|
45
|
+
files = data.get("files") or []
|
|
46
|
+
|
|
47
|
+
non_yanked_versions: set[Version] = set()
|
|
48
|
+
for file in files:
|
|
49
|
+
if not isinstance(file, dict) or file.get("yanked") is True:
|
|
50
|
+
continue
|
|
51
|
+
filename = file.get("filename")
|
|
52
|
+
if not isinstance(filename, str):
|
|
53
|
+
continue
|
|
54
|
+
parsed_version = _parse_filename_version(filename)
|
|
55
|
+
if parsed_version is not None:
|
|
56
|
+
non_yanked_versions.add(parsed_version)
|
|
57
|
+
|
|
58
|
+
valid_versions: list[Version] = []
|
|
59
|
+
for raw_version in versions:
|
|
60
|
+
try:
|
|
61
|
+
valid_versions.append(Version(str(raw_version)))
|
|
62
|
+
except InvalidVersion:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
for version in sorted(valid_versions, reverse=True):
|
|
66
|
+
if version in non_yanked_versions:
|
|
67
|
+
return Update(latest_version=str(version))
|
|
68
|
+
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
async def _fetch(self) -> httpx.Response:
|
|
72
|
+
headers = {"Accept": "application/vnd.pypi.simple.v1+json"}
|
|
73
|
+
request_path = f"/simple/{self._project_name}/"
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
if self._client is not None:
|
|
77
|
+
return await self._client.get(
|
|
78
|
+
f"{self._base_url}{request_path}",
|
|
79
|
+
headers=headers,
|
|
80
|
+
timeout=self._timeout,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async with httpx.AsyncClient(
|
|
84
|
+
base_url=self._base_url, timeout=self._timeout
|
|
85
|
+
) as client:
|
|
86
|
+
return await client.get(request_path, headers=headers)
|
|
87
|
+
except httpx.RequestError as exc:
|
|
88
|
+
raise UpdateGatewayError(cause=UpdateGatewayCause.REQUEST_FAILED) from exc
|
|
89
|
+
|
|
90
|
+
def _raise_gateway_error_if_any(self, response: httpx.Response) -> None:
|
|
91
|
+
if response.status_code in _STATUS_CAUSES:
|
|
92
|
+
raise UpdateGatewayError(cause=_STATUS_CAUSES[response.status_code])
|
|
93
|
+
|
|
94
|
+
if response.is_error:
|
|
95
|
+
raise UpdateGatewayError(cause=UpdateGatewayCause.ERROR_RESPONSE)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_filename_version(filename: str) -> Version | None:
|
|
99
|
+
try:
|
|
100
|
+
_, version, *_ = parse_wheel_filename(filename)
|
|
101
|
+
return Version(str(version))
|
|
102
|
+
except Exception:
|
|
103
|
+
try:
|
|
104
|
+
_, sdist_version = parse_sdist_filename(filename)
|
|
105
|
+
return Version(str(sdist_version))
|
|
106
|
+
except Exception:
|
|
107
|
+
return None
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class UpdateCache:
|
|
9
|
+
latest_version: str
|
|
10
|
+
stored_at_timestamp: int
|
|
11
|
+
seen_whats_new_version: str | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UpdateCacheRepository(Protocol):
|
|
15
|
+
async def get(self) -> UpdateCache | None: ...
|
|
16
|
+
async def set(self, update_cache: UpdateCache) -> None: ...
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum, auto
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class Update:
|
|
10
|
+
latest_version: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UpdateGatewayCause(StrEnum):
|
|
14
|
+
@staticmethod
|
|
15
|
+
def _generate_next_value_(
|
|
16
|
+
name: str, start: int, count: int, last_values: list[str]
|
|
17
|
+
) -> str:
|
|
18
|
+
return name.lower()
|
|
19
|
+
|
|
20
|
+
TOO_MANY_REQUESTS = auto()
|
|
21
|
+
FORBIDDEN = auto()
|
|
22
|
+
NOT_FOUND = auto()
|
|
23
|
+
REQUEST_FAILED = auto()
|
|
24
|
+
ERROR_RESPONSE = auto()
|
|
25
|
+
INVALID_RESPONSE = auto()
|
|
26
|
+
UNKNOWN = auto()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
DEFAULT_GATEWAY_MESSAGES: dict[UpdateGatewayCause, str] = {
|
|
30
|
+
UpdateGatewayCause.TOO_MANY_REQUESTS: "Rate limit exceeded while checking for updates.",
|
|
31
|
+
UpdateGatewayCause.FORBIDDEN: "Request was forbidden while checking for updates.",
|
|
32
|
+
UpdateGatewayCause.NOT_FOUND: "Unable to fetch the releases. Please check your permissions.",
|
|
33
|
+
UpdateGatewayCause.REQUEST_FAILED: "Network error while checking for updates.",
|
|
34
|
+
UpdateGatewayCause.ERROR_RESPONSE: "Unexpected response received while checking for updates.",
|
|
35
|
+
UpdateGatewayCause.INVALID_RESPONSE: "Received an invalid response while checking for updates.",
|
|
36
|
+
UpdateGatewayCause.UNKNOWN: "Unable to determine whether an update is available.",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UpdateGatewayError(Exception):
|
|
41
|
+
def __init__(
|
|
42
|
+
self, *, cause: UpdateGatewayCause, message: str | None = None
|
|
43
|
+
) -> None:
|
|
44
|
+
self.cause = cause
|
|
45
|
+
self.user_message = message
|
|
46
|
+
detail = message or DEFAULT_GATEWAY_MESSAGES.get(
|
|
47
|
+
cause, DEFAULT_GATEWAY_MESSAGES[UpdateGatewayCause.UNKNOWN]
|
|
48
|
+
)
|
|
49
|
+
super().__init__(detail)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class UpdateGateway(Protocol):
|
|
53
|
+
async def fetch_update(self) -> Update | None: ...
|