dlab-cli 0.1.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.
@@ -0,0 +1,208 @@
1
+ """
2
+ Log file watcher using simple polling.
3
+
4
+ Tracks file positions and streams new log events as they are appended.
5
+ """
6
+
7
+ import json
8
+ import threading
9
+ from pathlib import Path
10
+ from queue import Queue
11
+ from typing import Any, Callable
12
+
13
+
14
+ class LogWatcher:
15
+ """
16
+ Watches log directory for changes via periodic polling.
17
+
18
+ Simple and reliable approach - polls all log files for new content.
19
+ Called periodically from the UI update timer.
20
+ """
21
+
22
+ def __init__(self, logs_dir: Path) -> None:
23
+ """
24
+ Initialize watcher.
25
+
26
+ Parameters
27
+ ----------
28
+ logs_dir : Path
29
+ Directory containing log files.
30
+ """
31
+ self._logs_dir = logs_dir
32
+ self._event_queue: Queue[tuple[str, dict[str, Any]]] = Queue()
33
+ self._file_positions: dict[Path, int] = {}
34
+ self._file_inodes: dict[Path, int] = {} # Track inode for replacement detection
35
+ self._lock = threading.Lock()
36
+ self._running = False
37
+
38
+ def _get_source_name(self, log_path: Path) -> str:
39
+ """
40
+ Convert log path to source name.
41
+
42
+ Parameters
43
+ ----------
44
+ log_path : Path
45
+ Path to log file.
46
+
47
+ Returns
48
+ -------
49
+ str
50
+ Source name (e.g., "main", "instance-1").
51
+ """
52
+ try:
53
+ rel_path = log_path.relative_to(self._logs_dir)
54
+ if len(rel_path.parts) > 1:
55
+ return f"{rel_path.parent.name}/{rel_path.stem}"
56
+ return rel_path.stem
57
+ except ValueError:
58
+ return log_path.stem
59
+
60
+ def _read_new_lines(self, log_path: Path) -> list[tuple[str, dict[str, Any]]]:
61
+ """
62
+ Read new lines from a log file.
63
+
64
+ Handles atomic file replacement (common in Docker) by detecting:
65
+ - File inode changed (new file with same name)
66
+ - File size smaller than stored position (truncation)
67
+
68
+ Parameters
69
+ ----------
70
+ log_path : Path
71
+ Path to log file.
72
+
73
+ Returns
74
+ -------
75
+ list[tuple[str, dict[str, Any]]]
76
+ List of (source, event) tuples.
77
+ """
78
+ if not log_path.exists() or log_path.suffix != ".log":
79
+ return []
80
+
81
+ source = self._get_source_name(log_path)
82
+ events: list[tuple[str, dict[str, Any]]] = []
83
+
84
+ with self._lock:
85
+ position = self._file_positions.get(log_path, 0)
86
+ old_inode = self._file_inodes.get(log_path, 0)
87
+
88
+ try:
89
+ stat = log_path.stat()
90
+ current_size = stat.st_size
91
+ current_inode = stat.st_ino
92
+
93
+ # Detect file replacement or truncation:
94
+ # - Inode changed = file was atomically replaced (temp + rename)
95
+ # - Size < position = file was truncated
96
+ if current_inode != old_inode or current_size < position:
97
+ # File was replaced - start from beginning
98
+ position = 0
99
+
100
+ with open(log_path, "r") as f:
101
+ f.seek(position)
102
+ new_content = f.read()
103
+ new_position = f.tell()
104
+
105
+ self._file_positions[log_path] = new_position
106
+ self._file_inodes[log_path] = current_inode
107
+
108
+ # Track consecutive raw text lines to group them
109
+ raw_text_lines: list[str] = []
110
+
111
+ def flush_raw_text() -> None:
112
+ """Flush accumulated raw text lines as a single event."""
113
+ if raw_text_lines:
114
+ event = {
115
+ "type": "raw_text",
116
+ "timestamp": None,
117
+ "part": {"text": "\n".join(raw_text_lines)},
118
+ }
119
+ events.append((source, event))
120
+ raw_text_lines.clear()
121
+
122
+ for line in new_content.splitlines():
123
+ line = line.strip()
124
+ if not line:
125
+ continue
126
+
127
+ # Try to parse as JSON
128
+ if line.startswith("{"):
129
+ try:
130
+ event = json.loads(line)
131
+ # Flush any accumulated raw text first
132
+ flush_raw_text()
133
+ # Handle raw JSON outputs (no type/timestamp) as "additional_output"
134
+ if not event.get("type") or not event.get("timestamp"):
135
+ # Create a synthetic event for raw output
136
+ event = {
137
+ "type": "additional_output",
138
+ "timestamp": None,
139
+ "part": {"raw_data": event},
140
+ }
141
+ events.append((source, event))
142
+ except json.JSONDecodeError:
143
+ # JSON-like but malformed - treat as raw text
144
+ raw_text_lines.append(line)
145
+ else:
146
+ # Not JSON - accumulate as raw text
147
+ raw_text_lines.append(line)
148
+
149
+ # Flush any remaining raw text
150
+ flush_raw_text()
151
+
152
+ except (IOError, OSError):
153
+ pass
154
+
155
+ return events
156
+
157
+ def start(self) -> None:
158
+ """
159
+ Start watching - reads all existing log files.
160
+ """
161
+ if self._running:
162
+ return
163
+
164
+ # Read existing content
165
+ for log_path in self._logs_dir.rglob("*.log"):
166
+ for source, event in self._read_new_lines(log_path):
167
+ self._event_queue.put((source, event))
168
+
169
+ self._running = True
170
+
171
+ def stop(self) -> None:
172
+ """Stop watching."""
173
+ self._running = False
174
+
175
+ def poll(self) -> None:
176
+ """
177
+ Poll all log files for new content.
178
+
179
+ Should be called periodically from the UI update timer.
180
+ """
181
+ if not self._running:
182
+ return
183
+
184
+ for log_path in self._logs_dir.rglob("*.log"):
185
+ for source, event in self._read_new_lines(log_path):
186
+ self._event_queue.put((source, event))
187
+
188
+ def get_events(self) -> list[tuple[str, dict[str, Any]]]:
189
+ """
190
+ Get all pending events from queue (non-blocking).
191
+
192
+ Returns
193
+ -------
194
+ list[tuple[str, dict[str, Any]]]
195
+ List of (source_name, event_dict) tuples.
196
+ """
197
+ events: list[tuple[str, dict[str, Any]]] = []
198
+ while not self._event_queue.empty():
199
+ try:
200
+ events.append(self._event_queue.get_nowait())
201
+ except Exception:
202
+ break
203
+ return events
204
+
205
+ @property
206
+ def is_running(self) -> bool:
207
+ """Whether watcher is currently running."""
208
+ return self._running
dlab/tui/models.py ADDED
@@ -0,0 +1,438 @@
1
+ """
2
+ Data models for TUI state management.
3
+ """
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ @dataclass
13
+ class LogEvent:
14
+ """
15
+ Parsed log event with computed fields.
16
+
17
+ Attributes
18
+ ----------
19
+ timestamp : int
20
+ Timestamp in milliseconds.
21
+ dt : datetime
22
+ Datetime object.
23
+ event_type : str
24
+ Event type (step_start, step_finish, text, tool_use).
25
+ source : str
26
+ Source agent/log name.
27
+ description : str
28
+ Human-readable description (truncated for display).
29
+ raw : dict[str, Any]
30
+ Original raw JSON data.
31
+ cost : float
32
+ Cost for this event (from step_finish).
33
+ duration_ms : int | None
34
+ Duration in milliseconds if applicable.
35
+ """
36
+
37
+ timestamp: int
38
+ dt: datetime
39
+ event_type: str
40
+ source: str
41
+ description: str
42
+ raw: dict[str, Any]
43
+ cost: float = 0.0
44
+ duration_ms: int | None = None
45
+
46
+ @property
47
+ def full_description(self) -> str:
48
+ """
49
+ Get full untruncated description from raw data.
50
+
51
+ Returns
52
+ -------
53
+ str
54
+ Full description without any truncation.
55
+ """
56
+ if self.event_type == "step_start":
57
+ return "Step started"
58
+
59
+ if self.event_type == "step_finish":
60
+ part = self.raw.get("part", {})
61
+ reason = part.get("reason", "unknown")
62
+ return f"Step finished ({reason})"
63
+
64
+ if self.event_type == "text":
65
+ part = self.raw.get("part", {})
66
+ return part.get("text", "")
67
+
68
+ if self.event_type == "raw_text":
69
+ part = self.raw.get("part", {})
70
+ return part.get("text", "")
71
+
72
+ if self.event_type == "error":
73
+ error_data = self.raw.get("error", {})
74
+ error_name = error_data.get("name", "Error")
75
+ data = error_data.get("data", {})
76
+ message = data.get("message", "Unknown error")
77
+ status_code = data.get("statusCode", "")
78
+ response_body = data.get("responseBody", "")
79
+
80
+ description = f"[{error_name}]"
81
+ if status_code:
82
+ description += f" (status: {status_code})"
83
+ description += f"\n{message}"
84
+ if response_body:
85
+ description += f"\n--- response ---\n{response_body}"
86
+ return description
87
+
88
+ if self.event_type == "tool_use":
89
+ part = self.raw.get("part", {})
90
+ tool = part.get("tool", "unknown")
91
+ state = part.get("state", {})
92
+ status = state.get("status", "unknown")
93
+ input_data = state.get("input", {})
94
+ output = state.get("output", "")
95
+
96
+ if tool == "bash":
97
+ cmd = input_data.get("command", "")
98
+ desc = input_data.get("description", "")
99
+ description = f"bash: {desc or cmd}"
100
+ if output:
101
+ description += f"\n--- output ---\n{output}"
102
+ return description
103
+
104
+ if tool == "read":
105
+ filepath = input_data.get("filePath", "")
106
+ return f"read: {Path(filepath).name}"
107
+
108
+ if tool == "write":
109
+ filepath = input_data.get("filePath", "")
110
+ filename = Path(filepath).name
111
+ content = input_data.get("content", "")
112
+ description = f"write: {filename}"
113
+ if content:
114
+ description += f"\n--- content ---\n{content}"
115
+ return description
116
+
117
+ if tool == "edit":
118
+ filepath = input_data.get("filePath", "")
119
+ old_string = input_data.get("oldString", "")
120
+ new_string = input_data.get("newString", "")
121
+ description = f"edit: {Path(filepath).name}"
122
+ if old_string or new_string:
123
+ description += f"\n-{old_string}\n+{new_string}"
124
+ return description
125
+
126
+ if tool == "task":
127
+ subagent = input_data.get("subagent_type", "")
128
+ desc = input_data.get("description", "")
129
+ description = f"task: {subagent} - {desc}"
130
+ if output:
131
+ description += f"\n--- output ---\n{output}"
132
+ return description
133
+
134
+ if tool == "parallel-agents":
135
+ agent = input_data.get("agent", "")
136
+ prompts = input_data.get("prompts", [])
137
+ description = f"parallel-agents: {agent} x{len(prompts)}"
138
+ if output:
139
+ description += f"\n--- output ---\n{output}"
140
+ return description
141
+
142
+ # Generic tool - show everything for debugging
143
+ description = f"{tool} ({status})"
144
+ # Show input parameters
145
+ if input_data:
146
+ description += f"\n--- input ---\n"
147
+ for k, v in input_data.items():
148
+ description += f" {k}: {v}\n"
149
+ # Show error if present
150
+ error = state.get("error", "")
151
+ if error:
152
+ description += f"\n--- error ---\n{error}"
153
+ # Show output if present
154
+ if output:
155
+ description += f"\n--- output ---\n{output}"
156
+ return description
157
+
158
+ return self.description
159
+
160
+ @classmethod
161
+ def from_raw(cls, raw: dict[str, Any], source: str) -> "LogEvent":
162
+ """
163
+ Create LogEvent from raw JSON event data.
164
+
165
+ Parameters
166
+ ----------
167
+ raw : dict[str, Any]
168
+ Raw JSON event from log file.
169
+ source : str
170
+ Source name for this event.
171
+
172
+ Returns
173
+ -------
174
+ LogEvent
175
+ Parsed event.
176
+ """
177
+ timestamp = raw.get("timestamp")
178
+ # Handle None timestamp (for additional_output events)
179
+ if timestamp is None:
180
+ timestamp = 0 # Will be displayed specially
181
+ dt = datetime.min # Sentinel — no real timestamp, won't affect duration
182
+ else:
183
+ dt = datetime.fromtimestamp(timestamp / 1000)
184
+ event_type = raw.get("type", "unknown")
185
+ part = raw.get("part", {})
186
+
187
+ # Build description based on event type
188
+ description = ""
189
+ cost = 0.0
190
+ duration_ms = None
191
+
192
+ if event_type == "additional_output":
193
+ # Raw JSON output from tool/script (not a proper OpenCode event)
194
+ raw_data = part.get("raw_data", {})
195
+ description = f"[output] {json.dumps(raw_data)}"
196
+
197
+ elif event_type == "raw_text":
198
+ # Raw text output (non-JSON lines from log)
199
+ description = part.get("text", "")
200
+
201
+ elif event_type == "step_start":
202
+ description = "Step started"
203
+
204
+ elif event_type == "step_finish":
205
+ reason = part.get("reason", "unknown")
206
+ description = f"Step finished ({reason})"
207
+ cost = part.get("cost", 0.0)
208
+
209
+ elif event_type == "text":
210
+ text = part.get("text", "")
211
+ description = text
212
+
213
+ elif event_type == "error":
214
+ error_data = raw.get("error", {})
215
+ error_name = error_data.get("name", "Error")
216
+ data = error_data.get("data", {})
217
+ message = data.get("message", "Unknown error")
218
+ status_code = data.get("statusCode", "")
219
+ response_body = data.get("responseBody", "")
220
+
221
+ description = f"[{error_name}]"
222
+ if status_code:
223
+ description += f" (status: {status_code})"
224
+ description += f"\n{message}"
225
+ if response_body:
226
+ description += f"\n--- response ---\n{response_body}"
227
+
228
+ elif event_type == "tool_use":
229
+ tool = part.get("tool", "unknown")
230
+ state = part.get("state", {})
231
+ status = state.get("status", "unknown")
232
+ input_data = state.get("input", {})
233
+ output = state.get("output", "")
234
+
235
+ if tool == "bash":
236
+ cmd = input_data.get("command", "")
237
+ desc = input_data.get("description", "")
238
+ description = f"bash: {desc or cmd}"
239
+ if output:
240
+ description += f"\n--- output ---\n{output}"
241
+ elif tool == "read":
242
+ filepath = input_data.get("filePath", "")
243
+ description = f"read: {Path(filepath).name}"
244
+ elif tool == "write":
245
+ filepath = input_data.get("filePath", "")
246
+ filename = Path(filepath).name
247
+ content = input_data.get("content", "")
248
+ description = f"write: {filename}"
249
+ if content:
250
+ description += f"\n--- content ---\n{content}"
251
+ elif tool == "edit":
252
+ filepath = input_data.get("filePath", "")
253
+ old_string = input_data.get("oldString", "")
254
+ new_string = input_data.get("newString", "")
255
+ description = f"edit: {Path(filepath).name}"
256
+ if old_string or new_string:
257
+ description += f"\n-{old_string}\n+{new_string}"
258
+ elif tool == "task":
259
+ subagent = input_data.get("subagent_type", "")
260
+ desc = input_data.get("description", "")
261
+ description = f"task: {subagent} - {desc}"
262
+ if output:
263
+ description += f"\n--- output ---\n{output}"
264
+ elif tool == "parallel-agents":
265
+ agent = input_data.get("agent", "")
266
+ prompts = input_data.get("prompts", [])
267
+ description = f"parallel-agents: {agent} x{len(prompts)}"
268
+ if output:
269
+ description += f"\n--- output ---\n{output}"
270
+ else:
271
+ # Generic tool handling - show tool name, status, and any output/error
272
+ description = f"{tool} ({status})"
273
+ # Show input parameters
274
+ if input_data:
275
+ description += f"\n--- input ---\n"
276
+ for k, v in input_data.items():
277
+ description += f" {k}: {v}\n"
278
+ # Show error if present
279
+ error = state.get("error", "")
280
+ if error:
281
+ description += f"\n--- error ---\n{error}"
282
+ # Show output if present
283
+ if output:
284
+ description += f"\n--- output ---\n{output}"
285
+
286
+ # Extract timing
287
+ time_data = state.get("time", {})
288
+ if time_data:
289
+ start = time_data.get("start")
290
+ end = time_data.get("end")
291
+ if start and end:
292
+ duration_ms = end - start
293
+
294
+ return cls(
295
+ timestamp=timestamp,
296
+ dt=dt,
297
+ event_type=event_type,
298
+ source=source,
299
+ description=description,
300
+ raw=raw,
301
+ cost=cost,
302
+ duration_ms=duration_ms,
303
+ )
304
+
305
+
306
+ @dataclass
307
+ class AgentState:
308
+ """
309
+ State tracking for a single agent/source.
310
+
311
+ Attributes
312
+ ----------
313
+ name : str
314
+ Agent/source name.
315
+ is_running : bool
316
+ Whether the agent is still running.
317
+ events : list[LogEvent]
318
+ List of events for this agent.
319
+ total_cost : float
320
+ Accumulated cost.
321
+ start_time : datetime | None
322
+ Start time of first event.
323
+ end_time : datetime | None
324
+ End time of last event.
325
+ """
326
+
327
+ name: str
328
+ is_running: bool = True
329
+ events: list[LogEvent] = field(default_factory=list)
330
+ total_cost: float = 0.0
331
+ start_time: datetime | None = None
332
+ end_time: datetime | None = None
333
+
334
+ def add_event(self, event: LogEvent) -> bool:
335
+ """
336
+ Add event and update computed state.
337
+
338
+ Deduplicates events based on timestamp to prevent duplicates
339
+ from watchdog firing multiple events.
340
+
341
+ Parameters
342
+ ----------
343
+ event : LogEvent
344
+ Event to add.
345
+
346
+ Returns
347
+ -------
348
+ bool
349
+ True if event was added, False if it was a duplicate.
350
+ """
351
+ # Deduplicate based on timestamp (events with same timestamp are duplicates)
352
+ # Skip deduplication for timestamp=0 events (raw_text, additional_output)
353
+ if event.timestamp > 0 and any(e.timestamp == event.timestamp for e in self.events):
354
+ return False
355
+
356
+ self.events.append(event)
357
+ self.total_cost += event.cost
358
+
359
+ # Only update timing from events with real timestamps (not raw text / additional_output)
360
+ if event.timestamp > 0:
361
+ if self.start_time is None or event.dt < self.start_time:
362
+ self.start_time = event.dt
363
+ if self.end_time is None or event.dt > self.end_time:
364
+ self.end_time = event.dt
365
+
366
+ return True
367
+
368
+
369
+ @dataclass
370
+ class SessionState:
371
+ """
372
+ Overall session state.
373
+
374
+ Attributes
375
+ ----------
376
+ work_dir : Path
377
+ Path to work directory.
378
+ agents : dict[str, AgentState]
379
+ Agent states keyed by name.
380
+ global_start_ts : int | None
381
+ Earliest timestamp across all agents.
382
+ is_job_running : bool
383
+ Whether the job is still running.
384
+ """
385
+
386
+ work_dir: Path
387
+ agents: dict[str, AgentState] = field(default_factory=dict)
388
+ global_start_ts: int | None = None
389
+ is_job_running: bool = True
390
+
391
+ @property
392
+ def total_cost(self) -> float:
393
+ """Sum of all agent costs."""
394
+ return sum(a.total_cost for a in self.agents.values())
395
+
396
+ @property
397
+ def duration_seconds(self) -> float:
398
+ """
399
+ Duration from first to last timestamp in logs.
400
+
401
+ NEVER uses datetime.now() - duration is purely derived from
402
+ log timestamps so the view is identical whether viewed live
403
+ or a year later.
404
+ """
405
+ if self.global_start_ts is None:
406
+ return 0.0
407
+
408
+ start_dt = datetime.fromtimestamp(self.global_start_ts / 1000)
409
+
410
+ # Find latest end time from all agents (from log timestamps only)
411
+ end_times = [
412
+ a.end_time for a in self.agents.values() if a.end_time is not None
413
+ ]
414
+ if end_times:
415
+ end_dt = max(end_times)
416
+ else:
417
+ # No events yet - duration is 0
418
+ return 0.0
419
+
420
+ return (end_dt - start_dt).total_seconds()
421
+
422
+ def get_or_create_agent(self, name: str) -> AgentState:
423
+ """
424
+ Get existing agent state or create new one.
425
+
426
+ Parameters
427
+ ----------
428
+ name : str
429
+ Agent name.
430
+
431
+ Returns
432
+ -------
433
+ AgentState
434
+ Agent state.
435
+ """
436
+ if name not in self.agents:
437
+ self.agents[name] = AgentState(name=name)
438
+ return self.agents[name]
@@ -0,0 +1,18 @@
1
+ """
2
+ TUI widgets for dlab connect.
3
+ """
4
+
5
+ from dlab.tui.widgets.agent_list import AgentSelector
6
+ from dlab.tui.widgets.log_view import LogView
7
+ from dlab.tui.widgets.status_bar import StatusBar
8
+ from dlab.tui.widgets.artifacts_pane import ArtifactList, FileViewer
9
+ from dlab.tui.widgets.search_popup import SearchPopup
10
+
11
+ __all__ = [
12
+ "AgentSelector",
13
+ "LogView",
14
+ "StatusBar",
15
+ "ArtifactList",
16
+ "FileViewer",
17
+ "SearchPopup",
18
+ ]