v2.7.0 (#534)
Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai> Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
5ea69b547b
commit
6a50d1d521
49 changed files with 3726 additions and 99 deletions
|
|
@ -1,6 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import sys
|
||||
|
||||
ALT_KEY = "⌥" if sys.platform == "darwin" else "Alt"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -97,6 +100,11 @@ class CommandRegistry:
|
|||
description="Uninstall the Lean 4 agent",
|
||||
handler="_uninstall_lean",
|
||||
),
|
||||
"rewind": Command(
|
||||
aliases=frozenset(["/rewind"]),
|
||||
description="Rewind to a previous message",
|
||||
handler="_start_rewind_mode",
|
||||
),
|
||||
}
|
||||
|
||||
for command in excluded_commands:
|
||||
|
|
@ -125,6 +133,7 @@ class CommandRegistry:
|
|||
"- `Ctrl+G` Edit input in external editor",
|
||||
"- `Ctrl+O` Toggle tool output view",
|
||||
"- `Shift+Tab` Toggle auto-approve mode",
|
||||
f"- `{ALT_KEY}+↑↓` / `Ctrl+P/N` Rewind to previous/next message",
|
||||
"",
|
||||
"### Special Features",
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
|||
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
|
||||
from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
from vibe.cli.textual_ui.widgets.rewind_app import RewindApp
|
||||
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
|
||||
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
|
||||
|
|
@ -114,6 +115,7 @@ from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
|||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths import HISTORY_FILE
|
||||
from vibe.core.rewind import RewindError
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.teleport.types import (
|
||||
TeleportAuthCompleteEvent,
|
||||
|
|
@ -166,6 +168,7 @@ class BottomApp(StrEnum):
|
|||
ModelPicker = auto()
|
||||
ProxySetup = auto()
|
||||
Question = auto()
|
||||
Rewind = auto()
|
||||
SessionPicker = auto()
|
||||
Voice = auto()
|
||||
|
||||
|
|
@ -272,6 +275,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
Binding(
|
||||
"shift+down", "scroll_chat_down", "Scroll Down", show=False, priority=True
|
||||
),
|
||||
Binding("alt+up", "rewind_prev", "Rewind Previous", show=False, priority=True),
|
||||
Binding("ctrl+p", "rewind_prev", "Rewind Previous", show=False, priority=True),
|
||||
Binding("alt+down", "rewind_next", "Rewind Next", show=False, priority=True),
|
||||
Binding("ctrl+n", "rewind_next", "Rewind Next", show=False, priority=True),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
|
|
@ -347,6 +354,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._speak_task: asyncio.Task[None] | None = None
|
||||
self._cancel_summary: Callable[[], bool] | None = None
|
||||
|
||||
self._rewind_mode = False
|
||||
self._rewind_highlighted_widget: UserMessage | None = None
|
||||
|
||||
@property
|
||||
def config(self) -> VibeConfig:
|
||||
return self.agent_loop.config
|
||||
|
|
@ -750,7 +760,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
|
||||
async def _handle_user_message(self, message: str) -> None:
|
||||
user_message = UserMessage(message)
|
||||
# message_index is where the user message will land in agent_loop.messages
|
||||
# (checkpoint is created in agent_loop.act())
|
||||
message_index = len(self.agent_loop.messages)
|
||||
user_message = UserMessage(message, message_index=message_index)
|
||||
|
||||
await self._mount_and_scroll(user_message)
|
||||
if self.agent_loop.telemetry_client.is_active():
|
||||
|
|
@ -1471,6 +1484,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._switch_from_input(QuestionApp(args=args), scroll=True)
|
||||
|
||||
async def _switch_to_input_app(self) -> None:
|
||||
if self._chat_input_container:
|
||||
self._chat_input_container.disabled = False
|
||||
self._chat_input_container.display = True
|
||||
self._current_bottom_app = BottomApp.Input
|
||||
self._refresh_profile_widgets()
|
||||
|
||||
for app in BottomApp:
|
||||
if app != BottomApp.Input:
|
||||
try:
|
||||
|
|
@ -1479,10 +1498,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
pass
|
||||
|
||||
if self._chat_input_container:
|
||||
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:
|
||||
|
|
@ -1505,6 +1520,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.query_one(QuestionApp).focus()
|
||||
case BottomApp.SessionPicker:
|
||||
self.query_one(SessionPickerApp).focus()
|
||||
case BottomApp.Rewind:
|
||||
self.query_one(RewindApp).focus()
|
||||
case BottomApp.Voice:
|
||||
self.query_one(VoiceApp).focus()
|
||||
case app:
|
||||
|
|
@ -1562,6 +1579,196 @@ class VibeApp(App): # noqa: PLR0904
|
|||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
# --- Rewind mode ---
|
||||
|
||||
def _get_user_message_widgets(self) -> list[UserMessage]:
|
||||
"""Return all UserMessage widgets currently visible in #messages."""
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
return [
|
||||
child for child in messages_area.children if isinstance(child, UserMessage)
|
||||
]
|
||||
|
||||
def _start_rewind_mode(self) -> None:
|
||||
self.action_rewind_prev()
|
||||
|
||||
def action_rewind_prev(self) -> None:
|
||||
if self._agent_running:
|
||||
return
|
||||
|
||||
user_widgets = self._get_user_message_widgets()
|
||||
if not user_widgets:
|
||||
return
|
||||
|
||||
if not self._rewind_mode:
|
||||
self._rewind_mode = True
|
||||
target = user_widgets[-1]
|
||||
elif self._rewind_highlighted_widget is not None:
|
||||
try:
|
||||
idx = user_widgets.index(self._rewind_highlighted_widget)
|
||||
except ValueError:
|
||||
idx = len(user_widgets)
|
||||
if idx <= 0:
|
||||
self.run_worker(self._rewind_prev_at_top(), exclusive=False)
|
||||
return
|
||||
target = user_widgets[idx - 1]
|
||||
else:
|
||||
target = user_widgets[-1]
|
||||
|
||||
self.run_worker(self._select_rewind_widget(target), exclusive=False)
|
||||
|
||||
async def _rewind_prev_at_top(self) -> None:
|
||||
"""Handle alt+up when already at the topmost visible user message."""
|
||||
if self._load_more.widget is not None and self._windowing.has_backfill:
|
||||
await self.on_history_load_more_requested(HistoryLoadMoreRequested())
|
||||
user_widgets = self._get_user_message_widgets()
|
||||
if user_widgets and self._rewind_highlighted_widget is not None:
|
||||
# Find the current highlighted widget in the refreshed list
|
||||
# and select the one above it
|
||||
try:
|
||||
idx = user_widgets.index(self._rewind_highlighted_widget)
|
||||
except ValueError:
|
||||
idx = 0
|
||||
if idx > 0:
|
||||
await self._select_rewind_widget(user_widgets[idx - 1])
|
||||
return
|
||||
# No load more or already first message: scroll to top
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
self.call_after_refresh(chat.scroll_home, animate=False)
|
||||
|
||||
def action_rewind_next(self) -> None:
|
||||
if not self._rewind_mode:
|
||||
return
|
||||
|
||||
if self._rewind_highlighted_widget is None:
|
||||
return
|
||||
|
||||
user_widgets = self._get_user_message_widgets()
|
||||
try:
|
||||
idx = user_widgets.index(self._rewind_highlighted_widget)
|
||||
except ValueError:
|
||||
return
|
||||
if idx >= len(user_widgets) - 1:
|
||||
return
|
||||
|
||||
self.run_worker(
|
||||
self._select_rewind_widget(user_widgets[idx + 1]), exclusive=False
|
||||
)
|
||||
|
||||
async def _select_rewind_widget(self, widget: UserMessage) -> None:
|
||||
"""Highlight the given user message widget and show the rewind panel."""
|
||||
if self._rewind_highlighted_widget is not None:
|
||||
self._rewind_highlighted_widget.remove_class("rewind-selected")
|
||||
|
||||
widget.add_class("rewind-selected")
|
||||
self._rewind_highlighted_widget = widget
|
||||
|
||||
msg_index = widget.message_index
|
||||
has_file_changes = (
|
||||
msg_index is not None
|
||||
and self.agent_loop.rewind_manager.has_file_changes_at(msg_index)
|
||||
)
|
||||
|
||||
await self._switch_to_rewind_app(
|
||||
widget.get_content(), has_file_changes=has_file_changes
|
||||
)
|
||||
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
self.call_after_refresh(chat.scroll_to_widget, widget, animate=False, top=True)
|
||||
|
||||
async def _switch_to_rewind_app(
|
||||
self, message_preview: str, *, has_file_changes: bool
|
||||
) -> None:
|
||||
"""Show the rewind action panel at the bottom."""
|
||||
if self._current_bottom_app == BottomApp.Rewind:
|
||||
# Reuse existing widget if the option set hasn't changed
|
||||
try:
|
||||
existing = self.query_one(RewindApp)
|
||||
if existing.has_file_changes == has_file_changes:
|
||||
existing.update_preview(message_preview)
|
||||
return
|
||||
await existing.remove()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
rewind_app = RewindApp(
|
||||
message_preview=message_preview, has_file_changes=has_file_changes
|
||||
)
|
||||
bottom_container = self.query_one("#bottom-app-container")
|
||||
self._current_bottom_app = BottomApp.Rewind
|
||||
await bottom_container.mount(rewind_app)
|
||||
self.call_after_refresh(rewind_app.focus)
|
||||
else:
|
||||
rewind_app = RewindApp(
|
||||
message_preview=message_preview, has_file_changes=has_file_changes
|
||||
)
|
||||
await self._switch_from_input(rewind_app)
|
||||
|
||||
def _clear_rewind_state(self) -> None:
|
||||
if self._rewind_highlighted_widget is not None:
|
||||
self._rewind_highlighted_widget.remove_class("rewind-selected")
|
||||
self._rewind_highlighted_widget = None
|
||||
self._rewind_mode = False
|
||||
|
||||
async def _exit_rewind_mode(self) -> None:
|
||||
"""Exit rewind mode and restore the input panel."""
|
||||
self._clear_rewind_state()
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_rewind_app_rewind_with_restore(
|
||||
self, message: RewindApp.RewindWithRestore
|
||||
) -> None:
|
||||
await self._execute_rewind(restore_files=True)
|
||||
|
||||
async def on_rewind_app_rewind_without_restore(
|
||||
self, message: RewindApp.RewindWithoutRestore
|
||||
) -> None:
|
||||
await self._execute_rewind(restore_files=False)
|
||||
|
||||
async def _execute_rewind(self, *, restore_files: bool) -> None:
|
||||
"""Fork the session at the selected user message."""
|
||||
if not self._rewind_mode or self._rewind_highlighted_widget is None:
|
||||
return
|
||||
|
||||
target_widget = self._rewind_highlighted_widget
|
||||
msg_index = target_widget.message_index
|
||||
|
||||
if msg_index is None:
|
||||
return
|
||||
|
||||
try:
|
||||
(
|
||||
message_content,
|
||||
restore_errors,
|
||||
) = await self.agent_loop.rewind_manager.rewind_to_message(
|
||||
msg_index, restore_files=restore_files
|
||||
)
|
||||
except RewindError as exc:
|
||||
self.notify(str(exc), severity="error")
|
||||
return
|
||||
|
||||
for error in restore_errors:
|
||||
self.notify(error, severity="warning")
|
||||
|
||||
# Remove UI widgets from the selected message onward
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
children = list(messages_area.children)
|
||||
try:
|
||||
target_idx = children.index(target_widget)
|
||||
except ValueError:
|
||||
target_idx = len(children)
|
||||
to_remove = children[target_idx:]
|
||||
if to_remove:
|
||||
await messages_area.remove_children(to_remove)
|
||||
|
||||
self._clear_rewind_state()
|
||||
|
||||
# Switch back to input and pre-fill with the original message
|
||||
await self._switch_to_input_app()
|
||||
if self._chat_input_container:
|
||||
self._chat_input_container.value = message_content
|
||||
|
||||
# --- End rewind mode ---
|
||||
|
||||
def _handle_input_app_escape(self) -> None:
|
||||
try:
|
||||
input_widget = self.query_one(ChatInputContainer)
|
||||
|
|
@ -1614,6 +1821,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_session_picker_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.Rewind:
|
||||
self.run_worker(self._exit_rewind_mode(), exclusive=False)
|
||||
self._last_escape_time = None
|
||||
return
|
||||
|
||||
if (
|
||||
self._current_bottom_app == BottomApp.Input
|
||||
and self._last_escape_time is not None
|
||||
|
|
|
|||
|
|
@ -529,6 +529,24 @@ StatusMessage {
|
|||
height: auto;
|
||||
}
|
||||
|
||||
.tool-result-widget Markdown,
|
||||
.tool-approval-widget Markdown {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tool-result-widget Markdown > *,
|
||||
.tool-approval-widget Markdown > * {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tool-result-widget Markdown MarkdownFence,
|
||||
.tool-approval-widget Markdown MarkdownFence {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tool-call-detail,
|
||||
.tool-result-detail {
|
||||
height: auto;
|
||||
|
|
@ -1111,6 +1129,51 @@ FeedbackBar {
|
|||
margin-left: 1;
|
||||
}
|
||||
|
||||
.user-message.rewind-selected {
|
||||
.user-message-content {
|
||||
text-style: bold reverse;
|
||||
}
|
||||
}
|
||||
|
||||
#rewind-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#rewind-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.rewind-title {
|
||||
height: auto;
|
||||
text-style: bold;
|
||||
color: ansi_blue;
|
||||
}
|
||||
|
||||
.rewind-option {
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
}
|
||||
|
||||
.rewind-cursor-selected {
|
||||
color: ansi_blue;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.rewind-option-unselected {
|
||||
color: ansi_default;
|
||||
}
|
||||
|
||||
.rewind-help {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
#feedback-text {
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
|
|
|||
|
|
@ -38,11 +38,17 @@ class ExpandingBorder(NonSelectableStatic):
|
|||
|
||||
|
||||
class UserMessage(Static):
|
||||
def __init__(self, content: str, pending: bool = False) -> None:
|
||||
def __init__(
|
||||
self, content: str, pending: bool = False, message_index: int | None = None
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.add_class("user-message")
|
||||
self._content = content
|
||||
self._pending = pending
|
||||
self.message_index: int | None = message_index
|
||||
|
||||
def get_content(self) -> str:
|
||||
return self._content
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="user-message-container"):
|
||||
|
|
|
|||
147
vibe/cli/textual_ui/widgets/rewind_app.py
Normal file
147
vibe/cli/textual_ui/widgets/rewind_app.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum, auto
|
||||
from typing import ClassVar
|
||||
|
||||
from textual import events
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.commands import ALT_KEY
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
|
||||
class _RewindAction(StrEnum):
|
||||
EDIT_AND_RESTORE = auto()
|
||||
EDIT_ONLY = auto()
|
||||
|
||||
|
||||
class RewindApp(Container):
|
||||
"""Bottom panel widget for rewind mode actions."""
|
||||
|
||||
can_focus = True
|
||||
can_focus_children = False
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("up", "move_up", "Up", show=False),
|
||||
Binding("down", "move_down", "Down", show=False),
|
||||
Binding("enter", "select", "Select", show=False),
|
||||
Binding("1", "select_1", "Option 1", show=False),
|
||||
Binding("2", "select_2", "Option 2", show=False),
|
||||
]
|
||||
|
||||
class RewindWithRestore(Message):
|
||||
"""User chose to edit the message and restore files."""
|
||||
|
||||
class RewindWithoutRestore(Message):
|
||||
"""User chose to edit the message without restoring files."""
|
||||
|
||||
def __init__(self, message_preview: str, *, has_file_changes: bool) -> None:
|
||||
super().__init__(id="rewind-app")
|
||||
self._message_preview = message_preview
|
||||
self._has_file_changes = has_file_changes
|
||||
self.selected_option = 0
|
||||
self.option_widgets: list[Static] = []
|
||||
self._title_widget: NoMarkupStatic | None = None
|
||||
self._options = self._build_options()
|
||||
|
||||
def _build_options(self) -> list[tuple[str, _RewindAction]]:
|
||||
options: list[tuple[str, _RewindAction]] = []
|
||||
if self._has_file_changes:
|
||||
options.append((
|
||||
"Edit & restore files to this point",
|
||||
_RewindAction.EDIT_AND_RESTORE,
|
||||
))
|
||||
edit_only_label = (
|
||||
"Edit without restoring files"
|
||||
if self._has_file_changes
|
||||
else "Edit message from here"
|
||||
)
|
||||
options.append((edit_only_label, _RewindAction.EDIT_ONLY))
|
||||
return options
|
||||
|
||||
@property
|
||||
def has_file_changes(self) -> bool:
|
||||
return self._has_file_changes
|
||||
|
||||
def update_preview(self, message_preview: str) -> None:
|
||||
self._message_preview = message_preview
|
||||
if self._title_widget is not None:
|
||||
self._title_widget.update(f"Rewind to: {message_preview[:80]}")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="rewind-content"):
|
||||
self._title_widget = NoMarkupStatic(
|
||||
f"Rewind to: {self._message_preview[:80]}", classes="rewind-title"
|
||||
)
|
||||
yield self._title_widget
|
||||
yield NoMarkupStatic("")
|
||||
for _ in range(len(self._options)):
|
||||
widget = NoMarkupStatic("", classes="rewind-option")
|
||||
self.option_widgets.append(widget)
|
||||
yield widget
|
||||
yield NoMarkupStatic("")
|
||||
yield NoMarkupStatic(
|
||||
f"{ALT_KEY}+↑↓ or Ctrl+P/N browse messages ↑↓ pick option Enter confirm ESC cancel",
|
||||
classes="rewind-help",
|
||||
)
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
self._update_options()
|
||||
self.focus()
|
||||
|
||||
def _update_options(self) -> None:
|
||||
for idx, ((text, _action), widget) in enumerate(
|
||||
zip(self._options, self.option_widgets, strict=True)
|
||||
):
|
||||
is_selected = idx == self.selected_option
|
||||
cursor = "› " if is_selected else " "
|
||||
option_text = f"{cursor}{idx + 1}. {text}"
|
||||
|
||||
widget.update(option_text)
|
||||
|
||||
widget.remove_class("rewind-cursor-selected")
|
||||
widget.remove_class("rewind-option-unselected")
|
||||
|
||||
if is_selected:
|
||||
widget.add_class("rewind-cursor-selected")
|
||||
else:
|
||||
widget.add_class("rewind-option-unselected")
|
||||
|
||||
def _option_count(self) -> int:
|
||||
return len(self._options)
|
||||
|
||||
def action_move_up(self) -> None:
|
||||
self.selected_option = (self.selected_option - 1) % self._option_count()
|
||||
self._update_options()
|
||||
|
||||
def action_move_down(self) -> None:
|
||||
self.selected_option = (self.selected_option + 1) % self._option_count()
|
||||
self._update_options()
|
||||
|
||||
def action_select(self) -> None:
|
||||
self._handle_selection(self.selected_option)
|
||||
|
||||
def action_select_1(self) -> None:
|
||||
if self._option_count() >= 1:
|
||||
self.selected_option = 0
|
||||
self._handle_selection(0)
|
||||
|
||||
def action_select_2(self) -> None:
|
||||
if self._option_count() >= 2: # noqa: PLR2004
|
||||
self.selected_option = 1
|
||||
self._handle_selection(1)
|
||||
|
||||
def _handle_selection(self, option: int) -> None:
|
||||
_, action = self._options[option]
|
||||
match action:
|
||||
case _RewindAction.EDIT_AND_RESTORE:
|
||||
self.post_message(self.RewindWithRestore())
|
||||
case _RewindAction.EDIT_ONLY:
|
||||
self.post_message(self.RewindWithoutRestore())
|
||||
|
||||
def on_blur(self, event: events.Blur) -> None:
|
||||
self.call_after_refresh(self.focus)
|
||||
|
|
@ -98,8 +98,7 @@ class SessionPickerApp(Container):
|
|||
with Vertical(id="sessionpicker-content"):
|
||||
yield OptionList(*options, id="sessionpicker-options")
|
||||
yield NoMarkupStatic(
|
||||
"Up/Down Navigate Enter Select Esc Cancel",
|
||||
classes="sessionpicker-help",
|
||||
"↑↓ Navigate Enter Select Esc Cancel", classes="sessionpicker-help"
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
|
|
|
|||
|
|
@ -39,12 +39,17 @@ def build_history_widgets(
|
|||
) -> list[Widget]:
|
||||
widgets: list[Widget] = []
|
||||
|
||||
for offset, msg in enumerate(batch):
|
||||
history_index = start_index + offset
|
||||
for history_index, msg in zip(
|
||||
range(start_index, start_index + len(batch)), batch, strict=True
|
||||
):
|
||||
if msg.injected:
|
||||
continue
|
||||
match msg.role:
|
||||
case Role.user:
|
||||
if msg.content:
|
||||
widget = UserMessage(msg.content)
|
||||
# history_index is 0-based in non-system messages;
|
||||
# agent_loop.messages index = history_index + 1 (system msg at 0)
|
||||
widget = UserMessage(msg.content, message_index=history_index + 1)
|
||||
widgets.append(widget)
|
||||
history_widget_indices[widget] = history_index
|
||||
|
||||
|
|
|
|||
|
|
@ -60,9 +60,23 @@ class SessionWindowing:
|
|||
self._backfill_cursor = 0
|
||||
return False
|
||||
if visible_indices:
|
||||
backfill_end = min(visible_indices)
|
||||
oldest_widget = min(visible_indices)
|
||||
# _backfill_cursor is the first history index in the loaded window (tail + prepends).
|
||||
# Oldest widgets can start *after* that when the tail begins with injected-only slots
|
||||
# (no widgets). Using min(visible_indices) alone would shrink the backfill prefix into
|
||||
# the already-mounted tail and break load-more batch start_index alignment.
|
||||
if oldest_widget > self._backfill_cursor:
|
||||
prefix = history_messages[self._backfill_cursor : oldest_widget]
|
||||
backfill_end = (
|
||||
self._backfill_cursor
|
||||
if prefix and all(m.injected for m in prefix)
|
||||
else oldest_widget
|
||||
)
|
||||
else:
|
||||
backfill_end = self._backfill_cursor
|
||||
else:
|
||||
backfill_end = max(len(history_messages) - visible_history_widgets_count, 0)
|
||||
backfill_end = min(backfill_end, len(history_messages))
|
||||
self._backfill_messages = history_messages[:backfill_end]
|
||||
self._backfill_cursor = len(self._backfill_messages)
|
||||
return self._backfill_cursor > 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue