N/A
This commit is contained in:
BIN
src/ai/__pycache__/tui.cpython-313.pyc
Normal file
BIN
src/ai/__pycache__/tui.cpython-313.pyc
Normal file
Binary file not shown.
142
src/ai/tui.py
142
src/ai/tui.py
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user