devorch 0.1.2__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.
- cli/commands/ask.py +0 -0
- cli/commands/edit.py +0 -0
- cli/commands/run.py +0 -0
- cli/main.py +1527 -0
- config/permissions.py +285 -0
- config/profiles.py +0 -0
- config/settings.py +185 -0
- core/agent.py +433 -0
- core/context.py +0 -0
- core/executor.py +213 -0
- core/modes.py +141 -0
- core/planner.py +16 -0
- core/sessions.py +271 -0
- core/tasks.py +142 -0
- devorch-0.1.2.dist-info/METADATA +528 -0
- devorch-0.1.2.dist-info/RECORD +50 -0
- devorch-0.1.2.dist-info/WHEEL +4 -0
- devorch-0.1.2.dist-info/entry_points.txt +2 -0
- providers/__init__.py +130 -0
- providers/anthropic.py +127 -0
- providers/base.py +51 -0
- providers/custom.py +186 -0
- providers/deepseek.py +170 -0
- providers/gemini.py +192 -0
- providers/github_copilot.py +196 -0
- providers/groq.py +158 -0
- providers/kimi.py +181 -0
- providers/lmstudio.py +147 -0
- providers/local.py +214 -0
- providers/mistral.py +161 -0
- providers/openai.py +114 -0
- providers/openrouter.py +195 -0
- providers/together.py +159 -0
- schemas/message.py +32 -0
- schemas/task.py +115 -0
- schemas/tool.py +0 -0
- tools/__init__.py +30 -0
- tools/base.py +40 -0
- tools/edit.py +236 -0
- tools/filesystem.py +224 -0
- tools/git.py +0 -0
- tools/grep.py +280 -0
- tools/search.py +150 -0
- tools/shell.py +55 -0
- tools/task.py +91 -0
- tools/terminal.py +123 -0
- tools/terminal_session.py +298 -0
- tools/tests.py +0 -0
- tools/websearch.py +166 -0
- utils/logger.py +52 -0
core/modes.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent execution modes - controls how DevOrch handles tool execution.
|
|
3
|
+
|
|
4
|
+
Modes:
|
|
5
|
+
- PLAN: Create a plan first, show it, ask for approval, then execute
|
|
6
|
+
- AUTO: Execute tools automatically without asking (trusted mode)
|
|
7
|
+
- ASK: Ask for confirmation before each tool execution (default)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AgentMode(str, Enum):
|
|
16
|
+
PLAN = "plan" # Plan first, then execute after approval
|
|
17
|
+
AUTO = "auto" # Execute automatically (no confirmations)
|
|
18
|
+
ASK = "ask" # Ask before each tool execution (default)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class PlanStep:
|
|
23
|
+
"""A single step in an execution plan."""
|
|
24
|
+
|
|
25
|
+
description: str
|
|
26
|
+
tool_name: str | None = None
|
|
27
|
+
tool_args: dict | None = None
|
|
28
|
+
status: str = "pending" # pending, approved, rejected, completed, failed
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ExecutionPlan:
|
|
33
|
+
"""A plan of steps to execute."""
|
|
34
|
+
|
|
35
|
+
goal: str
|
|
36
|
+
steps: list[PlanStep] = field(default_factory=list)
|
|
37
|
+
approved: bool = False
|
|
38
|
+
|
|
39
|
+
def add_step(self, description: str, tool_name: str = None, tool_args: dict = None):
|
|
40
|
+
self.steps.append(
|
|
41
|
+
PlanStep(description=description, tool_name=tool_name, tool_args=tool_args)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def to_display(self) -> str:
|
|
45
|
+
"""Format plan for display."""
|
|
46
|
+
lines = [f"📋 **Plan: {self.goal}**\n"]
|
|
47
|
+
for i, step in enumerate(self.steps, 1):
|
|
48
|
+
status_icon = {
|
|
49
|
+
"pending": "⬜",
|
|
50
|
+
"approved": "✅",
|
|
51
|
+
"rejected": "❌",
|
|
52
|
+
"completed": "✔️",
|
|
53
|
+
"failed": "💥",
|
|
54
|
+
}.get(step.status, "⬜")
|
|
55
|
+
|
|
56
|
+
tool_info = f" [{step.tool_name}]" if step.tool_name else ""
|
|
57
|
+
lines.append(f"{status_icon} {i}. {step.description}{tool_info}")
|
|
58
|
+
|
|
59
|
+
return "\n".join(lines)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ModeManager:
|
|
63
|
+
"""Manages the current execution mode and plan state."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, default_mode: AgentMode = AgentMode.ASK):
|
|
66
|
+
self._mode = default_mode
|
|
67
|
+
self._current_plan: ExecutionPlan | None = None
|
|
68
|
+
self._on_mode_change: Callable[[AgentMode], None] | None = None
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def mode(self) -> AgentMode:
|
|
72
|
+
return self._mode
|
|
73
|
+
|
|
74
|
+
@mode.setter
|
|
75
|
+
def mode(self, value: AgentMode):
|
|
76
|
+
old_mode = self._mode
|
|
77
|
+
self._mode = value
|
|
78
|
+
if self._on_mode_change and old_mode != value:
|
|
79
|
+
self._on_mode_change(value)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def current_plan(self) -> ExecutionPlan | None:
|
|
83
|
+
return self._current_plan
|
|
84
|
+
|
|
85
|
+
def set_on_mode_change(self, callback: Callable[[AgentMode], None]):
|
|
86
|
+
"""Set callback for mode changes."""
|
|
87
|
+
self._on_mode_change = callback
|
|
88
|
+
|
|
89
|
+
def start_plan(self, goal: str) -> ExecutionPlan:
|
|
90
|
+
"""Start a new execution plan."""
|
|
91
|
+
self._current_plan = ExecutionPlan(goal=goal)
|
|
92
|
+
return self._current_plan
|
|
93
|
+
|
|
94
|
+
def clear_plan(self):
|
|
95
|
+
"""Clear the current plan."""
|
|
96
|
+
self._current_plan = None
|
|
97
|
+
|
|
98
|
+
def approve_plan(self) -> bool:
|
|
99
|
+
"""Approve the current plan for execution."""
|
|
100
|
+
if self._current_plan:
|
|
101
|
+
self._current_plan.approved = True
|
|
102
|
+
return True
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
def should_ask_permission(self) -> bool:
|
|
106
|
+
"""Check if we should ask for tool permission based on mode."""
|
|
107
|
+
if self._mode == AgentMode.AUTO:
|
|
108
|
+
return False
|
|
109
|
+
elif self._mode == AgentMode.PLAN:
|
|
110
|
+
# In plan mode, don't ask during planning, only during execution
|
|
111
|
+
if self._current_plan and self._current_plan.approved:
|
|
112
|
+
return False # Plan approved, execute without asking
|
|
113
|
+
return True
|
|
114
|
+
else: # ASK mode
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
def is_planning(self) -> bool:
|
|
118
|
+
"""Check if we're currently in planning phase."""
|
|
119
|
+
return (
|
|
120
|
+
self._mode == AgentMode.PLAN
|
|
121
|
+
and self._current_plan is not None
|
|
122
|
+
and not self._current_plan.approved
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def get_mode_display(self) -> str:
|
|
126
|
+
"""Get a short display string for the current mode."""
|
|
127
|
+
mode_displays = {
|
|
128
|
+
AgentMode.PLAN: "[yellow]PLAN[/yellow]",
|
|
129
|
+
AgentMode.AUTO: "[green]AUTO[/green]",
|
|
130
|
+
AgentMode.ASK: "[blue]ASK[/blue]",
|
|
131
|
+
}
|
|
132
|
+
return mode_displays.get(self._mode, str(self._mode))
|
|
133
|
+
|
|
134
|
+
def get_mode_description(self) -> str:
|
|
135
|
+
"""Get description of current mode."""
|
|
136
|
+
descriptions = {
|
|
137
|
+
AgentMode.PLAN: "Plan mode - I'll show you the plan before executing",
|
|
138
|
+
AgentMode.AUTO: "Auto mode - I'll execute tools automatically",
|
|
139
|
+
AgentMode.ASK: "Ask mode - I'll ask before each tool execution",
|
|
140
|
+
}
|
|
141
|
+
return descriptions.get(self._mode, "")
|
core/planner.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from schemas.message import Message
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Planner(ABC):
|
|
7
|
+
"""
|
|
8
|
+
Decides the next step for the agent.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def plan(self, history: list[Message]) -> list[Message]:
|
|
13
|
+
"""
|
|
14
|
+
Returns updated messages to send to the LLM.
|
|
15
|
+
"""
|
|
16
|
+
pass
|
core/sessions.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sqlite3
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from schemas.message import Message
|
|
8
|
+
|
|
9
|
+
DATA_DIR = Path.home() / ".devorch"
|
|
10
|
+
DB_PATH = DATA_DIR / "sessions.db"
|
|
11
|
+
|
|
12
|
+
# Default limits
|
|
13
|
+
DEFAULT_MESSAGE_LIMIT = 50 # Max messages before summarization
|
|
14
|
+
DEFAULT_TOKEN_ESTIMATE_LIMIT = 100000 # Rough token estimate
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SessionManager:
|
|
18
|
+
"""Manages chat session persistence using SQLite."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message_limit: int = DEFAULT_MESSAGE_LIMIT):
|
|
21
|
+
self._ensure_db()
|
|
22
|
+
self.current_session_id: str | None = None
|
|
23
|
+
self.message_limit = message_limit
|
|
24
|
+
self._message_count = 0
|
|
25
|
+
|
|
26
|
+
def _ensure_db(self):
|
|
27
|
+
"""Create database and tables if they don't exist."""
|
|
28
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
conn = sqlite3.connect(DB_PATH)
|
|
31
|
+
cursor = conn.cursor()
|
|
32
|
+
|
|
33
|
+
cursor.execute("""
|
|
34
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
name TEXT,
|
|
37
|
+
provider TEXT NOT NULL,
|
|
38
|
+
model TEXT NOT NULL,
|
|
39
|
+
parent_session_id TEXT,
|
|
40
|
+
summary TEXT,
|
|
41
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
42
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
43
|
+
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
|
44
|
+
)
|
|
45
|
+
""")
|
|
46
|
+
|
|
47
|
+
cursor.execute("""
|
|
48
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
49
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
50
|
+
session_id TEXT NOT NULL,
|
|
51
|
+
role TEXT NOT NULL,
|
|
52
|
+
content TEXT NOT NULL,
|
|
53
|
+
name TEXT,
|
|
54
|
+
tool_call_id TEXT,
|
|
55
|
+
metadata TEXT,
|
|
56
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
57
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
58
|
+
)
|
|
59
|
+
""")
|
|
60
|
+
|
|
61
|
+
# Add parent_session_id and summary columns if they don't exist (migration)
|
|
62
|
+
try:
|
|
63
|
+
cursor.execute("ALTER TABLE sessions ADD COLUMN parent_session_id TEXT")
|
|
64
|
+
except sqlite3.OperationalError:
|
|
65
|
+
pass # Column already exists
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
cursor.execute("ALTER TABLE sessions ADD COLUMN summary TEXT")
|
|
69
|
+
except sqlite3.OperationalError:
|
|
70
|
+
pass # Column already exists
|
|
71
|
+
|
|
72
|
+
conn.commit()
|
|
73
|
+
conn.close()
|
|
74
|
+
|
|
75
|
+
def create_session(
|
|
76
|
+
self,
|
|
77
|
+
provider: str,
|
|
78
|
+
model: str,
|
|
79
|
+
name: str | None = None,
|
|
80
|
+
parent_session_id: str | None = None,
|
|
81
|
+
summary: str | None = None,
|
|
82
|
+
) -> str:
|
|
83
|
+
"""Create a new session and return its ID."""
|
|
84
|
+
session_id = str(uuid.uuid4())[:8]
|
|
85
|
+
if not name:
|
|
86
|
+
name = f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
|
87
|
+
|
|
88
|
+
conn = sqlite3.connect(DB_PATH)
|
|
89
|
+
cursor = conn.cursor()
|
|
90
|
+
|
|
91
|
+
cursor.execute(
|
|
92
|
+
"""INSERT INTO sessions (id, name, provider, model, parent_session_id, summary)
|
|
93
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
94
|
+
(session_id, name, provider, model, parent_session_id, summary),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
conn.commit()
|
|
98
|
+
conn.close()
|
|
99
|
+
|
|
100
|
+
self.current_session_id = session_id
|
|
101
|
+
self._message_count = 0
|
|
102
|
+
return session_id
|
|
103
|
+
|
|
104
|
+
def save_message(self, message: Message, session_id: str | None = None):
|
|
105
|
+
"""Save a message to the current or specified session."""
|
|
106
|
+
sid = session_id or self.current_session_id
|
|
107
|
+
if not sid:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
conn = sqlite3.connect(DB_PATH)
|
|
111
|
+
cursor = conn.cursor()
|
|
112
|
+
|
|
113
|
+
metadata_json = json.dumps(message.metadata) if message.metadata else None
|
|
114
|
+
|
|
115
|
+
cursor.execute(
|
|
116
|
+
"""INSERT INTO messages (session_id, role, content, name, tool_call_id, metadata)
|
|
117
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
118
|
+
(sid, message.role, message.content, message.name, message.tool_call_id, metadata_json),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Update session timestamp
|
|
122
|
+
cursor.execute("UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ?", (sid,))
|
|
123
|
+
|
|
124
|
+
conn.commit()
|
|
125
|
+
conn.close()
|
|
126
|
+
|
|
127
|
+
if sid == self.current_session_id:
|
|
128
|
+
self._message_count += 1
|
|
129
|
+
|
|
130
|
+
def get_message_count(self, session_id: str | None = None) -> int:
|
|
131
|
+
"""Get the number of messages in a session."""
|
|
132
|
+
sid = session_id or self.current_session_id
|
|
133
|
+
if not sid:
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
conn = sqlite3.connect(DB_PATH)
|
|
137
|
+
cursor = conn.cursor()
|
|
138
|
+
|
|
139
|
+
cursor.execute("SELECT COUNT(*) FROM messages WHERE session_id = ?", (sid,))
|
|
140
|
+
count = cursor.fetchone()[0]
|
|
141
|
+
|
|
142
|
+
conn.close()
|
|
143
|
+
return count
|
|
144
|
+
|
|
145
|
+
def should_summarize(self) -> bool:
|
|
146
|
+
"""Check if the current session should be summarized."""
|
|
147
|
+
return self._message_count >= self.message_limit
|
|
148
|
+
|
|
149
|
+
def load_session(self, session_id: str) -> tuple[dict, list[Message]]:
|
|
150
|
+
"""Load a session and its messages. Returns (session_info, messages)."""
|
|
151
|
+
conn = sqlite3.connect(DB_PATH)
|
|
152
|
+
conn.row_factory = sqlite3.Row
|
|
153
|
+
cursor = conn.cursor()
|
|
154
|
+
|
|
155
|
+
# Get session info
|
|
156
|
+
cursor.execute("SELECT * FROM sessions WHERE id = ?", (session_id,))
|
|
157
|
+
session_row = cursor.fetchone()
|
|
158
|
+
|
|
159
|
+
if not session_row:
|
|
160
|
+
conn.close()
|
|
161
|
+
raise ValueError(f"Session '{session_id}' not found")
|
|
162
|
+
|
|
163
|
+
session_info = dict(session_row)
|
|
164
|
+
|
|
165
|
+
# Get messages
|
|
166
|
+
cursor.execute(
|
|
167
|
+
"SELECT * FROM messages WHERE session_id = ? ORDER BY created_at", (session_id,)
|
|
168
|
+
)
|
|
169
|
+
message_rows = cursor.fetchall()
|
|
170
|
+
|
|
171
|
+
messages = []
|
|
172
|
+
for row in message_rows:
|
|
173
|
+
metadata = json.loads(row["metadata"]) if row["metadata"] else None
|
|
174
|
+
msg = Message(
|
|
175
|
+
role=row["role"],
|
|
176
|
+
content=row["content"],
|
|
177
|
+
name=row["name"],
|
|
178
|
+
tool_call_id=row["tool_call_id"],
|
|
179
|
+
metadata=metadata,
|
|
180
|
+
)
|
|
181
|
+
messages.append(msg)
|
|
182
|
+
|
|
183
|
+
conn.close()
|
|
184
|
+
|
|
185
|
+
self.current_session_id = session_id
|
|
186
|
+
self._message_count = len(messages)
|
|
187
|
+
return session_info, messages
|
|
188
|
+
|
|
189
|
+
def create_continuation_session(self, provider: str, model: str, summary: str) -> str:
|
|
190
|
+
"""Create a new session that continues from the current one with a summary."""
|
|
191
|
+
parent_id = self.current_session_id
|
|
192
|
+
|
|
193
|
+
# Get parent session name for naming continuation
|
|
194
|
+
name = f"Continuation {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
|
195
|
+
|
|
196
|
+
new_session_id = self.create_session(
|
|
197
|
+
provider=provider, model=model, name=name, parent_session_id=parent_id, summary=summary
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return new_session_id
|
|
201
|
+
|
|
202
|
+
def get_session_chain(self, session_id: str) -> list[dict]:
|
|
203
|
+
"""Get the chain of sessions (parent -> child) for context."""
|
|
204
|
+
conn = sqlite3.connect(DB_PATH)
|
|
205
|
+
conn.row_factory = sqlite3.Row
|
|
206
|
+
cursor = conn.cursor()
|
|
207
|
+
|
|
208
|
+
chain = []
|
|
209
|
+
current_id = session_id
|
|
210
|
+
|
|
211
|
+
# Walk up the parent chain
|
|
212
|
+
while current_id:
|
|
213
|
+
cursor.execute("SELECT * FROM sessions WHERE id = ?", (current_id,))
|
|
214
|
+
row = cursor.fetchone()
|
|
215
|
+
if row:
|
|
216
|
+
chain.insert(0, dict(row))
|
|
217
|
+
current_id = row["parent_session_id"]
|
|
218
|
+
else:
|
|
219
|
+
break
|
|
220
|
+
|
|
221
|
+
conn.close()
|
|
222
|
+
return chain
|
|
223
|
+
|
|
224
|
+
def list_sessions(self, limit: int = 20) -> list[dict]:
|
|
225
|
+
"""List recent sessions."""
|
|
226
|
+
conn = sqlite3.connect(DB_PATH)
|
|
227
|
+
conn.row_factory = sqlite3.Row
|
|
228
|
+
cursor = conn.cursor()
|
|
229
|
+
|
|
230
|
+
cursor.execute(
|
|
231
|
+
"""SELECT s.*, COUNT(m.id) as message_count
|
|
232
|
+
FROM sessions s
|
|
233
|
+
LEFT JOIN messages m ON s.id = m.session_id
|
|
234
|
+
GROUP BY s.id
|
|
235
|
+
ORDER BY s.updated_at DESC
|
|
236
|
+
LIMIT ?""",
|
|
237
|
+
(limit,),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
sessions = [dict(row) for row in cursor.fetchall()]
|
|
241
|
+
conn.close()
|
|
242
|
+
|
|
243
|
+
return sessions
|
|
244
|
+
|
|
245
|
+
def delete_session(self, session_id: str) -> bool:
|
|
246
|
+
"""Delete a session and its messages."""
|
|
247
|
+
conn = sqlite3.connect(DB_PATH)
|
|
248
|
+
cursor = conn.cursor()
|
|
249
|
+
|
|
250
|
+
# Enable foreign keys for cascade delete
|
|
251
|
+
cursor.execute("PRAGMA foreign_keys = ON")
|
|
252
|
+
|
|
253
|
+
cursor.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
|
|
254
|
+
cursor.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
|
255
|
+
|
|
256
|
+
deleted = cursor.rowcount > 0
|
|
257
|
+
conn.commit()
|
|
258
|
+
conn.close()
|
|
259
|
+
|
|
260
|
+
return deleted
|
|
261
|
+
|
|
262
|
+
def session_exists(self, session_id: str) -> bool:
|
|
263
|
+
"""Check if a session exists."""
|
|
264
|
+
conn = sqlite3.connect(DB_PATH)
|
|
265
|
+
cursor = conn.cursor()
|
|
266
|
+
|
|
267
|
+
cursor.execute("SELECT 1 FROM sessions WHERE id = ?", (session_id,))
|
|
268
|
+
exists = cursor.fetchone() is not None
|
|
269
|
+
|
|
270
|
+
conn.close()
|
|
271
|
+
return exists
|
core/tasks.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Task manager for tracking work progress with visual display."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.live import Live
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
|
|
9
|
+
from schemas.task import Task, TaskList, TaskStatus
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TaskManager:
|
|
15
|
+
"""Manages tasks and displays progress."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, on_update: Callable | None = None):
|
|
18
|
+
self.task_list = TaskList()
|
|
19
|
+
self.on_update = on_update # Callback when tasks change
|
|
20
|
+
self._live: Live | None = None
|
|
21
|
+
|
|
22
|
+
def set_tasks(self, tasks: list[dict]) -> None:
|
|
23
|
+
"""Set tasks from a list of dictionaries (from AI tool call)."""
|
|
24
|
+
self.task_list.clear()
|
|
25
|
+
for task_data in tasks:
|
|
26
|
+
task = Task(
|
|
27
|
+
content=task_data["content"],
|
|
28
|
+
status=TaskStatus(task_data.get("status", "pending")),
|
|
29
|
+
active_form=task_data.get("activeForm") or task_data.get("active_form"),
|
|
30
|
+
)
|
|
31
|
+
self.task_list.add(task)
|
|
32
|
+
self._display()
|
|
33
|
+
|
|
34
|
+
def add_task(self, content: str, active_form: str | None = None) -> Task:
|
|
35
|
+
"""Add a new task."""
|
|
36
|
+
task = Task(content=content, active_form=active_form or content)
|
|
37
|
+
self.task_list.add(task)
|
|
38
|
+
self._display()
|
|
39
|
+
return task
|
|
40
|
+
|
|
41
|
+
def start_task(self, task_id: str) -> Task | None:
|
|
42
|
+
"""Mark a task as in progress."""
|
|
43
|
+
task = self.task_list.update_status(task_id, TaskStatus.IN_PROGRESS)
|
|
44
|
+
self._display()
|
|
45
|
+
return task
|
|
46
|
+
|
|
47
|
+
def complete_task(self, task_id: str) -> Task | None:
|
|
48
|
+
"""Mark a task as completed."""
|
|
49
|
+
task = self.task_list.update_status(task_id, TaskStatus.COMPLETED)
|
|
50
|
+
self._display()
|
|
51
|
+
return task
|
|
52
|
+
|
|
53
|
+
def get_current_task(self) -> Task | None:
|
|
54
|
+
"""Get the currently active task."""
|
|
55
|
+
return self.task_list.get_current()
|
|
56
|
+
|
|
57
|
+
def clear_tasks(self) -> None:
|
|
58
|
+
"""Clear all tasks."""
|
|
59
|
+
self.task_list.clear()
|
|
60
|
+
|
|
61
|
+
def _display(self) -> None:
|
|
62
|
+
"""Display the current task list."""
|
|
63
|
+
if self.task_list.total_count == 0:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
panel = self._create_panel()
|
|
67
|
+
console.print(panel)
|
|
68
|
+
|
|
69
|
+
if self.on_update:
|
|
70
|
+
self.on_update(self.task_list)
|
|
71
|
+
|
|
72
|
+
def _create_panel(self) -> Panel:
|
|
73
|
+
"""Create a Rich panel showing task progress."""
|
|
74
|
+
# Build task display
|
|
75
|
+
lines = []
|
|
76
|
+
|
|
77
|
+
for task in self.task_list.tasks:
|
|
78
|
+
if task.status == TaskStatus.COMPLETED:
|
|
79
|
+
icon = "[green]✓[/green]"
|
|
80
|
+
style = "dim"
|
|
81
|
+
text = task.content
|
|
82
|
+
elif task.status == TaskStatus.IN_PROGRESS:
|
|
83
|
+
icon = "[cyan]●[/cyan]"
|
|
84
|
+
style = "bold cyan"
|
|
85
|
+
text = task.active_form or task.content
|
|
86
|
+
else: # PENDING
|
|
87
|
+
icon = "[dim]○[/dim]"
|
|
88
|
+
style = "dim"
|
|
89
|
+
text = task.content
|
|
90
|
+
|
|
91
|
+
lines.append(f" {icon} [{style}]{text}[/{style}]")
|
|
92
|
+
|
|
93
|
+
content = "\n".join(lines)
|
|
94
|
+
|
|
95
|
+
# Progress info
|
|
96
|
+
completed = self.task_list.completed_count
|
|
97
|
+
total = self.task_list.total_count
|
|
98
|
+
progress = f"{completed}/{total}"
|
|
99
|
+
|
|
100
|
+
# Create panel with progress in title
|
|
101
|
+
return Panel(
|
|
102
|
+
content,
|
|
103
|
+
title=f"[bold]Tasks[/bold] [dim]({progress})[/dim]",
|
|
104
|
+
border_style="blue",
|
|
105
|
+
padding=(0, 1),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def get_status_line(self) -> str:
|
|
109
|
+
"""Get a short status line for the current task."""
|
|
110
|
+
current = self.get_current_task()
|
|
111
|
+
if current:
|
|
112
|
+
completed = self.task_list.completed_count
|
|
113
|
+
total = self.task_list.total_count
|
|
114
|
+
return f"[{completed}/{total}] {current.active_form or current.content}"
|
|
115
|
+
return ""
|
|
116
|
+
|
|
117
|
+
def to_dict(self) -> dict:
|
|
118
|
+
"""Export task list as dictionary."""
|
|
119
|
+
return {
|
|
120
|
+
"tasks": self.task_list.to_list(),
|
|
121
|
+
"completed": self.task_list.completed_count,
|
|
122
|
+
"total": self.task_list.total_count,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Global task manager instance
|
|
127
|
+
_task_manager: TaskManager | None = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_task_manager() -> TaskManager:
|
|
131
|
+
"""Get or create the global task manager."""
|
|
132
|
+
global _task_manager
|
|
133
|
+
if _task_manager is None:
|
|
134
|
+
_task_manager = TaskManager()
|
|
135
|
+
return _task_manager
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def reset_task_manager() -> TaskManager:
|
|
139
|
+
"""Reset and return a new task manager."""
|
|
140
|
+
global _task_manager
|
|
141
|
+
_task_manager = TaskManager()
|
|
142
|
+
return _task_manager
|