N/A
This commit is contained in:
97
README.md
97
README.md
@@ -1,6 +1,6 @@
|
|||||||
# ai 0.3.3
|
# ai 0.3.4
|
||||||
|
|
||||||
Kompakt terminalklient för OpenRouter, optimerad för PicoCalc/Lyra.
|
Terminal AI-klient för OpenRouter, anpassad för Lyra/PicoCalc.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -8,12 +8,40 @@ Kompakt terminalklient för OpenRouter, optimerad för PicoCalc/Lyra.
|
|||||||
./install
|
./install
|
||||||
```
|
```
|
||||||
|
|
||||||
På system med `/data` används automatiskt:
|
På Lyra används `/data/tmp` och `/data/pip-cache` automatiskt om `/data` finns.
|
||||||
|
|
||||||
```text
|
## Tangentbeteende
|
||||||
/data/tmp
|
|
||||||
/data/pip-cache
|
### Input
|
||||||
```
|
|
||||||
|
- Enter = ny rad
|
||||||
|
- Enter två gånger = skicka
|
||||||
|
- När prompten fortfarande ryms på en visuell rad används piltangenterna för att scrolla historiken.
|
||||||
|
- När prompten blivit flera visuella rader används piltangenterna för att navigera i prompten.
|
||||||
|
- Home/End, Backspace och Delete fungerar i editorn.
|
||||||
|
|
||||||
|
### Historik
|
||||||
|
|
||||||
|
`Ctrl-B` följt av `↑` eller `↓` flyttar fokus till historiken. När historiken har fokus används alla piltangenter för att flytta markören i historiken.
|
||||||
|
|
||||||
|
`Ctrl-Q` återgår till inputfältet.
|
||||||
|
|
||||||
|
### Joe-liknande blockkommandon
|
||||||
|
|
||||||
|
`Ctrl-K B` börjar markera från aktuell cursorposition.
|
||||||
|
|
||||||
|
`Ctrl-K K` avslutar markeringen. I historiken kopieras markeringen även till terminalens clipboard via OSC 52 om terminalen stöder det.
|
||||||
|
|
||||||
|
Övriga grundkommandon:
|
||||||
|
|
||||||
|
- `Ctrl-K C` = kopiera markerad text i input
|
||||||
|
- `Ctrl-K X` = klipp ut markerad text i input
|
||||||
|
- `Ctrl-K Y` = klistra in intern kopia
|
||||||
|
- `Ctrl-K U` = ta bort markering
|
||||||
|
|
||||||
|
## AI-streaming
|
||||||
|
|
||||||
|
AI-svaret visas medan det genereras. PgUp/PgDn kan användas för att läsa tidigare text medan svaret fortfarande strömmar.
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
@@ -25,57 +53,4 @@ model: openrouter/auto
|
|||||||
api_key: sk-or-v1-...
|
api_key: sk-or-v1-...
|
||||||
```
|
```
|
||||||
|
|
||||||
eller:
|
En lokal `.ai/config.yaml` i en underkatalog övertrumfar den globala konfigurationen.
|
||||||
|
|
||||||
```bash
|
|
||||||
export OPENROUTER_API_KEY='sk-or-v1-...'
|
|
||||||
```
|
|
||||||
|
|
||||||
`.ai` ärvs upp genom katalogträdet.
|
|
||||||
|
|
||||||
## Chattar
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ai
|
|
||||||
ai 2026-10-13-vinterdack.md
|
|
||||||
```
|
|
||||||
|
|
||||||
En ny chat sparas inte förrän den har passerat titeltröskeln:
|
|
||||||
|
|
||||||
- minst två användarmeddelanden, eller
|
|
||||||
- ett användarmeddelande på minst 100 tecken.
|
|
||||||
|
|
||||||
Titelmodellen får returnera `UNTITLED`.
|
|
||||||
|
|
||||||
Chatten sparas i aktuell katalog, inte i `.ai/chats`.
|
|
||||||
|
|
||||||
## Editor
|
|
||||||
|
|
||||||
Inputfältet är flerradigt.
|
|
||||||
|
|
||||||
- Enter = ny rad
|
|
||||||
- Enter två gånger = skicka
|
|
||||||
- ←/→ = flytta markören
|
|
||||||
- ↑/↓ = flytta mellan rader
|
|
||||||
- Home/End = början/slut av aktuell rad
|
|
||||||
- Backspace
|
|
||||||
- Delete
|
|
||||||
|
|
||||||
Visuell word wrapping påverkar inte den faktiska texten.
|
|
||||||
|
|
||||||
## AI-streaming
|
|
||||||
|
|
||||||
AI-svaret visas medan det genereras.
|
|
||||||
|
|
||||||
- PgUp = scrolla upp
|
|
||||||
- PgDn = scrolla ned
|
|
||||||
- När du är längst ned följer visningen automatiskt det nya svaret.
|
|
||||||
- Om du scrollar upp stannar visningen där även medan AI fortsätter generera.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
- `You:` och `AI:` på samma rad som texten
|
|
||||||
- fortsättningsrader har två teckens indrag
|
|
||||||
- hela You-meddelandet har You-färg
|
|
||||||
- hela AI-meddelandet har AI-färg
|
|
||||||
- faktiska radbrytningar bevaras
|
|
||||||
|
|||||||
599
src/ai/tui.py
599
src/ai/tui.py
@@ -22,27 +22,33 @@ class App:
|
|||||||
self.title = "Ny chat"
|
self.title = "Ny chat"
|
||||||
self.messages = []
|
self.messages = []
|
||||||
|
|
||||||
# Multiline editor.
|
|
||||||
self.input = ""
|
self.input = ""
|
||||||
self.cursor = 0
|
self.cursor = 0
|
||||||
self.preferred_column = None
|
|
||||||
self.last_was_enter = False
|
self.last_was_enter = False
|
||||||
|
self.input_mark = None
|
||||||
|
self.kill_buffer = ""
|
||||||
|
|
||||||
# Streaming worker.
|
|
||||||
self.events = queue.Queue()
|
self.events = queue.Queue()
|
||||||
self.streaming = False
|
self.streaming = False
|
||||||
self.stream_answer = ""
|
self.stream_answer = ""
|
||||||
self.stream_error = None
|
self.stream_error = None
|
||||||
|
|
||||||
# Viewport.
|
|
||||||
self.scroll = 0
|
self.scroll = 0
|
||||||
self.follow_stream = True
|
self.follow_stream = True
|
||||||
|
|
||||||
|
# Focus is normally the input. History focus is entered with
|
||||||
|
# Ctrl-B followed by Up/Down, tmux-style.
|
||||||
|
self.focus = "input"
|
||||||
|
self.prefix_ctrl_b = False
|
||||||
|
self.prefix_ctrl_k = False
|
||||||
|
self.history_cursor = 0
|
||||||
|
self.history_col = 0
|
||||||
|
self.history_mark = None
|
||||||
|
|
||||||
if chat_file:
|
if chat_file:
|
||||||
path = Path(chat_file).expanduser()
|
path = Path(chat_file).expanduser()
|
||||||
if not path.is_absolute():
|
if not path.is_absolute():
|
||||||
path = Path.cwd() / path
|
path = Path.cwd() / path
|
||||||
|
|
||||||
if path.exists():
|
if path.exists():
|
||||||
self.path = path
|
self.path = path
|
||||||
self.title, self.messages = load_chat(path)
|
self.title, self.messages = load_chat(path)
|
||||||
@@ -50,249 +56,160 @@ class App:
|
|||||||
def setup_colors(self):
|
def setup_colors(self):
|
||||||
if not curses.has_colors():
|
if not curses.has_colors():
|
||||||
return
|
return
|
||||||
|
|
||||||
curses.start_color()
|
curses.start_color()
|
||||||
curses.use_default_colors()
|
curses.use_default_colors()
|
||||||
curses.init_pair(1, curses.COLOR_CYAN, -1) # You
|
curses.init_pair(1, curses.COLOR_CYAN, -1)
|
||||||
curses.init_pair(2, curses.COLOR_GREEN, -1) # AI
|
curses.init_pair(2, curses.COLOR_GREEN, -1)
|
||||||
curses.init_pair(3, curses.COLOR_YELLOW, -1) # status
|
curses.init_pair(3, curses.COLOR_YELLOW, -1)
|
||||||
curses.init_pair(4, curses.COLOR_WHITE, -1)
|
curses.init_pair(4, curses.COLOR_WHITE, -1)
|
||||||
|
curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_WHITE)
|
||||||
|
|
||||||
def wrap_text(self, text, width):
|
def wrap_text(self, text, width):
|
||||||
result = []
|
result = []
|
||||||
|
|
||||||
for paragraph in text.split("\n"):
|
for paragraph in text.split("\n"):
|
||||||
if paragraph == "":
|
if paragraph == "":
|
||||||
result.append("")
|
result.append("")
|
||||||
else:
|
else:
|
||||||
result.extend(
|
result.extend(textwrap.wrap(
|
||||||
textwrap.wrap(
|
|
||||||
paragraph,
|
paragraph,
|
||||||
width=max(1, width),
|
width=max(1, width),
|
||||||
break_long_words=True,
|
break_long_words=True,
|
||||||
break_on_hyphens=False,
|
break_on_hyphens=False,
|
||||||
) or [""]
|
) or [""])
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def chat_lines(self, messages, width):
|
def chat_lines(self, messages, width):
|
||||||
lines = []
|
lines = []
|
||||||
|
|
||||||
for message in messages:
|
for message in messages:
|
||||||
prefix = "You: " if message["role"] == "user" else "AI: "
|
prefix = "You: " if message["role"] == "user" else "AI: "
|
||||||
continuation = " "
|
wrapped = self.wrap_text(message["content"], max(1, width - len(prefix)))
|
||||||
|
|
||||||
first_width = max(1, width - len(prefix))
|
|
||||||
rest_width = max(1, width - len(continuation))
|
|
||||||
|
|
||||||
wrapped = self.wrap_text(message["content"], first_width)
|
|
||||||
|
|
||||||
if not wrapped:
|
if not wrapped:
|
||||||
wrapped = [""]
|
wrapped = [""]
|
||||||
|
lines.append((message["role"], prefix + wrapped[0]))
|
||||||
lines.append(
|
|
||||||
(message["role"], prefix + wrapped[0])
|
|
||||||
)
|
|
||||||
|
|
||||||
for line in wrapped[1:]:
|
for line in wrapped[1:]:
|
||||||
lines.append(
|
lines.append((message["role"], " " + line))
|
||||||
(message["role"], continuation + line)
|
|
||||||
)
|
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
def input_lines(self, width):
|
def input_lines(self, width):
|
||||||
prefix = "> "
|
prefix = "> "
|
||||||
continuation = " "
|
continuation = " "
|
||||||
available_first = max(1, width - len(prefix))
|
first_width = max(1, width - len(prefix))
|
||||||
available_rest = max(1, width - len(continuation))
|
rest_width = max(1, width - len(continuation))
|
||||||
|
|
||||||
logical_lines = self.input.split("\n")
|
|
||||||
if not logical_lines:
|
|
||||||
logical_lines = [""]
|
|
||||||
|
|
||||||
rendered = []
|
rendered = []
|
||||||
cursor_locations = []
|
cursor_location = (0, len(prefix))
|
||||||
|
|
||||||
cursor_pos = self.cursor
|
|
||||||
absolute = 0
|
absolute = 0
|
||||||
|
|
||||||
for logical in logical_lines:
|
for logical in self.input.split("\n"):
|
||||||
if absolute + len(logical) < cursor_pos:
|
local_cursor = self.cursor - absolute
|
||||||
absolute += len(logical) + 1
|
if local_cursor < 0:
|
||||||
continue
|
local_cursor = -1
|
||||||
|
if local_cursor > len(logical):
|
||||||
local_cursor = cursor_pos - absolute
|
local_cursor = len(logical)
|
||||||
local_cursor = max(0, min(local_cursor, len(logical)))
|
|
||||||
|
|
||||||
first = True
|
first = True
|
||||||
remaining = logical
|
remaining = logical
|
||||||
|
|
||||||
if remaining == "":
|
if remaining == "":
|
||||||
rendered.append((prefix if first else continuation, ""))
|
rendered.append((prefix if first else continuation, ""))
|
||||||
cursor_locations.append(
|
if local_cursor == 0:
|
||||||
(len(rendered) - 1, len(prefix) + local_cursor)
|
cursor_location = (len(rendered) - 1, len(prefix))
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
while remaining:
|
while remaining:
|
||||||
width_for_line = available_first if first else available_rest
|
line_width = first_width if first else rest_width
|
||||||
piece = remaining[:width_for_line]
|
piece = remaining[:line_width]
|
||||||
remaining = remaining[width_for_line:]
|
remaining = remaining[line_width:]
|
||||||
|
pfx = prefix if first else continuation
|
||||||
rendered.append(
|
rendered.append((pfx, piece))
|
||||||
(
|
if 0 <= local_cursor <= len(piece):
|
||||||
prefix if first else continuation,
|
cursor_location = (len(rendered) - 1, len(pfx) + local_cursor)
|
||||||
piece,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if first and local_cursor <= len(piece):
|
|
||||||
cursor_locations.append(
|
|
||||||
(len(rendered) - 1, len(prefix) + local_cursor)
|
|
||||||
)
|
|
||||||
local_cursor = -1
|
local_cursor = -1
|
||||||
elif not first and local_cursor >= 0:
|
elif local_cursor > len(piece):
|
||||||
if local_cursor <= len(piece):
|
|
||||||
cursor_locations.append(
|
|
||||||
(len(rendered) - 1, len(continuation) + local_cursor)
|
|
||||||
)
|
|
||||||
local_cursor = -1
|
|
||||||
else:
|
|
||||||
local_cursor -= len(piece)
|
local_cursor -= len(piece)
|
||||||
|
|
||||||
first = False
|
first = False
|
||||||
|
|
||||||
absolute += len(logical) + 1
|
absolute += len(logical) + 1
|
||||||
|
|
||||||
if self.cursor == len(self.input) and not cursor_locations:
|
if self.cursor == len(self.input) and rendered:
|
||||||
# Cursor at the end.
|
pfx, text = rendered[-1]
|
||||||
last = len(rendered) - 1 if rendered else 0
|
cursor_location = (len(rendered) - 1, len(pfx) + len(text))
|
||||||
text = rendered[last][1] if rendered else ""
|
return rendered, cursor_location
|
||||||
prefix_len = len(rendered[last][0]) if rendered else len(prefix)
|
|
||||||
cursor_locations = [(last, prefix_len + len(text))]
|
|
||||||
|
|
||||||
return rendered, cursor_locations[0] if cursor_locations else (0, len(prefix))
|
def input_visual_height(self, width):
|
||||||
|
return max(1, len(self.input_lines(width)[0]))
|
||||||
|
|
||||||
def total_input_height(self, width):
|
def display_messages(self):
|
||||||
rendered, _ = self.input_lines(width)
|
if self.streaming and self.stream_answer:
|
||||||
return max(1, len(rendered))
|
return self.messages + [{"role": "assistant", "content": self.stream_answer}]
|
||||||
|
return self.messages
|
||||||
|
|
||||||
def draw(self, status="", messages=None):
|
def draw(self, status="", messages=None):
|
||||||
screen = self.s
|
screen = self.s
|
||||||
height, width = screen.getmaxyx()
|
height, width = screen.getmaxyx()
|
||||||
screen.erase()
|
screen.erase()
|
||||||
|
|
||||||
self.setup_colors()
|
self.setup_colors()
|
||||||
|
|
||||||
screen.addnstr(
|
screen.addnstr(0, 0, f" AI · {self.title} ", max(0, width), curses.A_REVERSE)
|
||||||
0,
|
|
||||||
0,
|
|
||||||
f" AI · {self.title} ",
|
|
||||||
max(0, width - 1),
|
|
||||||
curses.A_REVERSE,
|
|
||||||
)
|
|
||||||
|
|
||||||
visible_messages = self.messages if messages is None else messages
|
visible_messages = self.display_messages() if messages is None else messages
|
||||||
chat = self.chat_lines(visible_messages, max(1, width))
|
chat = self.chat_lines(visible_messages, max(1, width))
|
||||||
|
|
||||||
input_height = min(
|
input_height = min(max(1, height - 4), self.input_visual_height(max(1, width)))
|
||||||
max(1, height - 4),
|
separator = height - 2 - input_height
|
||||||
self.total_input_height(max(1, width)),
|
chat_available = max(0, separator - 1)
|
||||||
)
|
|
||||||
|
|
||||||
separator_row = height - 2 - input_height
|
|
||||||
chat_top = 1
|
|
||||||
chat_available = max(0, separator_row - chat_top)
|
|
||||||
|
|
||||||
max_scroll = max(0, len(chat) - chat_available)
|
max_scroll = max(0, len(chat) - chat_available)
|
||||||
|
if self.follow_stream and self.focus == "input":
|
||||||
if self.follow_stream:
|
|
||||||
start = max_scroll
|
|
||||||
self.scroll = max_scroll
|
self.scroll = max_scroll
|
||||||
else:
|
else:
|
||||||
start = max(0, min(self.scroll, max_scroll))
|
self.scroll = max(0, min(self.scroll, max_scroll))
|
||||||
|
|
||||||
for y, (role, line) in enumerate(
|
start = self.scroll
|
||||||
chat[start:start + chat_available],
|
for y, (role, line) in enumerate(chat[start:start + chat_available], 1):
|
||||||
chat_top,
|
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)
|
||||||
if role == "user":
|
|
||||||
attr = curses.color_pair(1) if curses.has_colors() else 0
|
|
||||||
else:
|
|
||||||
attr = curses.color_pair(2) if curses.has_colors() else 0
|
|
||||||
|
|
||||||
screen.addnstr(
|
# Draw a cursor in history when history has focus.
|
||||||
y,
|
if self.focus == "history" and chat:
|
||||||
0,
|
self.history_cursor = max(0, min(self.history_cursor, len(chat) - 1))
|
||||||
line,
|
row = self.history_cursor - start + 1
|
||||||
max(0, width),
|
if 1 <= row < separator:
|
||||||
attr,
|
line = chat[self.history_cursor][1]
|
||||||
)
|
col = max(0, min(self.history_col, len(line)))
|
||||||
|
try:
|
||||||
|
screen.chgat(row, 0, min(width, max(1, len(line))), curses.A_REVERSE)
|
||||||
|
screen.move(row, min(width - 1, col))
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
screen.hline(
|
screen.hline(separator, 0, curses.ACS_HLINE, width)
|
||||||
separator_row,
|
|
||||||
0,
|
|
||||||
curses.ACS_HLINE,
|
|
||||||
width,
|
|
||||||
)
|
|
||||||
|
|
||||||
input_rendered, cursor_location = self.input_lines(max(1, width))
|
rendered, (cursor_row, cursor_col) = self.input_lines(max(1, width))
|
||||||
|
visible_input = rendered[-input_height:]
|
||||||
|
first_row = separator + 1
|
||||||
|
for i, (pfx, text) in enumerate(visible_input):
|
||||||
|
attr = curses.color_pair(1) if self.focus == "input" and curses.has_colors() else 0
|
||||||
|
screen.addnstr(first_row + i, 0, pfx + text, max(0, width), attr)
|
||||||
|
|
||||||
# Only show the last input lines if the editor is taller than the
|
if status:
|
||||||
# available terminal area.
|
screen.addnstr(height - 1, 0, status, max(0, width),
|
||||||
input_rendered = input_rendered[-input_height:]
|
curses.color_pair(3) if curses.has_colors() else 0)
|
||||||
|
|
||||||
first_input_row = separator_row + 1
|
|
||||||
|
|
||||||
for index, (prefix, text) in enumerate(input_rendered):
|
|
||||||
row = first_input_row + index
|
|
||||||
screen.addnstr(
|
|
||||||
row,
|
|
||||||
0,
|
|
||||||
prefix + text,
|
|
||||||
max(0, width),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Status is shown only when there is room below the input.
|
|
||||||
if status and height > 1:
|
|
||||||
screen.addnstr(
|
|
||||||
height - 1,
|
|
||||||
0,
|
|
||||||
status,
|
|
||||||
max(0, width),
|
|
||||||
curses.color_pair(3) if curses.has_colors() else 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
cursor_row, cursor_col = cursor_location
|
|
||||||
visible_first = max(0, len(self.input_lines(max(1, width))[0]) - input_height)
|
|
||||||
cursor_row -= visible_first
|
|
||||||
cursor_row = max(0, min(input_height - 1, cursor_row))
|
|
||||||
|
|
||||||
screen.move(
|
|
||||||
min(height - 1, first_input_row + cursor_row),
|
|
||||||
min(max(0, width - 1), cursor_col),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
if self.focus == "input":
|
||||||
|
offset = max(0, len(rendered) - input_height)
|
||||||
|
r = first_row + max(0, cursor_row - offset)
|
||||||
|
c = min(max(0, width - 1), cursor_col)
|
||||||
|
try:
|
||||||
|
screen.move(min(height - 1, r), c)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
screen.refresh()
|
screen.refresh()
|
||||||
|
|
||||||
def should_request_title(self):
|
def should_request_title(self):
|
||||||
users = [
|
users = [m["content"].strip() for m in self.messages if m["role"] == "user"]
|
||||||
m["content"].strip()
|
return len(users) >= 2 or bool(users and len(users[0]) >= 100)
|
||||||
for m in self.messages
|
|
||||||
if m["role"] == "user"
|
|
||||||
]
|
|
||||||
|
|
||||||
if len(users) >= 2:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return bool(users and len(users[0]) >= 100)
|
|
||||||
|
|
||||||
def start_stream(self):
|
def start_stream(self):
|
||||||
self.streaming = True
|
self.streaming = True
|
||||||
self.stream_answer = ""
|
self.stream_answer = ""
|
||||||
self.stream_error = None
|
self.stream_error = None
|
||||||
|
|
||||||
messages = list(self.messages)
|
messages = list(self.messages)
|
||||||
context = self.instructions + self.memories
|
context = self.instructions + self.memories
|
||||||
|
|
||||||
@@ -303,34 +220,20 @@ class App:
|
|||||||
self.events.put(("done", None))
|
self.events.put(("done", None))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.events.put(("error", exc))
|
self.events.put(("error", exc))
|
||||||
|
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
def process_events(self):
|
def process_events(self):
|
||||||
changed = False
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
event, value = self.events.get_nowait()
|
event, value = self.events.get_nowait()
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if event == "chunk":
|
if event == "chunk":
|
||||||
self.stream_answer += value
|
self.stream_answer += value
|
||||||
|
|
||||||
elif event == "done":
|
elif event == "done":
|
||||||
if self.stream_answer:
|
if self.stream_answer:
|
||||||
self.messages.append(
|
self.messages.append({"role": "assistant", "content": self.stream_answer})
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": self.stream_answer,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.streaming = False
|
self.streaming = False
|
||||||
|
|
||||||
if self.path is None and self.should_request_title():
|
if self.path is None and self.should_request_title():
|
||||||
try:
|
try:
|
||||||
title = self.api.title(self.messages)
|
title = self.api.title(self.messages)
|
||||||
@@ -339,61 +242,46 @@ class App:
|
|||||||
self.path = chat_path(Path.cwd(), title)
|
self.path = chat_path(Path.cwd(), title)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if self.path:
|
if self.path:
|
||||||
save(
|
save(self.path, self.title, self.messages, self.cfg["model"])
|
||||||
self.path,
|
|
||||||
self.title,
|
|
||||||
self.messages,
|
|
||||||
self.cfg["model"],
|
|
||||||
)
|
|
||||||
|
|
||||||
elif event == "error":
|
elif event == "error":
|
||||||
self.stream_error = str(value)
|
self.stream_error = str(value)
|
||||||
self.streaming = False
|
self.streaming = False
|
||||||
|
|
||||||
return changed
|
# ---------- input editor ----------
|
||||||
|
|
||||||
def display_messages(self):
|
|
||||||
if self.streaming and self.stream_answer:
|
|
||||||
return self.messages + [
|
|
||||||
{"role": "assistant", "content": self.stream_answer}
|
|
||||||
]
|
|
||||||
|
|
||||||
return self.messages
|
|
||||||
|
|
||||||
# ---------- Multiline editor ----------
|
|
||||||
|
|
||||||
def line_col(self):
|
def line_col(self):
|
||||||
before = self.input[:self.cursor]
|
before = self.input[:self.cursor]
|
||||||
line = before.count("\n")
|
line = before.count("\n")
|
||||||
last_newline = before.rfind("\n")
|
last = before.rfind("\n")
|
||||||
col = self.cursor if last_newline < 0 else self.cursor - last_newline - 1
|
col = self.cursor if last < 0 else self.cursor - last - 1
|
||||||
return line, col
|
return line, col
|
||||||
|
|
||||||
def cursor_from_line_col(self, target_line, target_col):
|
def cursor_from_line_col(self, line, col):
|
||||||
lines = self.input.split("\n")
|
lines = self.input.split("\n")
|
||||||
target_line = max(0, min(target_line, len(lines) - 1))
|
line = max(0, min(line, len(lines) - 1))
|
||||||
pos = sum(len(x) + 1 for x in lines[:target_line])
|
return min(sum(len(x) + 1 for x in lines[:line]) + col, sum(len(x) + 1 for x in lines[:line]) + len(lines[line]))
|
||||||
return min(pos + target_col, pos + len(lines[target_line]))
|
|
||||||
|
|
||||||
def move_left(self):
|
def move_input(self, direction):
|
||||||
if self.cursor > 0:
|
if direction == "left" and self.cursor > 0:
|
||||||
|
self.cursor -= 1
|
||||||
|
elif direction == "right" and self.cursor < len(self.input):
|
||||||
|
self.cursor += 1
|
||||||
|
elif direction in ("up", "down"):
|
||||||
|
line, col = self.line_col()
|
||||||
|
self.cursor = self.cursor_from_line_col(line + (1 if direction == "down" else -1), col)
|
||||||
|
|
||||||
|
def insert(self, text):
|
||||||
|
self.input = self.input[:self.cursor] + text + self.input[self.cursor:]
|
||||||
|
self.cursor += len(text)
|
||||||
|
|
||||||
|
def backspace(self):
|
||||||
|
if self.cursor:
|
||||||
|
self.input = self.input[:self.cursor - 1] + self.input[self.cursor:]
|
||||||
self.cursor -= 1
|
self.cursor -= 1
|
||||||
|
|
||||||
def move_right(self):
|
def delete(self):
|
||||||
if self.cursor < len(self.input):
|
if self.cursor < len(self.input):
|
||||||
self.cursor += 1
|
self.input = self.input[:self.cursor] + self.input[self.cursor + 1:]
|
||||||
|
|
||||||
def move_vertical(self, direction):
|
|
||||||
line, col = self.line_col()
|
|
||||||
target = line + direction
|
|
||||||
lines = self.input.split("\n")
|
|
||||||
|
|
||||||
if target < 0 or target >= len(lines):
|
|
||||||
return
|
|
||||||
|
|
||||||
self.cursor = self.cursor_from_line_col(target, col)
|
|
||||||
|
|
||||||
def home(self):
|
def home(self):
|
||||||
line, _ = self.line_col()
|
line, _ = self.line_col()
|
||||||
@@ -404,88 +292,253 @@ class App:
|
|||||||
lines = self.input.split("\n")
|
lines = self.input.split("\n")
|
||||||
self.cursor = self.cursor_from_line_col(line, len(lines[line]))
|
self.cursor = self.cursor_from_line_col(line, len(lines[line]))
|
||||||
|
|
||||||
def insert(self, text):
|
def editor_mark_start(self):
|
||||||
self.input = self.input[:self.cursor] + text + self.input[self.cursor:]
|
self.input_mark = self.cursor
|
||||||
self.cursor += len(text)
|
|
||||||
|
|
||||||
def backspace(self):
|
def editor_mark_end(self):
|
||||||
if self.cursor > 0:
|
if self.input_mark is None:
|
||||||
self.input = self.input[:self.cursor - 1] + self.input[self.cursor:]
|
return
|
||||||
self.cursor -= 1
|
a, b = sorted((self.input_mark, self.cursor))
|
||||||
|
self.kill_buffer = self.input[a:b]
|
||||||
|
self.input_mark = None
|
||||||
|
|
||||||
def delete(self):
|
def delete_marked(self):
|
||||||
if self.cursor < len(self.input):
|
if self.input_mark is None:
|
||||||
self.input = self.input[:self.cursor] + self.input[self.cursor + 1:]
|
return
|
||||||
|
a, b = sorted((self.input_mark, self.cursor))
|
||||||
|
self.kill_buffer = self.input[a:b]
|
||||||
|
self.input = self.input[:a] + self.input[b:]
|
||||||
|
self.cursor = a
|
||||||
|
self.input_mark = None
|
||||||
|
|
||||||
|
def paste(self):
|
||||||
|
if self.kill_buffer:
|
||||||
|
self.insert(self.kill_buffer)
|
||||||
|
|
||||||
|
# ---------- history focus / selection ----------
|
||||||
|
def history_focus(self, direction):
|
||||||
|
lines = self.chat_lines(self.display_messages(), max(1, self.s.getmaxyx()[1]))
|
||||||
|
if not lines:
|
||||||
|
return
|
||||||
|
self.focus = "history"
|
||||||
|
if direction == "up":
|
||||||
|
self.history_cursor = max(0, self.scroll)
|
||||||
|
else:
|
||||||
|
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.history_cursor = min(len(lines) - 1, self.scroll + available - 1)
|
||||||
|
self.history_col = min(self.history_col, len(lines[self.history_cursor][1]))
|
||||||
|
self.follow_stream = False
|
||||||
|
|
||||||
|
def history_move(self, key):
|
||||||
|
lines = self.chat_lines(self.display_messages(), max(1, self.s.getmaxyx()[1]))
|
||||||
|
if not lines:
|
||||||
|
return
|
||||||
|
if key == curses.KEY_UP:
|
||||||
|
self.history_cursor = max(0, self.history_cursor - 1)
|
||||||
|
elif key == curses.KEY_DOWN:
|
||||||
|
self.history_cursor = min(len(lines) - 1, self.history_cursor + 1)
|
||||||
|
elif key == curses.KEY_LEFT:
|
||||||
|
self.history_col = max(0, self.history_col - 1)
|
||||||
|
elif key == curses.KEY_RIGHT:
|
||||||
|
self.history_col = min(len(lines[self.history_cursor][1]), self.history_col + 1)
|
||||||
|
elif key == curses.KEY_PPAGE:
|
||||||
|
self.history_cursor = max(0, self.history_cursor - 5)
|
||||||
|
elif key == curses.KEY_NPAGE:
|
||||||
|
self.history_cursor = min(len(lines) - 1, self.history_cursor + 5)
|
||||||
|
self.scroll = max(0, self.history_cursor - 2)
|
||||||
|
|
||||||
|
def history_mark_start(self):
|
||||||
|
self.history_mark = (self.history_cursor, self.history_col)
|
||||||
|
|
||||||
|
def history_mark_end(self):
|
||||||
|
if self.history_mark is None:
|
||||||
|
return
|
||||||
|
lines = self.chat_lines(self.display_messages(), max(1, self.s.getmaxyx()[1]))
|
||||||
|
a = self.history_mark
|
||||||
|
b = (self.history_cursor, self.history_col)
|
||||||
|
if a > b:
|
||||||
|
a, b = b, a
|
||||||
|
selected = []
|
||||||
|
for i in range(a[0], min(b[0] + 1, len(lines))):
|
||||||
|
text = lines[i][1]
|
||||||
|
if i == a[0] and i == b[0]:
|
||||||
|
selected.append(text[a[1]:b[1]])
|
||||||
|
elif i == a[0]:
|
||||||
|
selected.append(text[a[1]:])
|
||||||
|
elif i == b[0]:
|
||||||
|
selected.append(text[:b[1]])
|
||||||
|
else:
|
||||||
|
selected.append(text)
|
||||||
|
self.kill_buffer = "\n".join(selected)
|
||||||
|
self.history_mark = None
|
||||||
|
self.send_osc52(self.kill_buffer)
|
||||||
|
|
||||||
|
def send_osc52(self, text):
|
||||||
|
# OSC 52 lets compatible terminals copy the selected history text
|
||||||
|
# to the system clipboard. Unsupported terminals simply ignore it.
|
||||||
|
import base64
|
||||||
|
data = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
||||||
|
try:
|
||||||
|
print(f"\033]52;c;{data}\a", end="", flush=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def return_to_input(self):
|
||||||
|
self.focus = "input"
|
||||||
|
self.follow_stream = self.scroll == 0
|
||||||
|
|
||||||
def submit(self):
|
def submit(self):
|
||||||
text = self.input.strip()
|
text = self.input.strip()
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.input = ""
|
self.input = ""
|
||||||
self.cursor = 0
|
self.cursor = 0
|
||||||
self.last_was_enter = False
|
self.last_was_enter = False
|
||||||
|
|
||||||
self.messages.append({"role": "user", "content": text})
|
self.messages.append({"role": "user", "content": text})
|
||||||
|
self.focus = "input"
|
||||||
self.follow_stream = True
|
self.follow_stream = True
|
||||||
self.scroll = 0
|
self.scroll = 0
|
||||||
self.start_stream()
|
self.start_stream()
|
||||||
|
|
||||||
|
def joe_key(self, key):
|
||||||
|
# A small, useful subset of joe's ^K commands:
|
||||||
|
# ^KB = begin block, ^KK = end/copy block, ^KC = copy,
|
||||||
|
# ^KY = delete block, ^KU = unmark, ^Y = paste.
|
||||||
|
if key in ("b", "B"):
|
||||||
|
if self.focus == "input":
|
||||||
|
self.editor_mark_start()
|
||||||
|
else:
|
||||||
|
self.history_mark_start()
|
||||||
|
return True
|
||||||
|
if key in ("k", "K"):
|
||||||
|
if self.focus == "input":
|
||||||
|
self.editor_mark_end()
|
||||||
|
else:
|
||||||
|
self.history_mark_end()
|
||||||
|
return True
|
||||||
|
if key in ("c", "C"):
|
||||||
|
if self.focus == "input" and self.input_mark is not None:
|
||||||
|
a, b = sorted((self.input_mark, self.cursor))
|
||||||
|
self.kill_buffer = self.input[a:b]
|
||||||
|
return True
|
||||||
|
if key in ("y", "Y"):
|
||||||
|
if self.focus == "input":
|
||||||
|
self.paste()
|
||||||
|
return True
|
||||||
|
if key in ("x", "X"):
|
||||||
|
if self.focus == "input":
|
||||||
|
self.delete_marked()
|
||||||
|
return True
|
||||||
|
if key in ("u", "U"):
|
||||||
|
self.input_mark = None
|
||||||
|
self.history_mark = None
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def handle_key(self, key):
|
def handle_key(self, key):
|
||||||
|
if self.prefix_ctrl_b:
|
||||||
|
self.prefix_ctrl_b = False
|
||||||
|
if key == curses.KEY_UP:
|
||||||
|
self.history_focus("up")
|
||||||
|
return
|
||||||
|
if key == curses.KEY_DOWN:
|
||||||
|
self.history_focus("down")
|
||||||
|
return
|
||||||
|
if key == "b":
|
||||||
|
self.prefix_ctrl_b = True
|
||||||
|
return
|
||||||
|
return
|
||||||
|
|
||||||
|
if key == "\x02": # Ctrl-B, tmux-style prefix
|
||||||
|
self.prefix_ctrl_b = True
|
||||||
|
return
|
||||||
|
|
||||||
if key in ("\x04", "\x1b"):
|
if key in ("\x04", "\x1b"):
|
||||||
return "quit"
|
return "quit"
|
||||||
|
|
||||||
# Scroll the chat while AI is streaming or idle.
|
if self.focus == "history":
|
||||||
|
if key in (curses.KEY_UP, curses.KEY_DOWN, curses.KEY_LEFT, curses.KEY_RIGHT,
|
||||||
|
curses.KEY_PPAGE, curses.KEY_NPAGE):
|
||||||
|
self.history_move(key)
|
||||||
|
return
|
||||||
|
if key == "\x11": # Ctrl-Q as a convenient escape to input
|
||||||
|
self.return_to_input()
|
||||||
|
return
|
||||||
|
if key == "\x0b": # Ctrl-K
|
||||||
|
self.prefix_ctrl_k = True
|
||||||
|
return
|
||||||
|
if getattr(self, "prefix_ctrl_k", False):
|
||||||
|
self.prefix_ctrl_k = False
|
||||||
|
self.joe_key(key)
|
||||||
|
return
|
||||||
|
# Ctrl-K handling needs to happen before ordinary input.
|
||||||
|
if key == "\x0b":
|
||||||
|
self.prefix_ctrl_k = True
|
||||||
|
return
|
||||||
|
return
|
||||||
|
|
||||||
|
# Input focus.
|
||||||
|
multiline = self.input_visual_height(max(1, self.s.getmaxyx()[1])) > 1
|
||||||
|
|
||||||
|
if key == "\x0b": # Ctrl-K joe prefix
|
||||||
|
self.prefix_ctrl_k = True
|
||||||
|
return
|
||||||
|
|
||||||
|
if getattr(self, "prefix_ctrl_k", False):
|
||||||
|
self.prefix_ctrl_k = False
|
||||||
|
if self.joe_key(key):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Before the input wraps to multiple visual lines, arrows scroll history.
|
||||||
|
if not multiline and key in (curses.KEY_UP, curses.KEY_DOWN,
|
||||||
|
curses.KEY_LEFT, curses.KEY_RIGHT):
|
||||||
|
if key in (curses.KEY_UP, curses.KEY_LEFT):
|
||||||
|
self.scroll = min(
|
||||||
|
max(0, len(self.chat_lines(self.display_messages(), self.s.getmaxyx()[1]))),
|
||||||
|
self.scroll + 1,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.scroll = max(0, self.scroll - 1)
|
||||||
|
self.follow_stream = self.scroll == 0
|
||||||
|
return
|
||||||
|
|
||||||
if key == curses.KEY_PPAGE:
|
if key == curses.KEY_PPAGE:
|
||||||
self.follow_stream = False
|
self.follow_stream = False
|
||||||
self.scroll += 5
|
self.scroll += 5
|
||||||
return
|
return
|
||||||
|
|
||||||
if key == curses.KEY_NPAGE:
|
if key == curses.KEY_NPAGE:
|
||||||
self.scroll = max(0, self.scroll - 5)
|
self.scroll = max(0, self.scroll - 5)
|
||||||
if self.scroll == 0:
|
self.follow_stream = self.scroll == 0
|
||||||
self.follow_stream = True
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Arrow keys are normally editor navigation. When Ctrl is used,
|
|
||||||
# curses may expose them differently on various terminals.
|
|
||||||
if key == curses.KEY_LEFT:
|
if key == curses.KEY_LEFT:
|
||||||
self.move_left()
|
self.move_input("left")
|
||||||
return
|
return
|
||||||
|
|
||||||
if key == curses.KEY_RIGHT:
|
if key == curses.KEY_RIGHT:
|
||||||
self.move_right()
|
self.move_input("right")
|
||||||
return
|
return
|
||||||
|
|
||||||
if key == curses.KEY_UP:
|
if key == curses.KEY_UP:
|
||||||
self.move_vertical(-1)
|
self.move_input("up")
|
||||||
return
|
return
|
||||||
|
|
||||||
if key == curses.KEY_DOWN:
|
if key == curses.KEY_DOWN:
|
||||||
self.move_vertical(1)
|
self.move_input("down")
|
||||||
return
|
return
|
||||||
|
|
||||||
if key == curses.KEY_HOME:
|
if key == curses.KEY_HOME:
|
||||||
self.home()
|
self.home()
|
||||||
return
|
return
|
||||||
|
|
||||||
if key == curses.KEY_END:
|
if key == curses.KEY_END:
|
||||||
self.end()
|
self.end()
|
||||||
return
|
return
|
||||||
|
|
||||||
if key in (curses.KEY_BACKSPACE, 127, 8):
|
if key in (curses.KEY_BACKSPACE, 127, 8):
|
||||||
self.backspace()
|
self.backspace()
|
||||||
self.last_was_enter = False
|
self.last_was_enter = False
|
||||||
return
|
return
|
||||||
|
|
||||||
if key == curses.KEY_DC:
|
if key == curses.KEY_DC:
|
||||||
self.delete()
|
self.delete()
|
||||||
self.last_was_enter = False
|
self.last_was_enter = False
|
||||||
return
|
return
|
||||||
|
|
||||||
if key in ("\n", "\r", curses.KEY_ENTER):
|
if key in ("\n", "\r", curses.KEY_ENTER):
|
||||||
# Double Enter sends. The first Enter inserts a newline.
|
|
||||||
if self.last_was_enter and self.input.endswith("\n"):
|
if self.last_was_enter and self.input.endswith("\n"):
|
||||||
self.input = self.input[:-1]
|
self.input = self.input[:-1]
|
||||||
self.cursor = min(self.cursor, len(self.input))
|
self.cursor = min(self.cursor, len(self.input))
|
||||||
@@ -494,7 +547,6 @@ class App:
|
|||||||
self.insert("\n")
|
self.insert("\n")
|
||||||
self.last_was_enter = True
|
self.last_was_enter = True
|
||||||
return
|
return
|
||||||
|
|
||||||
if isinstance(key, str) and key.isprintable():
|
if isinstance(key, str) and key.isprintable():
|
||||||
self.insert(key)
|
self.insert(key)
|
||||||
self.last_was_enter = False
|
self.last_was_enter = False
|
||||||
@@ -506,28 +558,21 @@ class App:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
self.process_events()
|
self.process_events()
|
||||||
|
status = "AI skriver..." if self.streaming else (f"OpenRouter-fel: {self.stream_error}" if self.stream_error else "")
|
||||||
if self.streaming:
|
if self.focus == "history":
|
||||||
status = "AI skriver..."
|
status = "HISTORIK · Ctrl-B↓/↑ för fokusbyte · Ctrl-K B/K markerar"
|
||||||
elif self.stream_error:
|
elif self.prefix_ctrl_b:
|
||||||
status = f"OpenRouter-fel: {self.stream_error}"
|
status = "Ctrl-B · ↑/↓ = historik"
|
||||||
else:
|
self.draw(status)
|
||||||
status = ""
|
|
||||||
|
|
||||||
self.draw(status, self.display_messages())
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
key = self.s.get_wch()
|
key = self.s.get_wch()
|
||||||
except curses.error:
|
except curses.error:
|
||||||
# timeout() lets streaming update the display while waiting
|
|
||||||
# for keyboard input. curses raises ERR on timeout.
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if key == -1:
|
if key == -1:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result = self.handle_key(key)
|
result = self.handle_key(key)
|
||||||
|
|
||||||
if result == "quit":
|
if result == "quit":
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user