termuxcode 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- termuxcode/__init__.py +7 -0
- termuxcode/cli.py +12 -0
- termuxcode/tui/__init__.py +6 -0
- termuxcode/tui/agent.py +96 -0
- termuxcode/tui/app.py +176 -0
- termuxcode/tui/chat.py +54 -0
- termuxcode/tui/history.py +72 -0
- termuxcode/tui/sessions.py +113 -0
- termuxcode/tui/styles.py +71 -0
- termuxcode-0.2.0.dist-info/METADATA +40 -0
- termuxcode-0.2.0.dist-info/RECORD +14 -0
- termuxcode-0.2.0.dist-info/WHEEL +5 -0
- termuxcode-0.2.0.dist-info/entry_points.txt +2 -0
- termuxcode-0.2.0.dist-info/top_level.txt +1 -0
termuxcode/__init__.py
ADDED
termuxcode/cli.py
ADDED
termuxcode/tui/agent.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Módulo para comunicarse con el agente Claude con historial en JSONL"""
|
|
2
|
+
import os
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from claude_agent_sdk import query, ClaudeAgentOptions
|
|
6
|
+
|
|
7
|
+
from .history import MessageHistory
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .chat import ChatLog
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AgentClient:
|
|
14
|
+
"""Cliente para comunicarse con Claude Agent SDK con historial en JSONL"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
chat_log: 'ChatLog',
|
|
19
|
+
history: MessageHistory,
|
|
20
|
+
cwd: str = None,
|
|
21
|
+
):
|
|
22
|
+
self.chat_log = chat_log
|
|
23
|
+
self.history = history
|
|
24
|
+
self.cwd = cwd or os.getcwd()
|
|
25
|
+
|
|
26
|
+
async def query(self, prompt: str) -> None:
|
|
27
|
+
"""Ejecutar query del agente con historial en JSONL"""
|
|
28
|
+
|
|
29
|
+
# Mostrar mensaje del usuario en la UI
|
|
30
|
+
self.chat_log.write_user(prompt)
|
|
31
|
+
|
|
32
|
+
# Cargar historial (ya está truncado a max_messages por save())
|
|
33
|
+
history = self.history.load()
|
|
34
|
+
|
|
35
|
+
# Construir prompt con el historial y el nuevo mensaje
|
|
36
|
+
full_prompt = self.history.build_prompt(history, prompt)
|
|
37
|
+
|
|
38
|
+
# Usar query() del SDK
|
|
39
|
+
options = ClaudeAgentOptions(
|
|
40
|
+
permission_mode="bypassPermissions",
|
|
41
|
+
cwd=self.cwd,
|
|
42
|
+
include_partial_messages=False,
|
|
43
|
+
cli_path="/data/data/com.termux/files/home/.claude/local/claude",
|
|
44
|
+
max_budget_usd=0.10,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
old_claudecode = os.environ.pop('CLAUDECODE', None)
|
|
48
|
+
assistant_response = ""
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
async for message in query(prompt=full_prompt, options=options):
|
|
52
|
+
await self._process_message(message)
|
|
53
|
+
# Acumular la respuesta del asistente
|
|
54
|
+
if hasattr(message, 'content') and isinstance(message.content, list):
|
|
55
|
+
for block in message.content:
|
|
56
|
+
if block.__class__.__name__ == "TextBlock":
|
|
57
|
+
assistant_response += block.text
|
|
58
|
+
finally:
|
|
59
|
+
if old_claudecode:
|
|
60
|
+
os.environ['CLAUDECODE'] = old_claudecode
|
|
61
|
+
|
|
62
|
+
# Guardar mensaje del usuario y respuesta del asistente en historial
|
|
63
|
+
self.history.append("user", prompt)
|
|
64
|
+
if assistant_response:
|
|
65
|
+
self.history.append("assistant", assistant_response)
|
|
66
|
+
|
|
67
|
+
async def _process_message(self, message) -> None:
|
|
68
|
+
"""Procesar mensaje del agente"""
|
|
69
|
+
msg_type = message.__class__.__name__
|
|
70
|
+
|
|
71
|
+
if msg_type == "ResultMessage":
|
|
72
|
+
return
|
|
73
|
+
elif msg_type == "AssistantMessage":
|
|
74
|
+
await self._process_assistant(message)
|
|
75
|
+
elif msg_type == "UserMessage":
|
|
76
|
+
await self._process_user(message)
|
|
77
|
+
|
|
78
|
+
async def _process_assistant(self, message) -> None:
|
|
79
|
+
"""Procesar AssistantMessage"""
|
|
80
|
+
if hasattr(message, 'content') and isinstance(message.content, list):
|
|
81
|
+
for block in message.content:
|
|
82
|
+
block_type = block.__class__.__name__
|
|
83
|
+
|
|
84
|
+
if block_type == "TextBlock":
|
|
85
|
+
self.chat_log.write_assistant(block.text)
|
|
86
|
+
|
|
87
|
+
elif block_type == "ToolUseBlock":
|
|
88
|
+
tool_input = str(block.input) if hasattr(block, 'input') else None
|
|
89
|
+
self.chat_log.write_tool(block.name, tool_input)
|
|
90
|
+
|
|
91
|
+
async def _process_user(self, message) -> None:
|
|
92
|
+
"""Procesar UserMessage (tool results)"""
|
|
93
|
+
if hasattr(message, 'content') and isinstance(message.content, list):
|
|
94
|
+
for block in message.content:
|
|
95
|
+
if block.__class__.__name__ == "ToolResultBlock":
|
|
96
|
+
self.chat_log.write_result(str(block.content))
|
termuxcode/tui/app.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""App principal - TUI responsive para Claude Agent SDK"""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from textual.app import App, ComposeResult
|
|
4
|
+
from textual.containers import Vertical, VerticalScroll
|
|
5
|
+
from textual.widgets import Header, Input, Tabs, Tab
|
|
6
|
+
|
|
7
|
+
from .chat import ChatLog
|
|
8
|
+
from .agent import AgentClient
|
|
9
|
+
from .history import MessageHistory
|
|
10
|
+
from .sessions import SessionManager
|
|
11
|
+
from .styles import CSS
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ClaudeChat(App):
|
|
15
|
+
"""TUI responsive para chat con Claude Agent SDK"""
|
|
16
|
+
|
|
17
|
+
CSS = CSS
|
|
18
|
+
TITLE = "Claude Chat"
|
|
19
|
+
AUTO_FOCUS = "#message-input"
|
|
20
|
+
|
|
21
|
+
BINDINGS = [
|
|
22
|
+
("ctrl+n", "new_session", "Nueva sesión"),
|
|
23
|
+
("ctrl+w", "close_session", "Cerrar sesión"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
def __init__(self, cwd: str = None, max_history: int = 100):
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.cwd = cwd or str(Path.cwd())
|
|
29
|
+
self.max_history = max_history
|
|
30
|
+
self.session_manager = SessionManager(Path(self.cwd) / ".sessions")
|
|
31
|
+
self._current_session_id: str | None = None
|
|
32
|
+
self._sessions: dict[str, MessageHistory] = {}
|
|
33
|
+
self.history: MessageHistory | None = None
|
|
34
|
+
self.agent: AgentClient | None = None
|
|
35
|
+
|
|
36
|
+
def compose(self) -> ComposeResult:
|
|
37
|
+
yield Header(id="header")
|
|
38
|
+
with VerticalScroll(id="chat-container"):
|
|
39
|
+
yield ChatLog(id="messages")
|
|
40
|
+
with Vertical(id="bottom-container"):
|
|
41
|
+
yield Tabs(id="sessions-tabs")
|
|
42
|
+
with Vertical(id="input-container"):
|
|
43
|
+
yield Input(id="message-input", placeholder="Escribe tu mensaje...")
|
|
44
|
+
|
|
45
|
+
def on_mount(self) -> None:
|
|
46
|
+
self.chat_log = self.query_one("#messages", ChatLog)
|
|
47
|
+
self.tabs = self.query_one("#sessions-tabs", Tabs)
|
|
48
|
+
self.input = self.query_one("#message-input", Input)
|
|
49
|
+
self.query_one("#chat-container").anchor() # Auto-scroll al final
|
|
50
|
+
self.call_later(self._load_first_session) # Cargar sesión inicial
|
|
51
|
+
|
|
52
|
+
async def _load_first_session(self) -> None:
|
|
53
|
+
"""Cargar sesiones existentes o crear la primera al iniciar la app"""
|
|
54
|
+
sessions = self.session_manager.list_sessions()
|
|
55
|
+
if not sessions:
|
|
56
|
+
session = self.session_manager.create_session("Nueva sesión")
|
|
57
|
+
else:
|
|
58
|
+
last_id = self.session_manager.get_last_active()
|
|
59
|
+
session = self.session_manager.get_session(last_id) or sessions[0]
|
|
60
|
+
|
|
61
|
+
await self._switch_to_session(session.id)
|
|
62
|
+
|
|
63
|
+
async def _load_history(self) -> None:
|
|
64
|
+
"""Cargar y mostrar el historial de mensajes"""
|
|
65
|
+
history = self.history.load()
|
|
66
|
+
for msg in history:
|
|
67
|
+
role = msg.get("role", "")
|
|
68
|
+
content = msg.get("content", "")
|
|
69
|
+
if role == "user":
|
|
70
|
+
self.chat_log.write_user(content)
|
|
71
|
+
elif role == "assistant":
|
|
72
|
+
self.chat_log.write_assistant(content)
|
|
73
|
+
|
|
74
|
+
async def _switch_to_session(self, session_id: str) -> None:
|
|
75
|
+
"""Cambiar a una sesión específica"""
|
|
76
|
+
session = self.session_manager.get_session(session_id)
|
|
77
|
+
if not session:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
self._current_session_id = session_id
|
|
81
|
+
|
|
82
|
+
# Obtener o crear MessageHistory para esta sesión
|
|
83
|
+
if session_id not in self._sessions:
|
|
84
|
+
self._sessions[session_id] = MessageHistory(
|
|
85
|
+
filename="messages.jsonl",
|
|
86
|
+
max_messages=self.max_history,
|
|
87
|
+
session_id=session_id,
|
|
88
|
+
cwd=self.cwd
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
self.history = self._sessions[session_id]
|
|
92
|
+
self.agent = AgentClient(self.chat_log, self.history, self.cwd)
|
|
93
|
+
|
|
94
|
+
# Actualizar tabs
|
|
95
|
+
self._update_tabs()
|
|
96
|
+
|
|
97
|
+
# Guardar última sesión activa
|
|
98
|
+
self.session_manager.set_last_active(session_id)
|
|
99
|
+
|
|
100
|
+
# Recargar historial en ChatLog
|
|
101
|
+
self.chat_log.clear()
|
|
102
|
+
await self._load_history()
|
|
103
|
+
|
|
104
|
+
def _update_tabs(self) -> None:
|
|
105
|
+
"""Actualizar los tabs basado en sesiones existentes"""
|
|
106
|
+
self.tabs.clear()
|
|
107
|
+
|
|
108
|
+
for session in self.session_manager.list_sessions():
|
|
109
|
+
tab_id = f"tab-{session.id}"
|
|
110
|
+
self.tabs.add(Tab(session.name, id=tab_id))
|
|
111
|
+
|
|
112
|
+
# Activar tab actual
|
|
113
|
+
if self._current_session_id:
|
|
114
|
+
for tab in self.tabs.children:
|
|
115
|
+
if tab.id == f"tab-{self._current_session_id}":
|
|
116
|
+
self.tabs.active_tab = tab
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
|
|
120
|
+
"""Cambio de sesión al hacer clic o presionar Enter en un tab"""
|
|
121
|
+
tab_id = event.tab.id
|
|
122
|
+
if tab_id and tab_id.startswith("tab-"):
|
|
123
|
+
session_id = tab_id[4:] # Remover prefijo "tab-"
|
|
124
|
+
self.call_later(self._run_switch_session, session_id)
|
|
125
|
+
|
|
126
|
+
def _run_switch_session(self, session_id: str) -> None:
|
|
127
|
+
"""Wrapper async para cambiar de sesión"""
|
|
128
|
+
import asyncio
|
|
129
|
+
asyncio.create_task(self._switch_to_session(session_id))
|
|
130
|
+
|
|
131
|
+
def action_new_session(self) -> None:
|
|
132
|
+
"""Crear nueva sesión (Ctrl+N)"""
|
|
133
|
+
session = self.session_manager.create_session()
|
|
134
|
+
self.call_later(self._run_switch_session, session.id)
|
|
135
|
+
self.input.focus()
|
|
136
|
+
|
|
137
|
+
def action_close_session(self) -> None:
|
|
138
|
+
"""Cerrar sesión actual (Ctrl+W)"""
|
|
139
|
+
sessions = self.session_manager.list_sessions()
|
|
140
|
+
if len(sessions) <= 1:
|
|
141
|
+
return # No cerrar la última sesión
|
|
142
|
+
|
|
143
|
+
if self._current_session_id:
|
|
144
|
+
self.session_manager.delete_session(self._current_session_id)
|
|
145
|
+
del self._sessions[self._current_session_id]
|
|
146
|
+
|
|
147
|
+
# Cambiar a la primera sesión disponible
|
|
148
|
+
sessions = self.session_manager.list_sessions()
|
|
149
|
+
self.call_later(self._run_switch_session, sessions[0].id)
|
|
150
|
+
|
|
151
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
152
|
+
if not event.value.strip():
|
|
153
|
+
return
|
|
154
|
+
prompt = event.value
|
|
155
|
+
event.input.clear()
|
|
156
|
+
self.call_later(self._run_query, prompt)
|
|
157
|
+
|
|
158
|
+
async def _run_query(self, prompt: str) -> None:
|
|
159
|
+
"""Ejecutar query del agente"""
|
|
160
|
+
if self.agent is None:
|
|
161
|
+
self.chat_log.write_error("No session loaded. Please wait...")
|
|
162
|
+
return
|
|
163
|
+
await self.agent.query(prompt)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
from pathlib import Path
|
|
168
|
+
import sys
|
|
169
|
+
|
|
170
|
+
project_root = Path(__file__).parent.parent.parent
|
|
171
|
+
src_path = project_root / "src"
|
|
172
|
+
if str(src_path) not in sys.path:
|
|
173
|
+
sys.path.insert(0, str(src_path))
|
|
174
|
+
|
|
175
|
+
app = ClaudeChat()
|
|
176
|
+
app.run()
|
termuxcode/tui/chat.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Widget de chat con auto-scroll, wrapping y markdown"""
|
|
2
|
+
from textual.widgets import RichLog
|
|
3
|
+
from rich.markdown import Markdown
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ChatLog(RichLog):
|
|
7
|
+
"""Widget de chat con auto-scroll, word wrapping y markdown"""
|
|
8
|
+
|
|
9
|
+
def __init__(self, **kwargs):
|
|
10
|
+
super().__init__(
|
|
11
|
+
wrap=True,
|
|
12
|
+
auto_scroll=True,
|
|
13
|
+
markup=True,
|
|
14
|
+
min_width=0,
|
|
15
|
+
**kwargs
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def write_user(self, content: str) -> None:
|
|
19
|
+
"""Escribir mensaje del usuario"""
|
|
20
|
+
self.write(f"[bold cyan]You:[/] {content}\n")
|
|
21
|
+
|
|
22
|
+
def write_assistant(self, content: str) -> None:
|
|
23
|
+
"""Escribir mensaje del asistente con soporte markdown"""
|
|
24
|
+
self.write(f"[bold green]Claude:[/]\n")
|
|
25
|
+
try:
|
|
26
|
+
md = Markdown(content)
|
|
27
|
+
self.write(md)
|
|
28
|
+
except Exception:
|
|
29
|
+
self.write(f"{content}\n")
|
|
30
|
+
|
|
31
|
+
def write_tool(self, tool_name: str, tool_input: str = None) -> None:
|
|
32
|
+
"""Escribir herramienta usada (max 2 líneas)"""
|
|
33
|
+
self.write(f"[dim][yellow]🔧 {tool_name}[/yellow][/dim]")
|
|
34
|
+
if tool_input:
|
|
35
|
+
lines = str(tool_input).split('\n')[:2]
|
|
36
|
+
preview = '\n'.join(lines)
|
|
37
|
+
if len(lines) == 2 or len(tool_input) > len(preview):
|
|
38
|
+
preview = preview + "..."
|
|
39
|
+
self.write(f"[dim][sub]{preview}[/sub][/dim]")
|
|
40
|
+
self.write("")
|
|
41
|
+
|
|
42
|
+
def write_result(self, content: str) -> None:
|
|
43
|
+
"""Escribir resultado de herramienta (max 2 líneas)"""
|
|
44
|
+
self.write(f"[dim][sub]✅ Result:[/sub][/dim]")
|
|
45
|
+
lines = str(content).split('\n')[:2]
|
|
46
|
+
preview = '\n'.join(lines)
|
|
47
|
+
if len(lines) == 2 or len(content) > len(preview):
|
|
48
|
+
preview = preview + "..."
|
|
49
|
+
self.write(f"[dim][sub]{preview}[/sub][/dim]")
|
|
50
|
+
self.write("")
|
|
51
|
+
|
|
52
|
+
def write_error(self, error: str) -> None:
|
|
53
|
+
"""Escribir error"""
|
|
54
|
+
self.write(f"[bold red]❌ Error: {error}[/]\n")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Módulo para manejo de historial de conversación en JSONL"""
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MessageHistory:
|
|
9
|
+
"""Gestiona el historial de mensajes en formato JSONL"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, filename: str = "messages.jsonl", max_messages: int = 100,
|
|
12
|
+
session_id: str = None, cwd: str = None):
|
|
13
|
+
self.max_messages = max_messages
|
|
14
|
+
self.cwd = Path(cwd) if cwd else Path.cwd()
|
|
15
|
+
self.base_dir = self._get_history_dir()
|
|
16
|
+
self.session_id = session_id or str(uuid.uuid4())[:8]
|
|
17
|
+
self.filename = filename.replace(".jsonl", f"_{self.session_id}.jsonl")
|
|
18
|
+
self._history_file = self.base_dir / self.filename
|
|
19
|
+
|
|
20
|
+
def _get_history_dir(self) -> Path:
|
|
21
|
+
"""Retorna el directorio donde se guarda el historial"""
|
|
22
|
+
sessions_dir = self.cwd / ".sessions"
|
|
23
|
+
sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
return sessions_dir
|
|
25
|
+
|
|
26
|
+
def load(self) -> list[dict]:
|
|
27
|
+
"""Carga el historial desde el archivo JSONL"""
|
|
28
|
+
if not self._history_file.exists():
|
|
29
|
+
return []
|
|
30
|
+
with open(self._history_file, 'r', encoding='utf-8') as f:
|
|
31
|
+
return [json.loads(line) for line in f if line.strip()]
|
|
32
|
+
|
|
33
|
+
def save(self, messages: list[dict]) -> None:
|
|
34
|
+
"""Guarda el historial en el archivo JSONL (limitado a max_messages)"""
|
|
35
|
+
messages_to_save = messages[-self.max_messages:]
|
|
36
|
+
with open(self._history_file, 'w', encoding='utf-8') as f:
|
|
37
|
+
for msg in messages_to_save:
|
|
38
|
+
f.write(json.dumps(msg, ensure_ascii=False) + '\n')
|
|
39
|
+
|
|
40
|
+
def append(self, role: str, content: str) -> list[dict]:
|
|
41
|
+
"""Agrega un mensaje al historial y lo guarda"""
|
|
42
|
+
history = self.load()
|
|
43
|
+
history.append({"role": role, "content": content})
|
|
44
|
+
self.save(history)
|
|
45
|
+
return history
|
|
46
|
+
|
|
47
|
+
def build_prompt(self, history: list[dict], new_message: str) -> str:
|
|
48
|
+
"""Construye el prompt con el historial de conversación"""
|
|
49
|
+
prompt = ""
|
|
50
|
+
for msg in history:
|
|
51
|
+
role = msg.get("role", "")
|
|
52
|
+
content = msg.get("content", "")
|
|
53
|
+
if role == "user":
|
|
54
|
+
prompt += f"User: {content}\n\n"
|
|
55
|
+
elif role == "assistant":
|
|
56
|
+
prompt += f"Assistant: {content}\n\n"
|
|
57
|
+
prompt += f"User: {new_message}\n\nAssistant:"
|
|
58
|
+
return prompt
|
|
59
|
+
|
|
60
|
+
def clear(self) -> None:
|
|
61
|
+
"""Limpia el historial"""
|
|
62
|
+
if self._history_file.exists():
|
|
63
|
+
self._history_file.unlink()
|
|
64
|
+
|
|
65
|
+
def count(self) -> int:
|
|
66
|
+
"""Retorna la cantidad de mensajes en el historial"""
|
|
67
|
+
return len(self.load())
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def filepath(self) -> Path:
|
|
71
|
+
"""Retorna la ruta del archivo de historial"""
|
|
72
|
+
return self._history_file
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Módulo para gestionar múltiples sesiones de chat"""
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Session:
|
|
11
|
+
"""Representa una sesión de chat"""
|
|
12
|
+
id: str
|
|
13
|
+
name: str
|
|
14
|
+
created_at: str
|
|
15
|
+
history_file: Path
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_dict(cls, data: dict, sessions_dir: Path) -> "Session":
|
|
19
|
+
"""Crear Session desde dict JSON"""
|
|
20
|
+
return cls(
|
|
21
|
+
id=data["id"],
|
|
22
|
+
name=data["name"],
|
|
23
|
+
created_at=data["created_at"],
|
|
24
|
+
history_file=sessions_dir / f"messages_{data['id']}.jsonl"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict:
|
|
28
|
+
"""Convertir a dict para JSON"""
|
|
29
|
+
return {
|
|
30
|
+
"id": self.id,
|
|
31
|
+
"name": self.name,
|
|
32
|
+
"created_at": self.created_at
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SessionManager:
|
|
37
|
+
"""Gestiona múltiples sesiones de chat"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, sessions_dir: Path):
|
|
40
|
+
self.sessions_dir = sessions_dir
|
|
41
|
+
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
self._index_file = sessions_dir / "sessions.json"
|
|
43
|
+
self._sessions: dict[str, Session] = {}
|
|
44
|
+
self._load_sessions()
|
|
45
|
+
|
|
46
|
+
def _load_sessions(self):
|
|
47
|
+
"""Cargar sesiones desde el archivo de índice"""
|
|
48
|
+
if self._index_file.exists():
|
|
49
|
+
data = json.loads(self._index_file.read_text())
|
|
50
|
+
for s in data:
|
|
51
|
+
session = Session.from_dict(s, self.sessions_dir)
|
|
52
|
+
self._sessions[session.id] = session
|
|
53
|
+
|
|
54
|
+
def _save_sessions(self):
|
|
55
|
+
"""Guardar sesiones al archivo de índice"""
|
|
56
|
+
data = [s.to_dict() for s in self._sessions.values()]
|
|
57
|
+
self._index_file.write_text(json.dumps(data, indent=2))
|
|
58
|
+
|
|
59
|
+
def create_session(self, name: str = None) -> Session:
|
|
60
|
+
"""Crear una nueva sesión"""
|
|
61
|
+
session_id = str(uuid.uuid4())[:8]
|
|
62
|
+
name = name or f"Session {len(self._sessions) + 1}"
|
|
63
|
+
session = Session(
|
|
64
|
+
id=session_id,
|
|
65
|
+
name=name,
|
|
66
|
+
created_at=datetime.now().isoformat(),
|
|
67
|
+
history_file=self.sessions_dir / f"messages_{session_id}.jsonl"
|
|
68
|
+
)
|
|
69
|
+
self._sessions[session_id] = session
|
|
70
|
+
self._save_sessions()
|
|
71
|
+
return session
|
|
72
|
+
|
|
73
|
+
def list_sessions(self) -> list[Session]:
|
|
74
|
+
"""Listar todas las sesiones, ordenadas por fecha descendente"""
|
|
75
|
+
return sorted(
|
|
76
|
+
self._sessions.values(),
|
|
77
|
+
key=lambda s: s.created_at,
|
|
78
|
+
reverse=True
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def get_session(self, session_id: str) -> Session | None:
|
|
82
|
+
"""Obtener una sesión por ID"""
|
|
83
|
+
return self._sessions.get(session_id)
|
|
84
|
+
|
|
85
|
+
def rename_session(self, session_id: str, new_name: str) -> bool:
|
|
86
|
+
"""Renombrar una sesión"""
|
|
87
|
+
if session_id in self._sessions:
|
|
88
|
+
self._sessions[session_id].name = new_name
|
|
89
|
+
self._save_sessions()
|
|
90
|
+
return True
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
def delete_session(self, session_id: str) -> bool:
|
|
94
|
+
"""Eliminar una sesión y su archivo de historial"""
|
|
95
|
+
if session_id in self._sessions:
|
|
96
|
+
session = self._sessions[session_id]
|
|
97
|
+
if session.history_file.exists():
|
|
98
|
+
session.history_file.unlink()
|
|
99
|
+
del self._sessions[session_id]
|
|
100
|
+
self._save_sessions()
|
|
101
|
+
return True
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
def get_last_active(self) -> str | None:
|
|
105
|
+
"""Obtener el ID de la última sesión activa"""
|
|
106
|
+
last_file = self.sessions_dir / ".last_active"
|
|
107
|
+
if last_file.exists():
|
|
108
|
+
return last_file.read_text().strip()
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
def set_last_active(self, session_id: str) -> None:
|
|
112
|
+
"""Establecer la última sesión activa"""
|
|
113
|
+
(self.sessions_dir / ".last_active").write_text(session_id)
|
termuxcode/tui/styles.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""CSS simple para layout vertical como mother.py"""
|
|
2
|
+
|
|
3
|
+
CSS = """
|
|
4
|
+
Screen {
|
|
5
|
+
layout: vertical;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/* Header - altura fija pequeña */
|
|
9
|
+
Header {
|
|
10
|
+
height: 3;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* Contenedor del chat - ocupa el espacio restante */
|
|
14
|
+
VerticalScroll#chat-container {
|
|
15
|
+
height: 1fr;
|
|
16
|
+
width: 100%;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* ChatLog */
|
|
20
|
+
ChatLog {
|
|
21
|
+
height: 100%;
|
|
22
|
+
width: 100%;
|
|
23
|
+
background: $surface;
|
|
24
|
+
color: $foreground;
|
|
25
|
+
scrollbar-gutter: auto;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* Bottom container (tabs + input) - altura fija */
|
|
29
|
+
#bottom-container {
|
|
30
|
+
height: 5;
|
|
31
|
+
width: 100%;
|
|
32
|
+
background: $panel;
|
|
33
|
+
border-top: solid $primary;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* Tabs */
|
|
37
|
+
Tabs {
|
|
38
|
+
height: 1;
|
|
39
|
+
background: $panel;
|
|
40
|
+
border-bottom: solid $primary 50%;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
Tab {
|
|
44
|
+
padding: 0 2;
|
|
45
|
+
text-style: bold;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
Tab.--active {
|
|
49
|
+
background: $primary;
|
|
50
|
+
color: $background;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Input container */
|
|
54
|
+
#input-container {
|
|
55
|
+
height: 3;
|
|
56
|
+
padding: 1 2;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* Input */
|
|
60
|
+
#message-input {
|
|
61
|
+
width: 100%;
|
|
62
|
+
max-height: 3;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* Placeholder del input */
|
|
66
|
+
#message-input > .input--placeholder {
|
|
67
|
+
color: $text 50%;
|
|
68
|
+
text-style: dim;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
"""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: termuxcode
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python project for Termux development
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: claude-agent-sdk==0.1.48
|
|
8
|
+
Requires-Dist: textual==8.0.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
12
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
13
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
14
|
+
Requires-Dist: textual-dev==1.8.0; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# TermuxCode
|
|
17
|
+
|
|
18
|
+
Python project for Termux development.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install -e .
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Development
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Install development dependencies
|
|
30
|
+
pip install -e ".[dev]"
|
|
31
|
+
|
|
32
|
+
# Run tests
|
|
33
|
+
pytest
|
|
34
|
+
|
|
35
|
+
# Format code
|
|
36
|
+
ruff format .
|
|
37
|
+
|
|
38
|
+
# Lint code
|
|
39
|
+
ruff check .
|
|
40
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
termuxcode/__init__.py,sha256=2M6qy6RWmUTwXUi0WJ3kb9tzBBR9FChm9nwlXxQ0nrA,136
|
|
2
|
+
termuxcode/cli.py,sha256=eXwO6IXd4OA8joYuy8iWVzRlJQWad_59xZDyk_1IEb0,216
|
|
3
|
+
termuxcode/tui/__init__.py,sha256=En-qENaywC5ONSk8gohasQNAcOuBWPi5yjPzOyBzcCk,158
|
|
4
|
+
termuxcode/tui/agent.py,sha256=4ZUnoml13Cg8O_RXd6opIpUzNPuLtCiv0Ij_iwh21mE,3552
|
|
5
|
+
termuxcode/tui/app.py,sha256=tNKgWCVddqN_BecrAh9m5Rrae0segNwnjiamZSwlwY0,6452
|
|
6
|
+
termuxcode/tui/chat.py,sha256=67l2ZOGoBdbpZ65OkoaMLLKiwH_sdUGnklPi777Ldwo,1936
|
|
7
|
+
termuxcode/tui/history.py,sha256=Bzp_8LrzzpRetcwNn1ghHCjmaR9XmEF4oM8iljPGqqY,2788
|
|
8
|
+
termuxcode/tui/sessions.py,sha256=2c1pHAWKGu9N7lRpWJ6grppylXplhDp3E794eKrBdK4,3800
|
|
9
|
+
termuxcode/tui/styles.py,sha256=eOSK54P24G4OQGPueGiYiJPWT3__PVsY78HZAxyMVPU,1065
|
|
10
|
+
termuxcode-0.2.0.dist-info/METADATA,sha256=OAZwArtZ0AVsaxx07VgeEz22z85VDMJiDAOrIt3TOz4,749
|
|
11
|
+
termuxcode-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
termuxcode-0.2.0.dist-info/entry_points.txt,sha256=UAjoJs1G65ITt11VEKCViHIjxgegpciyHtuc2itUZKI,51
|
|
13
|
+
termuxcode-0.2.0.dist-info/top_level.txt,sha256=__FTqr2MPCx4Yqtn4CceZjid4qLVn7UEQidi8oEHh-E,11
|
|
14
|
+
termuxcode-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
termuxcode
|