Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.6.0]

### Added
- `Ctrl+L Z` in compose now supports bulk attachment selection. After choosing a directory, Bor opens a keyboard-friendly picker where `Space` toggles files, `Enter` attaches the selected set, `Esc` cancels, and `Ctrl+A` toggles all files.
- Added `attachments.force_kitty_support` to force Kitty graphics support even when `TERM` is not `xterm-kitty`.

### Fixed
- Fixed message viewer freezing with 100% CPU on messages containing a zero-width character (e.g. U+200B) at the start of an overlong unbreakable "word" such as a 300+ char URL. Root cause was an infinite loop in `rich.cells.split_graphemes` in Rich ≤14.3.2; bumped the minimum required versions to `textual>=8.2.7` and `rich>=15.0.0`, which include the fix.
- Removed an unused `mu view --format=sexp` subprocess call from `MuInterface.view()` that ran on every message open with a 60-second timeout; the result was never read (the email file is parsed directly with Python's `email` module).
- Eliminated a whole class of `MarkupError` crashes in the message viewer (e.g. on messages with `View [ https://... ]`-style links or `[bracketed]` text in the subject). The message body and header rendering no longer concatenate user content into Rich/Textual markup strings; instead they build a `rich.text.Text` programmatically with `.append()`, so user-supplied content can never be re-parsed as markup. Clickable URL links are preserved via styled spans rather than `[link="..."]` tags.
- Fixed a message-view `MarkupError` crash when opening messages whose plain-text body contains a bracketed URL such as `[https://...]`; Bor now keeps the visible bracket while generating valid Textual link markup.
- `Ctrl+K` now behaves more like emacs in editable fields across the app: it still kills from the cursor to the end of the current input line, and now also copies the killed text to the system clipboard for later paste/yank.
- The shared file-path prompt used by compose now has bash-like tab completion behavior: a single match completes immediately, multiple matches are shown without forcing one choice, and the input expands only to the longest shared prefix.
- Fixed a brittle `BorApp` type assertion in tab widgets that could fail in some runtime and test contexts even when the app instance was valid.

## [0.5.0]

### Added
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,16 @@ bor
| Ctrl+L L | Send message |
| Ctrl+L D | Save draft |
| Ctrl+L X | Cancel |
| Ctrl+L A | Attach file |
| Ctrl+L Z | Attach many files from a directory |
| Ctrl+I | Insert file |
| Ctrl+A | Attach file |

`Ctrl+L A` opens a file-path prompt with bash-like tab completion: one match completes
immediately, multiple matches are listed without forcing a choice, and the input expands only
to the longest shared prefix.

`Ctrl+L Z` first prompts for a directory with the same tab-completion behavior, then opens a file picker where
`Space` marks files, `Enter` attaches the marked set, `Esc` cancels, and `Ctrl+A` toggles all.

### Attachments

Expand Down
2 changes: 1 addition & 1 deletion agents/implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ Alt+N switching handled via unicode character detection as in terminal_editor.py
- Ctrl+L X: Cancel
- Ctrl+S: Search
- Ctrl+I: Insert file
- Ctrl+A: Attach file
- Ctrl+L A: Attach file
- Tab: Autocomplete

## Message Flags Display
Expand Down
2 changes: 2 additions & 0 deletions bor.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ open_links_in_browser = true
save_directory = "~/Downloads"
# Use kitty icat for image preview (if available)
use_kitty_icat = true
# Force kitty graphics support even when TERM is not xterm-kitty
force_kitty_support = false

[editor]
# External editor for compose (optional)
Expand Down
2 changes: 1 addition & 1 deletion bor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
and textual for the user interface.
"""

__version__ = "0.5.0"
__version__ = "0.6.0"
__author__ = "Bor Development Team"
1 change: 1 addition & 0 deletions bor/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ class AttachmentsConfig:
"""Attachments handling configuration."""
save_directory: str = "~/Downloads"
use_kitty_icat: bool = True
force_kitty_support: bool = False


@dataclass
Expand Down
52 changes: 52 additions & 0 deletions bor/editing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Shared text-editing helpers for Bor widgets."""

from __future__ import annotations

from typing import Optional


try:
import pyperclip
except ImportError: # pragma: no cover - optional dependency
pyperclip = None


def copy_to_clipboard(text: str, append: bool = False) -> None:
"""Copy text to the system clipboard when clipboard support is available."""
if pyperclip is None or not text:
return
if append:
try:
text = pyperclip.paste() + text
except Exception:
pass
pyperclip.copy(text)


def kill_input_line(value: str, cursor_position: int) -> tuple[str, int, str]:
"""Kill from the cursor to the end of a single-line input."""
cursor_position = max(0, min(cursor_position, len(value)))
killed_text = value[cursor_position:]
return value[:cursor_position], cursor_position, killed_text


def kill_text_line(text: str, cursor_index: int) -> tuple[str, int, str]:
"""Kill from the cursor to the end of the current line, emacs-style."""
cursor_index = max(0, min(cursor_index, len(text)))

if cursor_index == len(text) and cursor_index > 0 and text[cursor_index - 1] == "\n":
killed_text = "\n"
return text[: cursor_index - 1], cursor_index - 1, killed_text

newline_index = text.find("\n", cursor_index)

if newline_index == -1:
killed_text = text[cursor_index:]
return text[:cursor_index], cursor_index, killed_text

if cursor_index == newline_index:
killed_text = "\n"
return text[:cursor_index] + text[newline_index + 1 :], cursor_index, killed_text

killed_text = text[cursor_index:newline_index]
return text[:cursor_index] + text[newline_index:], cursor_index, killed_text
3 changes: 0 additions & 3 deletions bor/mu.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,9 +568,6 @@ def view(self, path: str, mark_as_read: bool = True, msgid: str = "") -> Optiona
if not Path(path).exists():
return None

# Use mu view for metadata
result = self._run_mu(["view", path, "--format=sexp"])

# Parse the email file directly for full content
try:
with open(path, "rb") as f:
Expand Down
16 changes: 11 additions & 5 deletions bor/tabs/attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

from bor.tabs.base import BaseTab
from bor.mu import EmailMessage
from bor.config import get_config
from bor.config import Config, get_config


class AttachmentItem(ListItem):
Expand Down Expand Up @@ -107,6 +107,13 @@ def _kitty_image_size_params(img_w: int, img_h: int, avail_cols: int, avail_rows
return f",r={avail_rows}"


def _supports_kitty_graphics(config: Optional[Config] = None) -> bool:
"""Return whether Kitty graphics support should be used."""
if config is None:
config = get_config()
return config.attachments.force_kitty_support or os.environ.get("TERM") == "xterm-kitty"


class AttachmentPreview(ScrollableContainer):
"""Widget to preview attachment content."""

Expand Down Expand Up @@ -198,8 +205,7 @@ def _render_kitty_image(self) -> None:
if not self._current_image_path or not self._current_image_path.exists():
return

# Check if we're in kitty
if os.environ.get("TERM") != "xterm-kitty":
if not _supports_kitty_graphics():
return

try:
Expand Down Expand Up @@ -312,7 +318,7 @@ def _render_kitty_image(self) -> None:
def clear_image(self) -> None:
"""Clear any displayed Kitty image."""
self._current_image_path = None
if os.environ.get("TERM") == "xterm-kitty":
if _supports_kitty_graphics():
try:
# Clear all images using Kitty graphics protocol
# a=d means delete, d=A means all images
Expand Down Expand Up @@ -524,7 +530,7 @@ def _preview_attachment(self, index: int) -> None:

def _check_kitty(self) -> bool:
"""Check if we're running in kitty terminal."""
return os.environ.get("TERM") == "xterm-kitty"
return _supports_kitty_graphics()

def _open_with_kitty_icat(self, index: int) -> None:
"""
Expand Down
7 changes: 2 additions & 5 deletions bor/tabs/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from __future__ import annotations

from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast

from textual.widget import Widget

Expand All @@ -30,10 +30,7 @@ def __init__(self, *args, **kwargs) -> None:
@property
def bor_app(self) -> "BorApp":
"""Get the Bor application instance."""
from bor.app import BorApp
app = self.app
assert isinstance(app, BorApp)
return app
return cast("BorApp", self.app)

def close_tab(self) -> None:
"""Close this tab."""
Expand Down
Loading
Loading