v2.4.0 (#470)
Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com> Co-authored-by: Antoine W <antoine.wronka@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
5d2e01a6d7
commit
dd372ce494
89 changed files with 2086 additions and 596 deletions
|
|
@ -151,6 +151,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
|
||||
stdin_prompt = get_prompt_from_stdin()
|
||||
if args.prompt is not None:
|
||||
config.disabled_tools = [*config.disabled_tools, "ask_user_question"]
|
||||
programmatic_prompt = args.prompt or stdin_prompt
|
||||
if not programmatic_prompt:
|
||||
print(
|
||||
|
|
|
|||
|
|
@ -34,13 +34,7 @@ class HttpWhoAmIGateway:
|
|||
raise WhoAmIGatewayError(f"Unexpected status {response.status_code}")
|
||||
|
||||
payload = _safe_json(response) or {}
|
||||
return WhoAmIResponse(
|
||||
is_pro_plan=_parse_bool(payload.get("is_pro_plan")),
|
||||
advertise_pro_plan=_parse_bool(payload.get("advertise_pro_plan")),
|
||||
prompt_switching_to_pro_plan=_parse_bool(
|
||||
payload.get("prompt_switching_to_pro_plan")
|
||||
),
|
||||
)
|
||||
return WhoAmIResponse.from_payload(payload)
|
||||
|
||||
|
||||
def _safe_json(response: httpx.Response) -> Mapping[str, object] | None:
|
||||
|
|
@ -49,19 +43,3 @@ def _safe_json(response: httpx.Response) -> Mapping[str, object] | None:
|
|||
except ValueError:
|
||||
return None
|
||||
return cast(Mapping[str, object], data) if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _parse_bool(value: object | None) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
match value.strip().lower():
|
||||
case "true":
|
||||
return True
|
||||
case "false":
|
||||
return False
|
||||
case _:
|
||||
raise WhoAmIGatewayError("Invalid boolean string in whoami response")
|
||||
raise WhoAmIGatewayError("Invalid boolean value in whoami response")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from os import getenv
|
||||
|
||||
|
|
@ -8,6 +7,7 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
|
|||
WhoAmIGateway,
|
||||
WhoAmIGatewayError,
|
||||
WhoAmIGatewayUnauthorized,
|
||||
WhoAmIPlanType,
|
||||
WhoAmIResponse,
|
||||
)
|
||||
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, Backend, ProviderConfig
|
||||
|
|
@ -19,51 +19,50 @@ UPGRADE_URL = CONSOLE_CLI_URL
|
|||
SWITCH_TO_PRO_KEY_URL = CONSOLE_CLI_URL
|
||||
|
||||
|
||||
class PlanOfferAction(StrEnum):
|
||||
NONE = "none"
|
||||
UPGRADE = "upgrade"
|
||||
SWITCH_TO_PRO_KEY = "switch_to_pro_key"
|
||||
class PlanInfo:
|
||||
plan_type: WhoAmIPlanType
|
||||
plan_name: str
|
||||
prompt_switching_to_pro_plan: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plan_type: WhoAmIPlanType,
|
||||
plan_name: str = "",
|
||||
prompt_switching_to_pro_plan: bool = False,
|
||||
) -> None:
|
||||
self.plan_type = plan_type
|
||||
self.plan_name = plan_name
|
||||
self.prompt_switching_to_pro_plan = prompt_switching_to_pro_plan
|
||||
|
||||
@classmethod
|
||||
def from_response(cls, response: WhoAmIResponse) -> PlanInfo:
|
||||
return cls(
|
||||
plan_type=response.plan_type,
|
||||
plan_name=response.plan_name,
|
||||
prompt_switching_to_pro_plan=response.prompt_switching_to_pro_plan,
|
||||
)
|
||||
|
||||
def is_paid_api_plan(self) -> bool:
|
||||
return self.plan_type == WhoAmIPlanType.API and not self.is_free_api_plan()
|
||||
|
||||
def is_free_api_plan(self) -> bool:
|
||||
return self.plan_type == WhoAmIPlanType.API and "FREE" in self.plan_name.upper()
|
||||
|
||||
def is_chat_pro_plan(self) -> bool:
|
||||
return self.plan_type == WhoAmIPlanType.CHAT
|
||||
|
||||
|
||||
ACTION_TO_URL: dict[PlanOfferAction, str] = {
|
||||
PlanOfferAction.UPGRADE: UPGRADE_URL,
|
||||
PlanOfferAction.SWITCH_TO_PRO_KEY: SWITCH_TO_PRO_KEY_URL,
|
||||
}
|
||||
|
||||
|
||||
class PlanType(StrEnum):
|
||||
FREE = "free"
|
||||
PRO = "pro"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
async def decide_plan_offer(
|
||||
api_key: str | None, gateway: WhoAmIGateway
|
||||
) -> tuple[PlanOfferAction, PlanType]:
|
||||
async def decide_plan_offer(api_key: str | None, gateway: WhoAmIGateway) -> PlanInfo:
|
||||
if not api_key:
|
||||
return PlanOfferAction.UPGRADE, PlanType.FREE
|
||||
return PlanInfo(WhoAmIPlanType.UNKNOWN)
|
||||
try:
|
||||
response = await gateway.whoami(api_key)
|
||||
return PlanInfo.from_response(response)
|
||||
except WhoAmIGatewayUnauthorized:
|
||||
return PlanOfferAction.UPGRADE, PlanType.FREE
|
||||
return PlanInfo(WhoAmIPlanType.UNAUTHORIZED)
|
||||
except WhoAmIGatewayError:
|
||||
logger.warning("Failed to fetch plan status.", exc_info=True)
|
||||
return PlanOfferAction.NONE, PlanType.UNKNOWN
|
||||
return _action_and_plan_from_response(response)
|
||||
|
||||
|
||||
def _action_and_plan_from_response(
|
||||
response: WhoAmIResponse,
|
||||
) -> tuple[PlanOfferAction, PlanType]:
|
||||
match response:
|
||||
case WhoAmIResponse(is_pro_plan=True):
|
||||
return PlanOfferAction.NONE, PlanType.PRO
|
||||
case WhoAmIResponse(prompt_switching_to_pro_plan=True):
|
||||
return PlanOfferAction.SWITCH_TO_PRO_KEY, PlanType.PRO
|
||||
case WhoAmIResponse(advertise_pro_plan=True):
|
||||
return PlanOfferAction.UPGRADE, PlanType.FREE
|
||||
case _:
|
||||
return PlanOfferAction.NONE, PlanType.UNKNOWN
|
||||
return PlanInfo(WhoAmIPlanType.UNKNOWN)
|
||||
|
||||
|
||||
def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
|
||||
|
|
@ -75,13 +74,22 @@ def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
|
|||
return getenv(api_env_key)
|
||||
|
||||
|
||||
def plan_offer_cta(action: PlanOfferAction) -> str | None:
|
||||
if action is PlanOfferAction.NONE:
|
||||
def plan_offer_cta(payload: PlanInfo | None) -> str | None:
|
||||
if not payload:
|
||||
return
|
||||
url = ACTION_TO_URL[action]
|
||||
match action:
|
||||
case PlanOfferAction.UPGRADE:
|
||||
text = f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({url})"
|
||||
case PlanOfferAction.SWITCH_TO_PRO_KEY:
|
||||
text = f"### Switch to your [Le Chat Pro API key]({url})"
|
||||
return text
|
||||
if payload.prompt_switching_to_pro_plan:
|
||||
return f"### Switch to your [Le Chat Pro API key]({SWITCH_TO_PRO_KEY_URL})"
|
||||
if payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}:
|
||||
return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({UPGRADE_URL})"
|
||||
|
||||
|
||||
def plan_title(payload: PlanInfo | None) -> str | None:
|
||||
if not payload:
|
||||
return None
|
||||
if payload.is_chat_pro_plan():
|
||||
return "[Subscription] Pro"
|
||||
if payload.is_free_api_plan():
|
||||
return "[API] Experiment plan"
|
||||
if payload.is_paid_api_plan():
|
||||
return "[API] Scale plan"
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,15 +1,58 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
|
||||
class WhoAmIPlanType(StrEnum):
|
||||
API = "API"
|
||||
CHAT = "CHAT"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
UNAUTHORIZED = "UNAUTHORIZED"
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, value: str) -> WhoAmIPlanType:
|
||||
try:
|
||||
return cls(value.strip().upper())
|
||||
except ValueError:
|
||||
return cls.UNKNOWN
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WhoAmIResponse:
|
||||
is_pro_plan: bool
|
||||
advertise_pro_plan: bool
|
||||
plan_type: WhoAmIPlanType
|
||||
plan_name: str
|
||||
prompt_switching_to_pro_plan: bool
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: Mapping[str, object]) -> WhoAmIResponse:
|
||||
plan_type = payload.get("plan_type")
|
||||
plan_name = payload.get("plan_name")
|
||||
if not isinstance(plan_type, str) or not isinstance(plan_name, str):
|
||||
raise WhoAmIGatewayError(f"Invalid whoami response: {payload}")
|
||||
return cls(
|
||||
plan_type=WhoAmIPlanType.from_string(plan_type),
|
||||
plan_name=plan_name.strip(),
|
||||
prompt_switching_to_pro_plan=_parse_bool(
|
||||
payload.get("prompt_switching_to_pro_plan")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_bool(value: object | None) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
try:
|
||||
return TypeAdapter(bool).validate_python(value)
|
||||
except ValidationError as e:
|
||||
raise WhoAmIGatewayError(
|
||||
f"Invalid boolean value in whoami response: {value}"
|
||||
) from e
|
||||
|
||||
|
||||
class WhoAmIGatewayUnauthorized(Exception):
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -25,12 +25,13 @@ from vibe.cli.clipboard import copy_selection_to_clipboard
|
|||
from vibe.cli.commands import CommandRegistry
|
||||
from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
|
||||
from vibe.cli.plan_offer.decide_plan_offer import (
|
||||
PlanType,
|
||||
PlanInfo,
|
||||
decide_plan_offer,
|
||||
plan_offer_cta,
|
||||
plan_title,
|
||||
resolve_api_key_for_plan,
|
||||
)
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway, WhoAmIPlanType
|
||||
from vibe.cli.terminal_setup import setup_terminal
|
||||
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
|
||||
from vibe.cli.textual_ui.notifications import (
|
||||
|
|
@ -89,7 +90,7 @@ from vibe.cli.update_notifier.update import do_update
|
|||
from vibe.core.agent_loop import AgentLoop, TeleportError
|
||||
from vibe.core.agents import AgentProfile
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.config import Backend, VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths.config_paths import HISTORY_FILE
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
|
|
@ -270,6 +271,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._cached_chat: ChatScroll | None = None
|
||||
self._cached_loading_area: Widget | None = None
|
||||
self._switch_agent_generation = 0
|
||||
self._plan_info: PlanInfo | None = None
|
||||
|
||||
@property
|
||||
def config(self) -> VibeConfig:
|
||||
|
|
@ -319,7 +321,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
def update_context_progress(stats: AgentStats) -> None:
|
||||
context_progress.tokens = TokenState(
|
||||
max_tokens=self.config.auto_compact_threshold,
|
||||
max_tokens=self.config.get_active_model().auto_compact_threshold,
|
||||
current_tokens=stats.context_tokens,
|
||||
)
|
||||
|
||||
|
|
@ -332,6 +334,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
chat_input_container = self.query_one(ChatInputContainer)
|
||||
chat_input_container.focus_input()
|
||||
await self._resolve_plan()
|
||||
await self._show_dangerous_directory_warning()
|
||||
await self._resume_history_from_messages()
|
||||
await self._check_and_show_whats_new()
|
||||
|
|
@ -728,10 +731,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
message = str(e)
|
||||
if isinstance(e, RateLimitError):
|
||||
if self.plan_type == PlanType.FREE:
|
||||
message = "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access."
|
||||
else:
|
||||
message = "Rate limits exceeded. Please wait a moment before trying again."
|
||||
message = self._rate_limit_message()
|
||||
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(message, collapsed=self._tools_collapsed)
|
||||
|
|
@ -748,6 +748,15 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._refresh_windowing_from_history()
|
||||
self._terminal_notifier.notify(NotificationContext.COMPLETE)
|
||||
|
||||
def _rate_limit_message(self) -> str:
|
||||
upgrade_to_pro = self._plan_info and self._plan_info.plan_type in {
|
||||
WhoAmIPlanType.API,
|
||||
WhoAmIPlanType.UNAUTHORIZED,
|
||||
}
|
||||
if upgrade_to_pro:
|
||||
return "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access."
|
||||
return "Rate limits exceeded. Please wait a moment before trying again."
|
||||
|
||||
async def _teleport_command(self) -> None:
|
||||
await self._handle_teleport_command(show_message=False)
|
||||
|
||||
|
|
@ -1003,9 +1012,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
base_config = VibeConfig.load()
|
||||
|
||||
await self.agent_loop.reload_with_initial_messages(base_config=base_config)
|
||||
await self._resolve_plan()
|
||||
|
||||
if self._banner:
|
||||
self._banner.set_state(base_config, self.agent_loop.skill_manager)
|
||||
self._banner.set_state(
|
||||
base_config,
|
||||
self.agent_loop.skill_manager,
|
||||
plan_title(self._plan_info),
|
||||
)
|
||||
await self._mount_and_scroll(UserCommandMessage("Configuration reloaded."))
|
||||
except Exception as e:
|
||||
await self._mount_and_scroll(
|
||||
|
|
@ -1198,6 +1212,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._chat_input_container.disabled = False
|
||||
self._chat_input_container.display = True
|
||||
self._current_bottom_app = BottomApp.Input
|
||||
self._refresh_profile_widgets()
|
||||
self.call_after_refresh(self._chat_input_container.focus_input)
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
if chat.is_at_bottom:
|
||||
|
|
@ -1371,7 +1386,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
def _refresh_banner(self) -> None:
|
||||
if self._banner:
|
||||
self._banner.set_state(self.config, self.agent_loop.skill_manager)
|
||||
self._banner.set_state(
|
||||
self.config, self.agent_loop.skill_manager, plan_title(self._plan_info)
|
||||
)
|
||||
|
||||
def _update_profile_widgets(self, profile: AgentProfile) -> None:
|
||||
if self._chat_input_container:
|
||||
|
|
@ -1460,7 +1477,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
content = load_whats_new_content()
|
||||
if content is not None:
|
||||
whats_new_message = WhatsNewMessage(content)
|
||||
plan_offer = await self._plan_offer_cta()
|
||||
plan_offer = plan_offer_cta(self._plan_info)
|
||||
if plan_offer is not None:
|
||||
whats_new_message = WhatsNewMessage(f"{content}\n\n{plan_offer}")
|
||||
if self._history_widget_indices:
|
||||
|
|
@ -1474,23 +1491,21 @@ class VibeApp(App): # noqa: PLR0904
|
|||
chat.anchor()
|
||||
await mark_version_as_seen(self._current_version, self._update_cache_repository)
|
||||
|
||||
async def _plan_offer_cta(self) -> str | None:
|
||||
self.plan_type = PlanType.UNKNOWN
|
||||
|
||||
async def _resolve_plan(self) -> None:
|
||||
if self._plan_offer_gateway is None:
|
||||
self._plan_info = None
|
||||
return
|
||||
|
||||
try:
|
||||
active_model = self.config.get_active_model()
|
||||
provider = self.config.get_provider_for_model(active_model)
|
||||
|
||||
api_key = resolve_api_key_for_plan(provider)
|
||||
action, plan_type = await decide_plan_offer(
|
||||
api_key, self._plan_offer_gateway
|
||||
)
|
||||
if provider.backend != Backend.MISTRAL:
|
||||
self._plan_info = None
|
||||
return
|
||||
|
||||
self.plan_type = plan_type
|
||||
return plan_offer_cta(action)
|
||||
api_key = resolve_api_key_for_plan(provider)
|
||||
self._plan_info = await decide_plan_offer(api_key, self._plan_offer_gateway)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Plan-offer check failed (%s).", type(exc).__name__, exc_info=True
|
||||
|
|
|
|||
|
|
@ -840,6 +840,10 @@ StatusMessage {
|
|||
height: auto;
|
||||
}
|
||||
|
||||
#question-content.question-content-docked {
|
||||
dock: bottom;
|
||||
}
|
||||
|
||||
.question-tabs {
|
||||
height: auto;
|
||||
color: ansi_blue;
|
||||
|
|
@ -900,6 +904,28 @@ StatusMessage {
|
|||
margin-top: 1;
|
||||
}
|
||||
|
||||
.question-content-preview {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 50vh;
|
||||
border: none;
|
||||
border-left: wide ansi_bright_black;
|
||||
padding: 0 0 0 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.question-content-preview-text {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
& > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ExpandingBorder {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
|
|
@ -959,6 +985,10 @@ ContextProgress {
|
|||
width: auto;
|
||||
}
|
||||
|
||||
#banner-user-plan {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
#banner-meta-counts {
|
||||
width: auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class BannerState:
|
|||
models_count: int = 0
|
||||
mcp_servers_count: int = 0
|
||||
skills_count: int = 0
|
||||
plan_description: str | None = None
|
||||
|
||||
|
||||
class Banner(Static):
|
||||
|
|
@ -36,6 +37,7 @@ class Banner(Static):
|
|||
models_count=len(config.models),
|
||||
mcp_servers_count=len(config.mcp_servers),
|
||||
skills_count=len(skill_manager.available_skills),
|
||||
plan_description=None,
|
||||
)
|
||||
self._animated = not config.disable_welcome_banner_animation
|
||||
|
||||
|
|
@ -49,6 +51,7 @@ class Banner(Static):
|
|||
yield NoMarkupStatic(" ", classes="banner-spacer")
|
||||
yield NoMarkupStatic(f"v{__version__} · ", classes="banner-meta")
|
||||
yield NoMarkupStatic("", id="banner-model")
|
||||
yield NoMarkupStatic("", id="banner-user-plan")
|
||||
with Horizontal(classes="banner-line"):
|
||||
yield NoMarkupStatic("", id="banner-meta-counts")
|
||||
with Horizontal(classes="banner-line"):
|
||||
|
|
@ -64,17 +67,24 @@ class Banner(Static):
|
|||
self.query_one("#banner-meta-counts", NoMarkupStatic).update(
|
||||
self._format_meta_counts()
|
||||
)
|
||||
self.query_one("#banner-user-plan", NoMarkupStatic).update(self._format_plan())
|
||||
|
||||
def freeze_animation(self) -> None:
|
||||
if self._animated:
|
||||
self.query_one(PetitChat).freeze_animation()
|
||||
|
||||
def set_state(self, config: VibeConfig, skill_manager: SkillManager) -> None:
|
||||
def set_state(
|
||||
self,
|
||||
config: VibeConfig,
|
||||
skill_manager: SkillManager,
|
||||
plan_description: str | None = None,
|
||||
) -> None:
|
||||
self.state = BannerState(
|
||||
active_model=config.active_model,
|
||||
models_count=len(config.models),
|
||||
mcp_servers_count=len(config.mcp_servers),
|
||||
skills_count=len(skill_manager.available_skills),
|
||||
plan_description=plan_description,
|
||||
)
|
||||
|
||||
def _format_meta_counts(self) -> str:
|
||||
|
|
@ -83,3 +93,10 @@ class Banner(Static):
|
|||
f" · {self.state.mcp_servers_count} MCP server{'s' if self.state.mcp_servers_count != 1 else ''}"
|
||||
f" · {self.state.skills_count} skill{'s' if self.state.skills_count != 1 else ''}"
|
||||
)
|
||||
|
||||
def _format_plan(self) -> str:
|
||||
return (
|
||||
""
|
||||
if self.state.plan_description is None
|
||||
else f" · {self.state.plan_description}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -113,9 +113,10 @@ class ChatTextArea(TextArea):
|
|||
self.get_full_text(), self._get_full_cursor_offset()
|
||||
)
|
||||
|
||||
def _reset_prefix(self) -> None:
|
||||
def _reset_prefix(self, *, clear_last_used: bool = True) -> None:
|
||||
self._history_prefix = None
|
||||
self._last_used_prefix = None
|
||||
if clear_last_used:
|
||||
self._last_used_prefix = None
|
||||
|
||||
def _mark_cursor_moved_if_needed(self) -> None:
|
||||
if (
|
||||
|
|
@ -124,7 +125,7 @@ class ChatTextArea(TextArea):
|
|||
and self.cursor_location != self._cursor_pos_after_load
|
||||
):
|
||||
self._cursor_moved_since_load = True
|
||||
self._reset_prefix()
|
||||
self._reset_prefix(clear_last_used=False)
|
||||
|
||||
def _get_prefix_up_to_cursor(self) -> str:
|
||||
cursor_row, cursor_col = self.cursor_location
|
||||
|
|
@ -137,8 +138,17 @@ class ChatTextArea(TextArea):
|
|||
return ""
|
||||
|
||||
def _handle_history_up(self) -> bool:
|
||||
cursor_row, cursor_col = self.cursor_location
|
||||
if cursor_row == 0:
|
||||
_, cursor_col = self.cursor_location
|
||||
history_loaded_and_cursor_unmoved = (
|
||||
self._cursor_pos_after_load is not None
|
||||
and not self._cursor_moved_since_load
|
||||
)
|
||||
should_intercept = (
|
||||
self.navigator.is_first_wrapped_line(self.cursor_location)
|
||||
or history_loaded_and_cursor_unmoved
|
||||
)
|
||||
|
||||
if should_intercept:
|
||||
if self._history_prefix is not None and cursor_col != self._last_cursor_col:
|
||||
self._reset_prefix()
|
||||
self._last_cursor_col = 0
|
||||
|
|
@ -151,26 +161,45 @@ class ChatTextArea(TextArea):
|
|||
return True
|
||||
return False
|
||||
|
||||
def _handle_history_down(self) -> bool:
|
||||
cursor_row, cursor_col = self.cursor_location
|
||||
total_lines = self.text.count("\n") + 1
|
||||
def _is_on_loaded_history_entry(self) -> bool:
|
||||
return self._cursor_pos_after_load is not None
|
||||
|
||||
on_first_line_unmoved = cursor_row == 0 and not self._cursor_moved_since_load
|
||||
on_last_line = cursor_row == total_lines - 1
|
||||
def _has_history_prefix(self) -> bool:
|
||||
return self._history_prefix is not None
|
||||
|
||||
should_intercept = (
|
||||
on_first_line_unmoved and self._history_prefix is not None
|
||||
) or on_last_line
|
||||
def _has_history_navigation_context(self) -> bool:
|
||||
return self._is_on_loaded_history_entry() or self._has_history_prefix()
|
||||
|
||||
if not should_intercept:
|
||||
def _should_intercept_history_down(self) -> bool:
|
||||
history_loaded_and_cursor_unmoved = (
|
||||
self._is_on_loaded_history_entry() and not self._cursor_moved_since_load
|
||||
)
|
||||
if history_loaded_and_cursor_unmoved and self._has_history_prefix():
|
||||
return True
|
||||
|
||||
if not self.navigator.is_last_wrapped_line(self.cursor_location):
|
||||
return False
|
||||
|
||||
return self._has_history_navigation_context()
|
||||
|
||||
def _handle_history_down(self) -> bool:
|
||||
_, cursor_col = self.cursor_location
|
||||
|
||||
if not self._should_intercept_history_down():
|
||||
return False
|
||||
|
||||
navigating_loaded_history = self._is_on_loaded_history_entry()
|
||||
|
||||
if self._history_prefix is not None and cursor_col != self._last_cursor_col:
|
||||
self._reset_prefix()
|
||||
if not navigating_loaded_history:
|
||||
self._reset_prefix()
|
||||
self._last_cursor_col = 0
|
||||
|
||||
if self._history_prefix is None:
|
||||
self._history_prefix = self._get_prefix_up_to_cursor()
|
||||
if navigating_loaded_history and self._last_used_prefix is not None:
|
||||
self._history_prefix = self._last_used_prefix
|
||||
else:
|
||||
self._history_prefix = self._get_prefix_up_to_cursor()
|
||||
|
||||
self._navigating_history = True
|
||||
self.post_message(self.HistoryNext(self._history_prefix))
|
||||
|
|
@ -238,6 +267,11 @@ class ChatTextArea(TextArea):
|
|||
event.stop()
|
||||
return
|
||||
|
||||
# Work around VS Code 1.110+ sending space as CSI u (\x1b[32u),
|
||||
# which Textual parses as Key("space", character=None, is_printable=False).
|
||||
if event.key == "space" and event.character is None:
|
||||
event.character = " "
|
||||
|
||||
await super()._on_key(event)
|
||||
self._mark_cursor_moved_if_needed()
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@ from typing import TYPE_CHECKING, ClassVar
|
|||
from textual import events
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Input
|
||||
|
||||
from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -108,7 +109,16 @@ class QuestionApp(Container):
|
|||
)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="question-content"):
|
||||
if self.args.content_preview:
|
||||
with VerticalScroll(classes="question-content-preview"):
|
||||
yield AnsiMarkdown(
|
||||
self.args.content_preview, classes="question-content-preview-text"
|
||||
)
|
||||
|
||||
question_content_classes = (
|
||||
"question-content-docked" if self.args.content_preview else ""
|
||||
)
|
||||
with Vertical(id="question-content", classes=question_content_classes):
|
||||
if len(self.questions) > 1:
|
||||
self.tabs_widget = NoMarkupStatic("", classes="question-tabs")
|
||||
yield self.tabs_widget
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue