This commit is contained in:
Joakim
2026-10-14 21:32:47 +02:00
parent a258d44613
commit 911299f013
2 changed files with 121 additions and 21 deletions

Binary file not shown.

View File

@@ -166,7 +166,7 @@ class App:
attr = curses.color_pair(1 if role == "user" else 2) if curses.has_colors() else 0 attr = curses.color_pair(1 if role == "user" else 2) if curses.has_colors() else 0
screen.addnstr(y, 0, line, max(0, width), attr) screen.addnstr(y, 0, line, max(0, width), attr)
# Draw a cursor in history when history has focus. # Draw history cursor and character-level block selection.
if self.focus == "history" and chat: if self.focus == "history" and chat:
self.history_cursor = max(0, min(self.history_cursor, len(chat) - 1)) self.history_cursor = max(0, min(self.history_cursor, len(chat) - 1))
row = self.history_cursor - start + 1 row = self.history_cursor - start + 1
@@ -174,7 +174,18 @@ class App:
line = chat[self.history_cursor][1] line = chat[self.history_cursor][1]
col = max(0, min(self.history_col, len(line))) col = max(0, min(self.history_col, len(line)))
try: try:
screen.chgat(row, 0, min(width, max(1, len(line))), curses.A_REVERSE) if self.history_mark is not None:
a = self.history_mark
b = (self.history_cursor, self.history_col)
if a > b:
a, b = b, a
for r in range(a[0], min(b[0] + 1, len(chat))):
if r < start or r >= start + chat_available:
continue
lo = a[1] if r == a[0] else 0
hi = b[1] if r == b[0] else len(chat[r][1])
if hi > lo:
screen.chgat(r - start + 1, lo, min(width - lo, hi - lo), curses.A_REVERSE)
screen.move(row, min(width - 1, col)) screen.move(row, min(width - 1, col))
except curses.error: except curses.error:
pass pass
@@ -267,11 +278,55 @@ class App:
def move_input(self, direction): def move_input(self, direction):
if direction == "left" and self.cursor > 0: if direction == "left" and self.cursor > 0:
self.cursor -= 1 self.cursor -= 1
elif direction == "right" and self.cursor < len(self.input): return
if direction == "right" and self.cursor < len(self.input):
self.cursor += 1 self.cursor += 1
elif direction in ("up", "down"): return
line, col = self.line_col() if direction not in ("up", "down"):
self.cursor = self.cursor_from_line_col(line + (1 if direction == "down" else -1), col) return
# Move by *visual* terminal lines, not logical newline-separated
# lines. This makes Up/Down behave like a normal terminal editor:
# one press moves roughly one screen width while keeping the column.
width = max(1, self.s.getmaxyx()[1])
rendered, (row, col) = self.input_lines(width)
target = row + (-1 if direction == "up" else 1)
if target < 0 or target >= len(rendered):
return
# All input lines use a two-character prefix ("> " / " ").
# Convert the visual column back to a character offset in that line.
prefix_len = len(rendered[target][0])
target_text_col = max(0, col - prefix_len)
# Walk the input using the same wrapping rules as input_lines().
# Build visual chunks and their character ranges.
chunks = []
pos = 0
logical_lines = self.input.split("\n")
first_width = max(1, width - 2)
rest_width = max(1, width - 2)
for li, logical in enumerate(logical_lines):
if logical == "":
chunks.append((li, pos, pos, 2))
else:
remaining = logical
local = pos
first = True
while remaining:
n = first_width if first else rest_width
piece = remaining[:n]
chunks.append((li, local, local + len(piece), 2))
local += len(piece)
remaining = remaining[len(piece):]
first = False
pos += len(logical) + 1
if target >= len(chunks):
self.cursor = len(self.input)
return
_, start, end, _ = chunks[target]
self.cursor = min(end, start + target_text_col)
def insert(self, text): def insert(self, text):
self.input = self.input[:self.cursor] + text + self.input[self.cursor:] self.input = self.input[:self.cursor] + text + self.input[self.cursor:]
@@ -338,19 +393,40 @@ class App:
lines = self.chat_lines(self.display_messages(), max(1, self.s.getmaxyx()[1])) lines = self.chat_lines(self.display_messages(), max(1, self.s.getmaxyx()[1]))
if not lines: if not lines:
return return
row = self.history_cursor
col = self.history_col
if key == curses.KEY_UP: if key == curses.KEY_UP:
self.history_cursor = max(0, self.history_cursor - 1) if row > 0:
row -= 1
col = min(col, len(lines[row][1]))
elif key == curses.KEY_DOWN: elif key == curses.KEY_DOWN:
self.history_cursor = min(len(lines) - 1, self.history_cursor + 1) if row < len(lines) - 1:
row += 1
col = min(col, len(lines[row][1]))
elif key == curses.KEY_LEFT: elif key == curses.KEY_LEFT:
self.history_col = max(0, self.history_col - 1) if col > 0:
col -= 1
elif row > 0:
row -= 1
col = len(lines[row][1])
elif key == curses.KEY_RIGHT: elif key == curses.KEY_RIGHT:
self.history_col = min(len(lines[self.history_cursor][1]), self.history_col + 1) if col < len(lines[row][1]):
col += 1
elif row < len(lines) - 1:
row += 1
col = 0
elif key == curses.KEY_PPAGE: elif key == curses.KEY_PPAGE:
self.history_cursor = max(0, self.history_cursor - 5) row = max(0, row - 5)
col = min(col, len(lines[row][1]))
elif key == curses.KEY_NPAGE: elif key == curses.KEY_NPAGE:
self.history_cursor = min(len(lines) - 1, self.history_cursor + 5) row = min(len(lines) - 1, row + 5)
self.scroll = max(0, self.history_cursor - 2) col = min(col, len(lines[row][1]))
self.history_cursor = row
self.history_col = col
height, width = self.s.getmaxyx()
input_height = min(max(1, height - 4), self.input_visual_height(max(1, width)))
available = max(1, height - 3 - input_height)
self.scroll = max(0, min(self.history_cursor, max(0, len(lines) - available)))
def history_mark_start(self): def history_mark_start(self):
self.history_mark = (self.history_cursor, self.history_col) self.history_mark = (self.history_cursor, self.history_col)
@@ -379,12 +455,34 @@ class App:
self.send_osc52(self.kill_buffer) self.send_osc52(self.kill_buffer)
def send_osc52(self, text): def send_osc52(self, text):
# OSC 52 lets compatible terminals copy the selected history text # Keep a persistent clipboard for local/embedded terminals and also
# to the system clipboard. Unsupported terminals simply ignore it. # offer the terminal emulator clipboard through OSC 52 when supported.
import base64 import base64
import os
import shutil
import subprocess
try:
clip = Path.home() / ".ai" / "clipboard"
clip.parent.mkdir(parents=True, exist_ok=True)
clip.write_text(text, encoding="utf-8")
except Exception:
pass
for command in (("wl-copy",), ("xclip", "-selection", "clipboard"),
("xsel", "--clipboard", "--input")):
if shutil.which(command[0]):
try:
subprocess.run(command, input=text, text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, check=False)
break
except Exception:
pass
data = base64.b64encode(text.encode("utf-8")).decode("ascii") data = base64.b64encode(text.encode("utf-8")).decode("ascii")
try: try:
print(f"\033]52;c;{data}\a", end="", flush=True) os.write(1, f"\033]52;c;{data}\a".encode("ascii"))
except Exception: except Exception:
pass pass
@@ -443,11 +541,13 @@ class App:
def handle_key(self, key): def handle_key(self, key):
if self.prefix_ctrl_b: if self.prefix_ctrl_b:
self.prefix_ctrl_b = False self.prefix_ctrl_b = False
if key == curses.KEY_UP: if key in (curses.KEY_UP, curses.KEY_DOWN):
self.history_focus("up") if self.focus == "history":
return # Ctrl-B + arrow returns to the input field. Ctrl-B
if key == curses.KEY_DOWN: # + arrow from the input enters history.
self.history_focus("down") self.return_to_input()
else:
self.history_focus("up" if key == curses.KEY_UP else "down")
return return
if key == "b": if key == "b":
self.prefix_ctrl_b = True self.prefix_ctrl_b = True