diff --git a/elia_chat/chats_manager.py b/elia_chat/chats_manager.py index edc651c..804d039 100644 --- a/elia_chat/chats_manager.py +++ b/elia_chat/chats_manager.py @@ -28,6 +28,10 @@ async def get_chat(chat_id: int) -> ChatData: chat_dao = await ChatDao.from_id(chat_id) return chat_dao_to_chat_data(chat_dao) + @staticmethod + async def rename_chat(chat_id: int, new_title: str) -> None: + await ChatDao.rename_chat(chat_id, new_title) + @staticmethod async def get_messages( chat_id: int, @@ -66,7 +70,7 @@ async def create_chat(chat_data: ChatData) -> int: chat = ChatDao( model=lookup_key, title="", - started_at=datetime.datetime.now(datetime.UTC), + started_at=datetime.datetime.now(datetime.timezone.utc), ) session.add(chat) await session.commit() diff --git a/elia_chat/database/models.py b/elia_chat/database/models.py index 194f412..a260ba4 100644 --- a/elia_chat/database/models.py +++ b/elia_chat/database/models.py @@ -75,7 +75,7 @@ async def all() -> list["ChatDao"]: statement = ( select(ChatDao) .join(subquery, subquery.c.chat_id == ChatDao.id) - .where(ChatDao.archived == False) + .where(ChatDao.archived == False) # noqa: E712 .order_by(desc(subquery.c.max_timestamp)) .options(selectinload(ChatDao.messages)) ) @@ -92,3 +92,11 @@ async def from_id(chat_id: int) -> "ChatDao": ) result = await session.exec(statement) return result.one() + + @staticmethod + async def rename_chat(chat_id: int, new_title: str) -> None: + async with get_session() as session: + chat = await ChatDao.from_id(chat_id) + chat.title = new_title + session.add(chat) + await session.commit() diff --git a/elia_chat/elia.scss b/elia_chat/elia.scss index fa2b08a..dc05555 100644 --- a/elia_chat/elia.scss +++ b/elia_chat/elia.scss @@ -4,8 +4,8 @@ $main: #6C2BD9; $main-darken-1: #5521B5; $main-darken-2: #4A1D96; $main-border-text-color: greenyellow 70%; -$main-border-color: $main-lighten-1 50%; -$main-border-color-focus: $main-lighten-1 100%; +$main-border-color: $main-lighten-1 90%; +$main-border-color-focus: $main-lighten-2 100%; $left-border-trim: vkey $main-lighten-2 15%; @@ -327,37 +327,53 @@ OptionList > .option-list--option-hover-highlighted-disabled { background: $main 60%; } +RenameChat { + & > Vertical { + background: $background 0%; + height: auto; + & Input { + padding: 0 4; + border: none; + border-bottom: hkey $main-border-color; + border-top: hkey $main-border-color; + border-subtitle-color: $main-border-text-color; + border-subtitle-background: $background; + } + } + +} + ChatDetails { align: center middle; -} + & > #container { + width: 90%; + height: 85%; + background: $background; + padding: 1 2; + border: wide $main-border-color-focus; + border-title-color: $main-border-text-color; + border-title-background: $background; + border-title-style: b; + border-subtitle-color: $text-muted; + border-subtitle-background: $background; -ChatDetails > #container { - width: 90%; - height: 85%; - background: $background; - padding: 1 2; - border: wide $main-border-color-focus; - border-title-color: $main-border-text-color; - border-title-background: $background; - border-title-style: b; - border-subtitle-color: $text-muted; - border-subtitle-background: $background; + & Markdown { + padding: 0; + margin: 0; + } - & Markdown { - padding: 0; - margin: 0; - } + & .heading { + color: $text-muted; + } - & .heading { - color: $text-muted; - } + & .datum { + text-style: i; + } - & .datum { - text-style: i; } - } + MessageInfo #message-info-header { dock: top; width: 1fr; diff --git a/elia_chat/screens/chat_details.py b/elia_chat/screens/chat_details.py index 03cf9dd..fba6ff2 100644 --- a/elia_chat/screens/chat_details.py +++ b/elia_chat/screens/chat_details.py @@ -1,3 +1,4 @@ +from datetime import timezone from typing import TYPE_CHECKING, cast import humanize from textual.app import ComposeResult @@ -65,7 +66,9 @@ def compose(self) -> ComposeResult: yield Label("First message", classes="heading") if chat.create_timestamp: - create_timestamp = chat.create_timestamp.replace(tzinfo=None) + create_timestamp = chat.create_timestamp.replace( + tzinfo=timezone.utc + ) yield Label( f"{humanize.naturaltime(create_timestamp)}", classes="datum", @@ -75,11 +78,11 @@ def compose(self) -> ComposeResult: yield Rule() - update_time = chat.update_time + update_time = chat.update_time.replace(tzinfo=timezone.utc) yield Label("Updated at", classes="heading") if update_time: yield Label( - f"{humanize.naturaltime(chat.update_time.replace(tzinfo=None))}", + f"{humanize.naturaltime(chat.update_time)}", classes="datum", ) else: diff --git a/elia_chat/screens/help_screen.py b/elia_chat/screens/help_screen.py index 4231bdf..c6b6600 100644 --- a/elia_chat/screens/help_screen.py +++ b/elia_chat/screens/help_screen.py @@ -51,9 +51,10 @@ class HelpScreen(ModalScreen[None]): On the chat screen, pressing `up` and `down` will navigate through messages, but if you just wish to scroll a little, you can use `shift+up` and `shift+down`. -### The chat history +### The chat list - `up,down,k,j`: Navigate through chats. +- `a`: Archive the highlighted chat. - `pageup,pagedown`: Up/down a page. - `home,end`: Go to first/last chat. - `g,G`: Go to first/last chat. @@ -120,10 +121,11 @@ class HelpScreen(ModalScreen[None]): ### The chat screen -Press `shift+tab` to focus the latest message (or move the cursor `up` from (0, 0)). - You can use the arrow keys to move up and down through messages. +- `ctrl+r`: Rename the chat (or click the chat title). +- `f2`: View more information about the chat. + _With a message focused_: - `y,c`: Copy the raw Markdown of the message to the clipboard. @@ -142,7 +144,6 @@ class HelpScreen(ModalScreen[None]): - `G`: Focus the latest message. - `m`: Move focus to the prompt box. - `up,down,k,j`: Navigate through messages. -- `f2`: View more information about the chat. """ diff --git a/elia_chat/screens/rename_chat_screen.py b/elia_chat/screens/rename_chat_screen.py new file mode 100644 index 0000000..a23b2e7 --- /dev/null +++ b/elia_chat/screens/rename_chat_screen.py @@ -0,0 +1,25 @@ +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Input + + +class RenameChat(ModalScreen[str]): + BINDINGS = [ + Binding("escape", "app.pop_screen", "Cancel", key_display="esc"), + Binding("enter", "app.pop_screen", "Save"), + ] + + def compose(self) -> ComposeResult: + with Vertical(): + title_input = Input(placeholder="Enter a title...") + title_input.border_subtitle = ( + "[[white]enter[/]] Save [[white]esc[/]] Cancel" + ) + yield title_input + + @on(Input.Submitted) + def close_screen(self, event: Input.Submitted) -> None: + self.dismiss(event.value) diff --git a/elia_chat/widgets/chat.py b/elia_chat/widgets/chat.py index 7fc26d7..7aa29d3 100644 --- a/elia_chat/widgets/chat.py +++ b/elia_chat/widgets/chat.py @@ -18,7 +18,7 @@ from elia_chat.models import ChatData, ChatMessage from elia_chat.screens.chat_details import ChatDetails from elia_chat.widgets.agent_is_typing import AgentIsTyping -from elia_chat.widgets.chat_header import ChatHeader +from elia_chat.widgets.chat_header import ChatHeader, TitleStatic from elia_chat.widgets.prompt_input import PromptInput from elia_chat.widgets.chatbox import Chatbox @@ -37,6 +37,7 @@ class ChatPromptInput(PromptInput): class Chat(Widget): BINDINGS = [ + Binding("ctrl+r", "rename", "Rename", key_display="^r"), Binding("shift+down", "scroll_container_down", show=False), Binding("shift+up", "scroll_container_up", show=False), Binding( @@ -130,7 +131,7 @@ def restore_state_on_agent_failure(self, event: Chat.AgentResponseFailed) -> Non async def new_user_message(self, content: str) -> None: log.debug(f"User message submitted in chat {self.chat_data.id!r}: {content!r}") - now_utc = datetime.datetime.now(datetime.UTC) + now_utc = datetime.datetime.now(datetime.timezone.utc) user_message: ChatCompletionUserMessageParam = { "content": content, "role": "user", @@ -193,7 +194,7 @@ async def stream_agent_response(self) -> None: "content": "", "role": "assistant", } - now = datetime.datetime.now(datetime.UTC) + now = datetime.datetime.now(datetime.timezone.utc) message = ChatMessage(message=ai_message, model=model, timestamp=now) response_chatbox = Chatbox( @@ -268,6 +269,14 @@ async def on_cursor_up_from_prompt(self) -> None: def move_focus_to_prompt(self) -> None: self.query_one(ChatPromptInput).focus() + @on(TitleStatic.ChatRenamed) + async def handle_chat_rename(self, event: TitleStatic.ChatRenamed) -> None: + if event.chat_id == self.chat_data.id and event.new_title: + self.chat_data.title = event.new_title + header = self.query_one(ChatHeader) + header.update_header(self.chat_data, self.model) + await ChatsManager.rename_chat(event.chat_id, event.new_title) + def get_latest_chatbox(self) -> Chatbox: return self.query(Chatbox).last() @@ -277,6 +286,10 @@ def focus_latest_message(self) -> None: except NoMatches: pass + def action_rename(self) -> None: + title_static = self.query_one(TitleStatic) + title_static.begin_rename() + def action_focus_latest_message(self) -> None: self.focus_latest_message() diff --git a/elia_chat/widgets/chat_header.py b/elia_chat/widgets/chat_header.py index e9d4669..6a2f5c0 100644 --- a/elia_chat/widgets/chat_header.py +++ b/elia_chat/widgets/chat_header.py @@ -1,13 +1,58 @@ from __future__ import annotations +from dataclasses import dataclass +from rich.console import ConsoleRenderable, RichCast from rich.markup import escape from textual.app import ComposeResult +from textual.message import Message from textual.widget import Widget from textual.widgets import Static from elia_chat.config import EliaChatModel from elia_chat.models import ChatData +from elia_chat.screens.rename_chat_screen import RenameChat + + +class TitleStatic(Static): + @dataclass + class ChatRenamed(Message): + chat_id: int + new_title: str + + def __init__( + self, + chat_id: int, + renderable: ConsoleRenderable | RichCast | str = "", + *, + expand: bool = False, + shrink: bool = False, + markup: bool = True, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> None: + super().__init__( + renderable, + expand=expand, + shrink=shrink, + markup=markup, + name=name, + id=id, + classes=classes, + disabled=disabled, + ) + self.chat_id = chat_id + + def begin_rename(self) -> None: + self.app.push_screen(RenameChat(), callback=self.request_chat_rename) + + def action_rename_chat(self) -> None: + self.begin_rename() + + async def request_chat_rename(self, new_title: str) -> None: + self.post_message(self.ChatRenamed(self.chat_id, new_title)) class ChatHeader(Widget): @@ -36,12 +81,13 @@ def update_header(self, chat: ChatData, model: EliaChatModel): def title_static_content(self) -> str: chat = self.chat - return escape(chat.short_preview) if chat else "Empty chat" + content = escape(chat.title or chat.short_preview) if chat else "Empty chat" + return f"[@click=rename_chat]{content}[/]" def model_static_content(self) -> str: model = self.model return escape(model.display_name or model.name) if model else "Unknown model" def compose(self) -> ComposeResult: - yield Static(self.title_static_content(), id="title-static") + yield TitleStatic(self.chat.id, self.title_static_content(), id="title-static") yield Static(self.model_static_content(), id="model-static") diff --git a/elia_chat/widgets/chat_list.py b/elia_chat/widgets/chat_list.py index d666b6d..5952581 100644 --- a/elia_chat/widgets/chat_list.py +++ b/elia_chat/widgets/chat_list.py @@ -28,7 +28,7 @@ class ChatListItemRenderable: def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: - now = datetime.datetime.now(datetime.UTC) + now = datetime.datetime.now(datetime.timezone.utc) delta = now - self.chat.update_time time_ago = humanize.naturaltime(delta) time_ago_text = Text(time_ago, style="dim i") @@ -139,7 +139,10 @@ async def action_archive_chat(self) -> None: self.border_title = self.get_border_title() self.refresh() - self.app.notify(f"Chat [b]{chat_id!r}[/] archived") + self.app.notify( + item.chat.title or f"Chat [b]{chat_id!r}[/] archived.", + title="Chat archived", + ) def get_border_title(self) -> str: return f"History ({len(self.options)})" diff --git a/pyproject.toml b/pyproject.toml index 777ca97..b156945 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,22 +1,22 @@ [project] name = "elia_chat" -version = "1.6.1" +version = "1.7.0" description = "A powerful terminal user interface for interacting with large language models." authors = [ { name = "Darren Burns", email = "darrenb900@gmail.com" } ] dependencies = [ - "textual[syntax]==0.62.0", + "textual[syntax]==0.60.0", "sqlmodel>=0.0.9", "humanize>=4.6.0", "click>=8.1.6", "xdg-base-dirs>=6.0.1", "aiosqlite>=0.20.0", "click-default-group>=1.2.4", - "litellm==1.35.38", "greenlet>=3.0.3", "google-generativeai>=0.5.3", "pyperclip>=1.8.2", + "litellm>=1.37.19", ] readme = "README.md" requires-python = ">= 3.11" diff --git a/requirements-dev.lock b/requirements-dev.lock index f6ddd28..5b39ea0 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -17,7 +17,7 @@ aiosqlite==0.20.0 # via elia-chat annotated-types==0.6.0 # via pydantic -anyio==4.2.0 +anyio==4.3.0 # via httpx # via openai attrs==23.2.0 @@ -51,7 +51,7 @@ filelock==3.13.1 frozenlist==1.4.1 # via aiohttp # via aiosignal -fsspec==2024.3.1 +fsspec==2024.5.0 # via huggingface-hub google-ai-generativelanguage==0.6.3 # via google-generativeai @@ -76,6 +76,7 @@ googleapis-common-protos==1.63.0 # via grpcio-status greenlet==3.0.3 # via elia-chat + # via sqlalchemy grpcio==1.63.0 # via google-api-core # via grpcio-status @@ -83,14 +84,14 @@ grpcio-status==1.62.2 # via google-api-core h11==0.14.0 # via httpcore -httpcore==1.0.2 +httpcore==1.0.5 # via httpx httplib2==0.22.0 # via google-api-python-client # via google-auth-httplib2 -httpx==0.26.0 +httpx==0.27.0 # via openai -huggingface-hub==0.22.2 +huggingface-hub==0.23.1 # via tokenizers humanize==4.9.0 # via elia-chat @@ -103,11 +104,11 @@ idna==3.6 # via yarl importlib-metadata==7.1.0 # via litellm -jinja2==3.1.3 +jinja2==3.1.4 # via litellm linkify-it-py==2.0.3 # via markdown-it-py -litellm==1.35.38 +litellm==1.37.19 # via elia-chat markdown-it-py==3.0.0 # via mdit-py-plugins @@ -130,7 +131,7 @@ mypy-extensions==1.0.0 # via mypy nodeenv==1.8.0 # via pre-commit -openai==1.12.0 +openai==1.30.1 # via litellm packaging==23.2 # via black @@ -174,7 +175,7 @@ python-dotenv==1.0.1 pyyaml==6.0.1 # via huggingface-hub # via pre-commit -regex==2023.12.25 +regex==2024.5.15 # via tiktoken requests==2.31.0 # via google-api-core @@ -187,7 +188,7 @@ rsa==4.9 # via google-auth setuptools==69.0.3 # via nodeenv -sniffio==1.3.0 +sniffio==1.3.1 # via anyio # via httpx # via openai @@ -195,11 +196,11 @@ sqlalchemy==2.0.25 # via sqlmodel sqlmodel==0.0.14 # via elia-chat -textual==0.62.0 +textual==0.60.0 # via elia-chat # via textual-dev textual-dev==1.4.0 -tiktoken==0.5.2 +tiktoken==0.7.0 # via litellm tokenizers==0.19.1 # via litellm @@ -236,5 +237,5 @@ xdg-base-dirs==6.0.1 # via elia-chat yarl==1.9.4 # via aiohttp -zipp==3.18.1 +zipp==3.18.2 # via importlib-metadata diff --git a/requirements.lock b/requirements.lock index 68a175c..f18907a 100644 --- a/requirements.lock +++ b/requirements.lock @@ -16,7 +16,7 @@ aiosqlite==0.20.0 # via elia-chat annotated-types==0.6.0 # via pydantic -anyio==4.2.0 +anyio==4.3.0 # via httpx # via openai attrs==23.2.0 @@ -37,12 +37,12 @@ click-default-group==1.2.4 # via elia-chat distro==1.9.0 # via openai -filelock==3.13.4 +filelock==3.14.0 # via huggingface-hub frozenlist==1.4.1 # via aiohttp # via aiosignal -fsspec==2024.3.1 +fsspec==2024.5.0 # via huggingface-hub google-ai-generativelanguage==0.6.3 # via google-generativeai @@ -67,6 +67,7 @@ googleapis-common-protos==1.63.0 # via grpcio-status greenlet==3.0.3 # via elia-chat + # via sqlalchemy grpcio==1.63.0 # via google-api-core # via grpcio-status @@ -74,14 +75,14 @@ grpcio-status==1.62.2 # via google-api-core h11==0.14.0 # via httpcore -httpcore==1.0.2 +httpcore==1.0.5 # via httpx httplib2==0.22.0 # via google-api-python-client # via google-auth-httplib2 -httpx==0.26.0 +httpx==0.27.0 # via openai -huggingface-hub==0.22.2 +huggingface-hub==0.23.1 # via tokenizers humanize==4.9.0 # via elia-chat @@ -92,11 +93,11 @@ idna==3.6 # via yarl importlib-metadata==7.1.0 # via litellm -jinja2==3.1.3 +jinja2==3.1.4 # via litellm linkify-it-py==2.0.3 # via markdown-it-py -litellm==1.35.38 +litellm==1.37.19 # via elia-chat markdown-it-py==3.0.0 # via mdit-py-plugins @@ -111,9 +112,9 @@ mdurl==0.1.2 multidict==6.0.5 # via aiohttp # via yarl -openai==1.12.0 +openai==1.30.1 # via litellm -packaging==23.2 +packaging==24.0 # via huggingface-hub proto-plus==1.23.0 # via google-ai-generativelanguage @@ -146,7 +147,7 @@ python-dotenv==1.0.1 # via litellm pyyaml==6.0.1 # via huggingface-hub -regex==2023.12.25 +regex==2024.5.15 # via tiktoken requests==2.31.0 # via google-api-core @@ -157,7 +158,7 @@ rich==13.7.0 # via textual rsa==4.9 # via google-auth -sniffio==1.3.0 +sniffio==1.3.1 # via anyio # via httpx # via openai @@ -165,9 +166,9 @@ sqlalchemy==2.0.25 # via sqlmodel sqlmodel==0.0.14 # via elia-chat -textual==0.62.0 +textual==0.60.0 # via elia-chat -tiktoken==0.5.2 +tiktoken==0.7.0 # via litellm tokenizers==0.19.1 # via litellm @@ -199,5 +200,5 @@ xdg-base-dirs==6.0.1 # via elia-chat yarl==1.9.4 # via aiohttp -zipp==3.18.1 +zipp==3.18.2 # via importlib-metadata