first commit
This commit is contained in:
0
src/ai/__init__.py
Normal file
0
src/ai/__init__.py
Normal file
4
src/ai/__main__.py
Normal file
4
src/ai/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
7
src/ai/cli.py
Normal file
7
src/ai/cli.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import argparse
|
||||
from .tui import run
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(prog="ai")
|
||||
parser.add_argument("chat", nargs="?")
|
||||
run(parser.parse_args().chat)
|
||||
59
src/ai/config.py
Normal file
59
src/ai/config.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
import yaml
|
||||
|
||||
def load(start):
|
||||
dirs = []
|
||||
p = start.resolve()
|
||||
|
||||
while True:
|
||||
dirs.append(p / ".ai")
|
||||
if p == p.parent:
|
||||
break
|
||||
p = p.parent
|
||||
|
||||
cfg = {
|
||||
"provider": "openrouter",
|
||||
"model": "openrouter/auto",
|
||||
"api_key": None,
|
||||
}
|
||||
instructions = []
|
||||
memories = []
|
||||
|
||||
for d in reversed(dirs):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
|
||||
f = d / "config.yaml"
|
||||
if f.is_file():
|
||||
data = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
|
||||
for key in ("provider", "model", "api_key"):
|
||||
if key in data:
|
||||
cfg[key] = str(data[key])
|
||||
|
||||
for name, target in (
|
||||
("instructions.md", instructions),
|
||||
("memory.md", memories),
|
||||
):
|
||||
f = d / name
|
||||
if f.is_file():
|
||||
text = f.read_text(encoding="utf-8").strip()
|
||||
if text:
|
||||
target.append(text)
|
||||
|
||||
memory_dir = d / "memories"
|
||||
if memory_dir.is_dir():
|
||||
for f in sorted(memory_dir.glob("*.md")):
|
||||
text = f.read_text(encoding="utf-8").strip()
|
||||
if text:
|
||||
memories.append(text)
|
||||
|
||||
cfg["api_key"] = os.environ.get("OPENROUTER_API_KEY") or cfg["api_key"]
|
||||
|
||||
if not cfg["api_key"]:
|
||||
raise RuntimeError(
|
||||
"Ingen OpenRouter API-nyckel. Sätt OPENROUTER_API_KEY "
|
||||
"eller api_key i ~/.ai/config.yaml"
|
||||
)
|
||||
|
||||
return cfg, instructions, memories
|
||||
69
src/ai/history.py
Normal file
69
src/ai/history.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import re
|
||||
import yaml
|
||||
|
||||
def slug(text):
|
||||
text = re.sub(r"[^\w\s-]", "", text.lower(), flags=re.UNICODE)
|
||||
return re.sub(r"[\s_-]+", "-", text).strip("-") or "chat"
|
||||
|
||||
def chat_path(directory, title):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{datetime.now():%Y-%m-%d}-{slug(title)}.md"
|
||||
number = 2
|
||||
|
||||
while path.exists():
|
||||
path = directory / f"{datetime.now():%Y-%m-%d}-{slug(title)}-{number}.md"
|
||||
number += 1
|
||||
|
||||
return path
|
||||
|
||||
def save(path, title, messages, model):
|
||||
meta = {
|
||||
"title": title,
|
||||
"created": datetime.now().astimezone().isoformat(),
|
||||
"model": model,
|
||||
}
|
||||
|
||||
out = [
|
||||
"---",
|
||||
yaml.safe_dump(meta, allow_unicode=True, sort_keys=False).strip(),
|
||||
"---",
|
||||
"",
|
||||
]
|
||||
|
||||
for message in messages:
|
||||
role = "You" if message["role"] == "user" else "AI"
|
||||
out += [f"## {role}", "", message["content"].rstrip(), ""]
|
||||
|
||||
path.write_text("\n".join(out), encoding="utf-8")
|
||||
|
||||
def load(path):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
title = path.stem
|
||||
|
||||
if text.startswith("---"):
|
||||
_, meta, body = text.split("---", 2)
|
||||
title = (yaml.safe_load(meta) or {}).get("title", title)
|
||||
text = body
|
||||
|
||||
messages = []
|
||||
role = None
|
||||
buffer = []
|
||||
|
||||
for line in text.splitlines():
|
||||
if line == "## You":
|
||||
if role:
|
||||
messages.append({"role": role, "content": "\n".join(buffer).strip()})
|
||||
role, buffer = "user", []
|
||||
elif line == "## AI":
|
||||
if role:
|
||||
messages.append({"role": role, "content": "\n".join(buffer).strip()})
|
||||
role, buffer = "assistant", []
|
||||
elif role:
|
||||
buffer.append(line)
|
||||
|
||||
if role:
|
||||
messages.append({"role": role, "content": "\n".join(buffer).strip()})
|
||||
|
||||
return title, [m for m in messages if m["content"]]
|
||||
87
src/ai/openrouter.py
Normal file
87
src/ai/openrouter.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import json
|
||||
import httpx
|
||||
|
||||
class OpenRouter:
|
||||
URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
|
||||
def __init__(self, key, model):
|
||||
self.key = key
|
||||
self.model = model
|
||||
|
||||
def headers(self):
|
||||
return {
|
||||
"Authorization": f"Bearer {self.key}",
|
||||
"Content-Type": "application/json",
|
||||
"X-Title": "ai terminal",
|
||||
}
|
||||
|
||||
def stream(self, messages, context):
|
||||
system = "\n\n".join(context)
|
||||
outgoing = (
|
||||
[{"role": "system", "content": system}] if system else []
|
||||
) + messages
|
||||
|
||||
with httpx.stream(
|
||||
"POST",
|
||||
self.URL,
|
||||
headers=self.headers(),
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": outgoing,
|
||||
"stream": True,
|
||||
},
|
||||
timeout=None,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
for line in response.iter_lines():
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
|
||||
data = line[5:].strip()
|
||||
|
||||
if data == "[DONE]":
|
||||
break
|
||||
|
||||
try:
|
||||
delta = (
|
||||
json.loads(data)["choices"][0]
|
||||
.get("delta", {})
|
||||
.get("content")
|
||||
)
|
||||
if delta:
|
||||
yield delta
|
||||
except (json.JSONDecodeError, KeyError, IndexError):
|
||||
continue
|
||||
|
||||
def title(self, messages):
|
||||
response = httpx.post(
|
||||
self.URL,
|
||||
headers=self.headers(),
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Decide whether this conversation has a specific "
|
||||
"meaningful topic. Greetings, pleasantries, small "
|
||||
"talk, generic questions and vague requests are "
|
||||
"NOT enough. If there is no specific topic, return "
|
||||
"exactly UNTITLED. Otherwise return a concise "
|
||||
"descriptive title of 2-6 words in the "
|
||||
"conversation's language. Return only the title."
|
||||
),
|
||||
},
|
||||
*messages,
|
||||
],
|
||||
"stream": False,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return (
|
||||
response.json()["choices"][0]["message"]["content"]
|
||||
.strip()
|
||||
.splitlines()[0][:80]
|
||||
)
|
||||
531
src/ai/tui.py
Normal file
531
src/ai/tui.py
Normal file
@@ -0,0 +1,531 @@
|
||||
import curses
|
||||
import queue
|
||||
import textwrap
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from .config import load
|
||||
from .history import load as load_chat, save, chat_path
|
||||
from .openrouter import OpenRouter
|
||||
|
||||
|
||||
class App:
|
||||
def __init__(self, screen, chat_file):
|
||||
self.s = screen
|
||||
self.s.keypad(True)
|
||||
self.s.timeout(50)
|
||||
|
||||
self.cfg, self.instructions, self.memories = load(Path.cwd())
|
||||
self.api = OpenRouter(self.cfg["api_key"], self.cfg["model"])
|
||||
|
||||
self.path = None
|
||||
self.title = "Ny chat"
|
||||
self.messages = []
|
||||
|
||||
# Multiline editor.
|
||||
self.input = ""
|
||||
self.cursor = 0
|
||||
self.preferred_column = None
|
||||
self.last_was_enter = False
|
||||
|
||||
# Streaming worker.
|
||||
self.events = queue.Queue()
|
||||
self.streaming = False
|
||||
self.stream_answer = ""
|
||||
self.stream_error = None
|
||||
|
||||
# Viewport.
|
||||
self.scroll = 0
|
||||
self.follow_stream = True
|
||||
|
||||
if chat_file:
|
||||
path = Path(chat_file).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
|
||||
if path.exists():
|
||||
self.path = path
|
||||
self.title, self.messages = load_chat(path)
|
||||
|
||||
def setup_colors(self):
|
||||
if not curses.has_colors():
|
||||
return
|
||||
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_CYAN, -1) # You
|
||||
curses.init_pair(2, curses.COLOR_GREEN, -1) # AI
|
||||
curses.init_pair(3, curses.COLOR_YELLOW, -1) # status
|
||||
curses.init_pair(4, curses.COLOR_WHITE, -1)
|
||||
|
||||
def wrap_text(self, text, width):
|
||||
result = []
|
||||
|
||||
for paragraph in text.split("\n"):
|
||||
if paragraph == "":
|
||||
result.append("")
|
||||
else:
|
||||
result.extend(
|
||||
textwrap.wrap(
|
||||
paragraph,
|
||||
width=max(1, width),
|
||||
break_long_words=True,
|
||||
break_on_hyphens=False,
|
||||
) or [""]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def chat_lines(self, messages, width):
|
||||
lines = []
|
||||
|
||||
for message in messages:
|
||||
prefix = "You: " if message["role"] == "user" else "AI: "
|
||||
continuation = " "
|
||||
|
||||
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:
|
||||
wrapped = [""]
|
||||
|
||||
lines.append(
|
||||
(message["role"], prefix + wrapped[0])
|
||||
)
|
||||
|
||||
for line in wrapped[1:]:
|
||||
lines.append(
|
||||
(message["role"], continuation + line)
|
||||
)
|
||||
|
||||
return lines
|
||||
|
||||
def input_lines(self, width):
|
||||
prefix = "> "
|
||||
continuation = " "
|
||||
available_first = max(1, width - len(prefix))
|
||||
available_rest = max(1, width - len(continuation))
|
||||
|
||||
logical_lines = self.input.split("\n")
|
||||
if not logical_lines:
|
||||
logical_lines = [""]
|
||||
|
||||
rendered = []
|
||||
cursor_locations = []
|
||||
|
||||
cursor_pos = self.cursor
|
||||
absolute = 0
|
||||
|
||||
for logical in logical_lines:
|
||||
if absolute + len(logical) < cursor_pos:
|
||||
absolute += len(logical) + 1
|
||||
continue
|
||||
|
||||
local_cursor = cursor_pos - absolute
|
||||
local_cursor = max(0, min(local_cursor, len(logical)))
|
||||
|
||||
first = True
|
||||
remaining = logical
|
||||
|
||||
if remaining == "":
|
||||
rendered.append((prefix if first else continuation, ""))
|
||||
cursor_locations.append(
|
||||
(len(rendered) - 1, len(prefix) + local_cursor)
|
||||
)
|
||||
else:
|
||||
while remaining:
|
||||
width_for_line = available_first if first else available_rest
|
||||
piece = remaining[:width_for_line]
|
||||
remaining = remaining[width_for_line:]
|
||||
|
||||
rendered.append(
|
||||
(
|
||||
prefix if first else continuation,
|
||||
piece,
|
||||
)
|
||||
)
|
||||
|
||||
if first and local_cursor <= len(piece):
|
||||
cursor_locations.append(
|
||||
(len(rendered) - 1, len(prefix) + local_cursor)
|
||||
)
|
||||
local_cursor = -1
|
||||
elif not first and local_cursor >= 0:
|
||||
if local_cursor <= len(piece):
|
||||
cursor_locations.append(
|
||||
(len(rendered) - 1, len(continuation) + local_cursor)
|
||||
)
|
||||
local_cursor = -1
|
||||
else:
|
||||
local_cursor -= len(piece)
|
||||
|
||||
first = False
|
||||
|
||||
absolute += len(logical) + 1
|
||||
|
||||
if self.cursor == len(self.input) and not cursor_locations:
|
||||
# Cursor at the end.
|
||||
last = len(rendered) - 1 if rendered else 0
|
||||
text = rendered[last][1] if rendered else ""
|
||||
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 total_input_height(self, width):
|
||||
rendered, _ = self.input_lines(width)
|
||||
return max(1, len(rendered))
|
||||
|
||||
def draw(self, status="", messages=None):
|
||||
screen = self.s
|
||||
height, width = screen.getmaxyx()
|
||||
screen.erase()
|
||||
|
||||
self.setup_colors()
|
||||
|
||||
screen.addnstr(
|
||||
0,
|
||||
0,
|
||||
f" AI · {self.title} ",
|
||||
max(0, width - 1),
|
||||
curses.A_REVERSE,
|
||||
)
|
||||
|
||||
visible_messages = self.messages if messages is None else messages
|
||||
chat = self.chat_lines(visible_messages, max(1, width))
|
||||
|
||||
input_height = min(
|
||||
max(1, height - 4),
|
||||
self.total_input_height(max(1, width)),
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
if self.follow_stream:
|
||||
start = max_scroll
|
||||
self.scroll = max_scroll
|
||||
else:
|
||||
start = max(0, min(self.scroll, max_scroll))
|
||||
|
||||
for y, (role, line) in enumerate(
|
||||
chat[start:start + chat_available],
|
||||
chat_top,
|
||||
):
|
||||
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(
|
||||
y,
|
||||
0,
|
||||
line,
|
||||
max(0, width),
|
||||
attr,
|
||||
)
|
||||
|
||||
screen.hline(
|
||||
separator_row,
|
||||
0,
|
||||
curses.ACS_HLINE,
|
||||
width,
|
||||
)
|
||||
|
||||
input_rendered, cursor_location = self.input_lines(max(1, width))
|
||||
|
||||
# Only show the last input lines if the editor is taller than the
|
||||
# available terminal area.
|
||||
input_rendered = input_rendered[-input_height:]
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
screen.refresh()
|
||||
|
||||
def should_request_title(self):
|
||||
users = [
|
||||
m["content"].strip()
|
||||
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):
|
||||
self.streaming = True
|
||||
self.stream_answer = ""
|
||||
self.stream_error = None
|
||||
|
||||
messages = list(self.messages)
|
||||
context = self.instructions + self.memories
|
||||
|
||||
def worker():
|
||||
try:
|
||||
for chunk in self.api.stream(messages, context):
|
||||
self.events.put(("chunk", chunk))
|
||||
self.events.put(("done", None))
|
||||
except Exception as exc:
|
||||
self.events.put(("error", exc))
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def process_events(self):
|
||||
changed = False
|
||||
|
||||
while True:
|
||||
try:
|
||||
event, value = self.events.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
changed = True
|
||||
|
||||
if event == "chunk":
|
||||
self.stream_answer += value
|
||||
|
||||
elif event == "done":
|
||||
if self.stream_answer:
|
||||
self.messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": self.stream_answer,
|
||||
}
|
||||
)
|
||||
|
||||
self.streaming = False
|
||||
|
||||
if self.path is None and self.should_request_title():
|
||||
try:
|
||||
title = self.api.title(self.messages)
|
||||
if title and title != "UNTITLED":
|
||||
self.title = title
|
||||
self.path = chat_path(Path.cwd(), title)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.path:
|
||||
save(
|
||||
self.path,
|
||||
self.title,
|
||||
self.messages,
|
||||
self.cfg["model"],
|
||||
)
|
||||
|
||||
elif event == "error":
|
||||
self.stream_error = str(value)
|
||||
self.streaming = False
|
||||
|
||||
return changed
|
||||
|
||||
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):
|
||||
before = self.input[:self.cursor]
|
||||
line = before.count("\n")
|
||||
last_newline = before.rfind("\n")
|
||||
col = self.cursor if last_newline < 0 else self.cursor - last_newline - 1
|
||||
return line, col
|
||||
|
||||
def cursor_from_line_col(self, target_line, target_col):
|
||||
lines = self.input.split("\n")
|
||||
target_line = max(0, min(target_line, len(lines) - 1))
|
||||
pos = sum(len(x) + 1 for x in lines[:target_line])
|
||||
return min(pos + target_col, pos + len(lines[target_line]))
|
||||
|
||||
def move_left(self):
|
||||
if self.cursor > 0:
|
||||
self.cursor -= 1
|
||||
|
||||
def move_right(self):
|
||||
if self.cursor < len(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):
|
||||
line, _ = self.line_col()
|
||||
self.cursor = self.cursor_from_line_col(line, 0)
|
||||
|
||||
def end(self):
|
||||
line, _ = self.line_col()
|
||||
lines = self.input.split("\n")
|
||||
self.cursor = self.cursor_from_line_col(line, len(lines[line]))
|
||||
|
||||
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 > 0:
|
||||
self.input = self.input[:self.cursor - 1] + self.input[self.cursor:]
|
||||
self.cursor -= 1
|
||||
|
||||
def delete(self):
|
||||
if self.cursor < len(self.input):
|
||||
self.input = self.input[:self.cursor] + self.input[self.cursor + 1:]
|
||||
|
||||
def submit(self):
|
||||
text = self.input.strip()
|
||||
|
||||
if not text:
|
||||
return
|
||||
|
||||
self.input = ""
|
||||
self.cursor = 0
|
||||
self.last_was_enter = False
|
||||
|
||||
self.messages.append({"role": "user", "content": text})
|
||||
self.follow_stream = True
|
||||
self.scroll = 0
|
||||
self.start_stream()
|
||||
|
||||
def handle_key(self, key):
|
||||
if key in ("\x04", "\x1b"):
|
||||
return "quit"
|
||||
|
||||
# Scroll the chat while AI is streaming or idle.
|
||||
if key == curses.KEY_PPAGE:
|
||||
self.follow_stream = False
|
||||
self.scroll += 5
|
||||
return
|
||||
|
||||
if key == curses.KEY_NPAGE:
|
||||
self.scroll = max(0, self.scroll - 5)
|
||||
if self.scroll == 0:
|
||||
self.follow_stream = True
|
||||
return
|
||||
|
||||
# Arrow keys are normally editor navigation. When Ctrl is used,
|
||||
# curses may expose them differently on various terminals.
|
||||
if key == curses.KEY_LEFT:
|
||||
self.move_left()
|
||||
return
|
||||
|
||||
if key == curses.KEY_RIGHT:
|
||||
self.move_right()
|
||||
return
|
||||
|
||||
if key == curses.KEY_UP:
|
||||
self.move_vertical(-1)
|
||||
return
|
||||
|
||||
if key == curses.KEY_DOWN:
|
||||
self.move_vertical(1)
|
||||
return
|
||||
|
||||
if key == curses.KEY_HOME:
|
||||
self.home()
|
||||
return
|
||||
|
||||
if key == curses.KEY_END:
|
||||
self.end()
|
||||
return
|
||||
|
||||
if key in (curses.KEY_BACKSPACE, 127, 8):
|
||||
self.backspace()
|
||||
self.last_was_enter = False
|
||||
return
|
||||
|
||||
if key == curses.KEY_DC:
|
||||
self.delete()
|
||||
self.last_was_enter = False
|
||||
return
|
||||
|
||||
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"):
|
||||
self.input = self.input[:-1]
|
||||
self.cursor = min(self.cursor, len(self.input))
|
||||
self.submit()
|
||||
else:
|
||||
self.insert("\n")
|
||||
self.last_was_enter = True
|
||||
return
|
||||
|
||||
if isinstance(key, str) and key.isprintable():
|
||||
self.insert(key)
|
||||
self.last_was_enter = False
|
||||
|
||||
def run(self):
|
||||
curses.curs_set(1)
|
||||
self.setup_colors()
|
||||
self.draw()
|
||||
|
||||
while True:
|
||||
self.process_events()
|
||||
|
||||
if self.streaming:
|
||||
status = "AI skriver..."
|
||||
elif self.stream_error:
|
||||
status = f"OpenRouter-fel: {self.stream_error}"
|
||||
else:
|
||||
status = ""
|
||||
|
||||
self.draw(status, self.display_messages())
|
||||
|
||||
key = self.s.get_wch()
|
||||
|
||||
if key == -1:
|
||||
continue
|
||||
|
||||
result = self.handle_key(key)
|
||||
|
||||
if result == "quit":
|
||||
return
|
||||
|
||||
|
||||
def run(chat_file=None):
|
||||
curses.wrapper(lambda screen: App(screen, chat_file).run())
|
||||
Reference in New Issue
Block a user