galangal-orchestrate 0.13.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.
- galangal/__init__.py +36 -0
- galangal/__main__.py +6 -0
- galangal/ai/__init__.py +167 -0
- galangal/ai/base.py +159 -0
- galangal/ai/claude.py +352 -0
- galangal/ai/codex.py +370 -0
- galangal/ai/gemini.py +43 -0
- galangal/ai/subprocess.py +254 -0
- galangal/cli.py +371 -0
- galangal/commands/__init__.py +27 -0
- galangal/commands/complete.py +367 -0
- galangal/commands/github.py +355 -0
- galangal/commands/init.py +177 -0
- galangal/commands/init_wizard.py +762 -0
- galangal/commands/list.py +20 -0
- galangal/commands/pause.py +34 -0
- galangal/commands/prompts.py +89 -0
- galangal/commands/reset.py +41 -0
- galangal/commands/resume.py +30 -0
- galangal/commands/skip.py +62 -0
- galangal/commands/start.py +530 -0
- galangal/commands/status.py +44 -0
- galangal/commands/switch.py +28 -0
- galangal/config/__init__.py +15 -0
- galangal/config/defaults.py +183 -0
- galangal/config/loader.py +163 -0
- galangal/config/schema.py +330 -0
- galangal/core/__init__.py +33 -0
- galangal/core/artifacts.py +136 -0
- galangal/core/state.py +1097 -0
- galangal/core/tasks.py +454 -0
- galangal/core/utils.py +116 -0
- galangal/core/workflow/__init__.py +68 -0
- galangal/core/workflow/core.py +789 -0
- galangal/core/workflow/engine.py +781 -0
- galangal/core/workflow/pause.py +35 -0
- galangal/core/workflow/tui_runner.py +1322 -0
- galangal/exceptions.py +36 -0
- galangal/github/__init__.py +31 -0
- galangal/github/client.py +427 -0
- galangal/github/images.py +324 -0
- galangal/github/issues.py +298 -0
- galangal/logging.py +364 -0
- galangal/prompts/__init__.py +5 -0
- galangal/prompts/builder.py +527 -0
- galangal/prompts/defaults/benchmark.md +34 -0
- galangal/prompts/defaults/contract.md +35 -0
- galangal/prompts/defaults/design.md +54 -0
- galangal/prompts/defaults/dev.md +89 -0
- galangal/prompts/defaults/docs.md +104 -0
- galangal/prompts/defaults/migration.md +59 -0
- galangal/prompts/defaults/pm.md +110 -0
- galangal/prompts/defaults/pm_questions.md +53 -0
- galangal/prompts/defaults/preflight.md +32 -0
- galangal/prompts/defaults/qa.md +65 -0
- galangal/prompts/defaults/review.md +90 -0
- galangal/prompts/defaults/review_codex.md +99 -0
- galangal/prompts/defaults/security.md +84 -0
- galangal/prompts/defaults/test.md +91 -0
- galangal/results.py +176 -0
- galangal/ui/__init__.py +5 -0
- galangal/ui/console.py +126 -0
- galangal/ui/tui/__init__.py +56 -0
- galangal/ui/tui/adapters.py +168 -0
- galangal/ui/tui/app.py +902 -0
- galangal/ui/tui/entry.py +24 -0
- galangal/ui/tui/mixins.py +196 -0
- galangal/ui/tui/modals.py +339 -0
- galangal/ui/tui/styles/app.tcss +86 -0
- galangal/ui/tui/styles/modals.tcss +197 -0
- galangal/ui/tui/types.py +107 -0
- galangal/ui/tui/widgets.py +263 -0
- galangal/validation/__init__.py +5 -0
- galangal/validation/runner.py +1072 -0
- galangal_orchestrate-0.13.0.dist-info/METADATA +985 -0
- galangal_orchestrate-0.13.0.dist-info/RECORD +79 -0
- galangal_orchestrate-0.13.0.dist-info/WHEEL +4 -0
- galangal_orchestrate-0.13.0.dist-info/entry_points.txt +2 -0
- galangal_orchestrate-0.13.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Modal Styles
|
|
3
|
+
* Shared styles for all modal screens
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/* Base modal centering */
|
|
7
|
+
PromptModal,
|
|
8
|
+
TextInputModal,
|
|
9
|
+
QuestionAnswerModal,
|
|
10
|
+
UserQuestionsModal,
|
|
11
|
+
MultilineInputModal {
|
|
12
|
+
align: center middle;
|
|
13
|
+
layout: vertical;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* ============================================
|
|
17
|
+
* Prompt Modal (multi-choice selections)
|
|
18
|
+
* ============================================ */
|
|
19
|
+
|
|
20
|
+
#prompt-dialog {
|
|
21
|
+
width: 90%;
|
|
22
|
+
max-width: 120;
|
|
23
|
+
min-width: 50;
|
|
24
|
+
max-height: 80%;
|
|
25
|
+
background: #3c3836;
|
|
26
|
+
border: round #504945;
|
|
27
|
+
padding: 1 2;
|
|
28
|
+
layout: vertical;
|
|
29
|
+
overflow-y: auto;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#prompt-message {
|
|
33
|
+
color: #ebdbb2;
|
|
34
|
+
text-style: bold;
|
|
35
|
+
margin-bottom: 1;
|
|
36
|
+
text-wrap: wrap;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#prompt-options {
|
|
40
|
+
color: #ebdbb2;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#prompt-hint {
|
|
44
|
+
color: #7c6f64;
|
|
45
|
+
margin-top: 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ============================================
|
|
49
|
+
* Text Input Modal (single-line)
|
|
50
|
+
* ============================================ */
|
|
51
|
+
|
|
52
|
+
#text-input-dialog {
|
|
53
|
+
width: 70%;
|
|
54
|
+
max-width: 80;
|
|
55
|
+
min-width: 40;
|
|
56
|
+
background: #3c3836;
|
|
57
|
+
border: round #504945;
|
|
58
|
+
padding: 1 2;
|
|
59
|
+
layout: vertical;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#text-input-label {
|
|
63
|
+
color: #ebdbb2;
|
|
64
|
+
text-style: bold;
|
|
65
|
+
margin-bottom: 1;
|
|
66
|
+
text-wrap: wrap;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#text-input-field {
|
|
70
|
+
width: 100%;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#text-input-hint {
|
|
74
|
+
color: #7c6f64;
|
|
75
|
+
margin-top: 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ============================================
|
|
79
|
+
* Question Answer Modal (discovery Q&A)
|
|
80
|
+
* ============================================ */
|
|
81
|
+
|
|
82
|
+
#qa-dialog {
|
|
83
|
+
width: 90%;
|
|
84
|
+
max-width: 100;
|
|
85
|
+
min-width: 60;
|
|
86
|
+
max-height: 85%;
|
|
87
|
+
background: #3c3836;
|
|
88
|
+
border: round #504945;
|
|
89
|
+
padding: 1 2;
|
|
90
|
+
layout: vertical;
|
|
91
|
+
overflow-y: auto;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#qa-title {
|
|
95
|
+
color: #fe8019;
|
|
96
|
+
text-style: bold;
|
|
97
|
+
margin-bottom: 1;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#qa-questions {
|
|
101
|
+
color: #a89984;
|
|
102
|
+
margin-bottom: 1;
|
|
103
|
+
padding: 0 1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#qa-current-question {
|
|
107
|
+
color: #ebdbb2;
|
|
108
|
+
text-style: bold;
|
|
109
|
+
margin-bottom: 1;
|
|
110
|
+
padding: 0 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#qa-input-field {
|
|
114
|
+
width: 100%;
|
|
115
|
+
margin-bottom: 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#qa-progress {
|
|
119
|
+
color: #7c6f64;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#qa-hint {
|
|
123
|
+
color: #7c6f64;
|
|
124
|
+
margin-top: 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ============================================
|
|
128
|
+
* User Questions Modal (enter own questions)
|
|
129
|
+
* ============================================ */
|
|
130
|
+
|
|
131
|
+
#user-questions-dialog {
|
|
132
|
+
width: 90%;
|
|
133
|
+
max-width: 100;
|
|
134
|
+
min-width: 50;
|
|
135
|
+
height: auto;
|
|
136
|
+
max-height: 80%;
|
|
137
|
+
background: #3c3836;
|
|
138
|
+
border: round #504945;
|
|
139
|
+
padding: 1 2;
|
|
140
|
+
layout: vertical;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#user-questions-label {
|
|
144
|
+
color: #ebdbb2;
|
|
145
|
+
text-style: bold;
|
|
146
|
+
margin-bottom: 1;
|
|
147
|
+
text-wrap: wrap;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#user-questions-field {
|
|
151
|
+
width: 100%;
|
|
152
|
+
height: 10;
|
|
153
|
+
min-height: 6;
|
|
154
|
+
background: #282828;
|
|
155
|
+
border: solid #504945;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#user-questions-hint {
|
|
159
|
+
color: #7c6f64;
|
|
160
|
+
margin-top: 1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ============================================
|
|
164
|
+
* Multiline Input Modal (task descriptions)
|
|
165
|
+
* ============================================ */
|
|
166
|
+
|
|
167
|
+
#multiline-input-dialog {
|
|
168
|
+
width: 90%;
|
|
169
|
+
max-width: 100;
|
|
170
|
+
min-width: 50;
|
|
171
|
+
height: auto;
|
|
172
|
+
max-height: 80%;
|
|
173
|
+
background: #3c3836;
|
|
174
|
+
border: round #504945;
|
|
175
|
+
padding: 1 2;
|
|
176
|
+
layout: vertical;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#multiline-input-label {
|
|
180
|
+
color: #ebdbb2;
|
|
181
|
+
text-style: bold;
|
|
182
|
+
margin-bottom: 1;
|
|
183
|
+
text-wrap: wrap;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#multiline-input-field {
|
|
187
|
+
width: 100%;
|
|
188
|
+
height: 12;
|
|
189
|
+
min-height: 6;
|
|
190
|
+
background: #282828;
|
|
191
|
+
border: solid #504945;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#multiline-input-hint {
|
|
195
|
+
color: #7c6f64;
|
|
196
|
+
margin-top: 1;
|
|
197
|
+
}
|
galangal/ui/tui/types.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Activity log types for structured logging.
|
|
3
|
+
|
|
4
|
+
Provides structured activity entries with level, category, and optional details
|
|
5
|
+
for filtering and export capabilities.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ActivityLevel(str, Enum):
|
|
15
|
+
"""Log entry severity level."""
|
|
16
|
+
|
|
17
|
+
INFO = "info"
|
|
18
|
+
SUCCESS = "success"
|
|
19
|
+
WARNING = "warning"
|
|
20
|
+
ERROR = "error"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ActivityCategory(str, Enum):
|
|
24
|
+
"""Log entry category for filtering."""
|
|
25
|
+
|
|
26
|
+
STAGE = "stage" # Stage transitions
|
|
27
|
+
VALIDATION = "validation" # Validation results
|
|
28
|
+
CLAUDE = "claude" # AI backend activity
|
|
29
|
+
FILE = "file" # File operations
|
|
30
|
+
SYSTEM = "system" # System messages
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ActivityEntry:
|
|
35
|
+
"""
|
|
36
|
+
Structured activity log entry.
|
|
37
|
+
|
|
38
|
+
Captures rich metadata about each activity for filtering,
|
|
39
|
+
display, and export purposes.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
timestamp: When the activity occurred (UTC).
|
|
43
|
+
level: Severity level (info, success, warning, error).
|
|
44
|
+
category: Category for filtering (stage, validation, claude, file, system).
|
|
45
|
+
message: Human-readable message.
|
|
46
|
+
icon: Deprecated, kept for backwards compatibility.
|
|
47
|
+
details: Optional additional details (e.g., stack trace, full output).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
message: str
|
|
51
|
+
level: ActivityLevel = ActivityLevel.INFO
|
|
52
|
+
category: ActivityCategory = ActivityCategory.SYSTEM
|
|
53
|
+
icon: str = "•"
|
|
54
|
+
details: str | None = None
|
|
55
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
56
|
+
|
|
57
|
+
def format_display(self, show_timestamp: bool = True) -> str:
|
|
58
|
+
"""
|
|
59
|
+
Format entry for display in TUI.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
show_timestamp: Whether to include timestamp prefix.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Formatted string with Rich markup.
|
|
66
|
+
"""
|
|
67
|
+
colors = {
|
|
68
|
+
ActivityLevel.INFO: "#ebdbb2",
|
|
69
|
+
ActivityLevel.SUCCESS: "#b8bb26",
|
|
70
|
+
ActivityLevel.WARNING: "#fabd2f",
|
|
71
|
+
ActivityLevel.ERROR: "#fb4934",
|
|
72
|
+
}
|
|
73
|
+
color = colors.get(self.level, "#ebdbb2")
|
|
74
|
+
|
|
75
|
+
if show_timestamp:
|
|
76
|
+
time_str = self.timestamp.strftime("%H:%M:%S")
|
|
77
|
+
return f"[#928374]{time_str}[/] [{color}]{self.message}[/]"
|
|
78
|
+
return f"[{color}]{self.message}[/]"
|
|
79
|
+
|
|
80
|
+
def format_export(self) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Format entry for file export (plain text).
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Plain text line suitable for log file.
|
|
86
|
+
"""
|
|
87
|
+
time_str = self.timestamp.isoformat()
|
|
88
|
+
line = f"{time_str} [{self.level.value}] [{self.category.value}] {self.message}"
|
|
89
|
+
if self.details:
|
|
90
|
+
line += f"\n {self.details}"
|
|
91
|
+
return line
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def export_activity_log(entries: list[ActivityEntry], path: Path) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Export activity log entries to a file.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
entries: List of activity entries to export.
|
|
100
|
+
path: File path to write to.
|
|
101
|
+
"""
|
|
102
|
+
with open(path, "w") as f:
|
|
103
|
+
f.write("# Activity Log Export\n")
|
|
104
|
+
f.write(f"# Generated: {datetime.now(timezone.utc).isoformat()}\n")
|
|
105
|
+
f.write(f"# Entries: {len(entries)}\n\n")
|
|
106
|
+
for entry in entries:
|
|
107
|
+
f.write(entry.format_export() + "\n")
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom Textual widgets for TUI display.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual.reactive import reactive
|
|
8
|
+
from textual.widgets import Static
|
|
9
|
+
|
|
10
|
+
from galangal import __version__
|
|
11
|
+
from galangal.core.state import STAGE_ORDER
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HeaderWidget(Static):
|
|
15
|
+
"""Fixed header showing task info."""
|
|
16
|
+
|
|
17
|
+
task_name: reactive[str] = reactive("")
|
|
18
|
+
stage: reactive[str] = reactive("")
|
|
19
|
+
attempt: reactive[int] = reactive(1)
|
|
20
|
+
max_retries: reactive[int] = reactive(5)
|
|
21
|
+
elapsed: reactive[str] = reactive("0:00")
|
|
22
|
+
turns: reactive[int] = reactive(0)
|
|
23
|
+
status: reactive[str] = reactive("starting")
|
|
24
|
+
|
|
25
|
+
def render(self) -> Text:
|
|
26
|
+
text = Text()
|
|
27
|
+
|
|
28
|
+
# Row 1: Task, Stage, Attempt
|
|
29
|
+
text.append("Task: ", style="#928374")
|
|
30
|
+
text.append(self.task_name[:30], style="bold #83a598")
|
|
31
|
+
text.append(" Stage: ", style="#928374")
|
|
32
|
+
text.append(f"{self.stage}", style="bold #fabd2f")
|
|
33
|
+
text.append(f" ({self.attempt}/{self.max_retries})", style="#928374")
|
|
34
|
+
text.append(" Elapsed: ", style="#928374")
|
|
35
|
+
text.append(self.elapsed, style="bold #ebdbb2")
|
|
36
|
+
text.append(" Turns: ", style="#928374")
|
|
37
|
+
text.append(str(self.turns), style="bold #b8bb26")
|
|
38
|
+
text.append(f" v{__version__}", style="#665c54")
|
|
39
|
+
|
|
40
|
+
return text
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _format_duration(seconds: int) -> str:
|
|
44
|
+
"""Format duration in seconds to human-readable string."""
|
|
45
|
+
if seconds >= 3600:
|
|
46
|
+
hours, remainder = divmod(seconds, 3600)
|
|
47
|
+
mins, secs = divmod(remainder, 60)
|
|
48
|
+
return f"{hours}:{mins:02d}:{secs:02d}"
|
|
49
|
+
else:
|
|
50
|
+
mins, secs = divmod(seconds, 60)
|
|
51
|
+
return f"{mins}:{secs:02d}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class StageProgressWidget(Static):
|
|
55
|
+
"""Centered stage progress bar with full names and durations."""
|
|
56
|
+
|
|
57
|
+
current_stage: reactive[str] = reactive("PM")
|
|
58
|
+
skipped_stages: reactive[frozenset] = reactive(frozenset())
|
|
59
|
+
hidden_stages: reactive[frozenset] = reactive(frozenset())
|
|
60
|
+
stage_durations: reactive[dict] = reactive({}, always_update=True)
|
|
61
|
+
|
|
62
|
+
# Full stage display names
|
|
63
|
+
STAGE_DISPLAY = {
|
|
64
|
+
"PM": "PM",
|
|
65
|
+
"DESIGN": "DESIGN",
|
|
66
|
+
"PREFLIGHT": "PREFLIGHT",
|
|
67
|
+
"DEV": "DEV",
|
|
68
|
+
"MIGRATION": "MIGRATION",
|
|
69
|
+
"TEST": "TEST",
|
|
70
|
+
"CONTRACT": "CONTRACT",
|
|
71
|
+
"QA": "QA",
|
|
72
|
+
"BENCHMARK": "BENCHMARK",
|
|
73
|
+
"SECURITY": "SECURITY",
|
|
74
|
+
"REVIEW": "REVIEW",
|
|
75
|
+
"DOCS": "DOCS",
|
|
76
|
+
"COMPLETE": "COMPLETE",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
STAGE_COMPACT = {
|
|
80
|
+
"PM": "PM",
|
|
81
|
+
"DESIGN": "DSGN",
|
|
82
|
+
"PREFLIGHT": "PREF",
|
|
83
|
+
"DEV": "DEV",
|
|
84
|
+
"MIGRATION": "MIGR",
|
|
85
|
+
"TEST": "TEST",
|
|
86
|
+
"CONTRACT": "CNTR",
|
|
87
|
+
"QA": "QA",
|
|
88
|
+
"BENCHMARK": "BENCH",
|
|
89
|
+
"SECURITY": "SEC",
|
|
90
|
+
"REVIEW": "RVW",
|
|
91
|
+
"DOCS": "DOCS",
|
|
92
|
+
"COMPLETE": "DONE",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def render(self) -> Text:
|
|
96
|
+
text = Text(justify="center")
|
|
97
|
+
|
|
98
|
+
# Filter out hidden stages (task type + config skips)
|
|
99
|
+
visible_stages = [s for s in STAGE_ORDER if s.value not in self.hidden_stages]
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
current_idx = next(
|
|
103
|
+
i for i, s in enumerate(visible_stages) if s.value == self.current_stage
|
|
104
|
+
)
|
|
105
|
+
except StopIteration:
|
|
106
|
+
current_idx = 0
|
|
107
|
+
|
|
108
|
+
width = self.size.width or 0
|
|
109
|
+
use_window = width and width < 70
|
|
110
|
+
use_compact = width and width < 110
|
|
111
|
+
display_names = self.STAGE_COMPACT if use_compact else self.STAGE_DISPLAY
|
|
112
|
+
|
|
113
|
+
stages = visible_stages
|
|
114
|
+
if use_window:
|
|
115
|
+
start = max(current_idx - 2, 0)
|
|
116
|
+
end = min(current_idx + 3, len(stages))
|
|
117
|
+
items: list[int | None] = []
|
|
118
|
+
if start > 0:
|
|
119
|
+
items.append(None)
|
|
120
|
+
items.extend(range(start, end))
|
|
121
|
+
if end < len(stages):
|
|
122
|
+
items.append(None)
|
|
123
|
+
else:
|
|
124
|
+
items = list(range(len(stages)))
|
|
125
|
+
|
|
126
|
+
for idx, stage_idx in enumerate(items):
|
|
127
|
+
if idx > 0:
|
|
128
|
+
text.append(" ━ ", style="#504945")
|
|
129
|
+
if stage_idx is None:
|
|
130
|
+
text.append("...", style="#504945")
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
stage = stages[stage_idx]
|
|
134
|
+
name = display_names.get(stage.value, stage.value)
|
|
135
|
+
|
|
136
|
+
if stage.value in self.skipped_stages:
|
|
137
|
+
text.append(f"⊘ {name}", style="#504945 strike")
|
|
138
|
+
elif stage_idx < current_idx:
|
|
139
|
+
# Completed stage - show with duration if available
|
|
140
|
+
duration = self.stage_durations.get(stage.value)
|
|
141
|
+
if duration is not None:
|
|
142
|
+
duration_str = _format_duration(duration)
|
|
143
|
+
text.append(f"● {name} ", style="#b8bb26")
|
|
144
|
+
text.append(f"({duration_str})", style="#928374")
|
|
145
|
+
else:
|
|
146
|
+
text.append(f"● {name}", style="#b8bb26")
|
|
147
|
+
elif stage_idx == current_idx:
|
|
148
|
+
text.append(f"◉ {name}", style="bold #fabd2f")
|
|
149
|
+
else:
|
|
150
|
+
text.append(f"○ {name}", style="#504945")
|
|
151
|
+
|
|
152
|
+
return text
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class CurrentActionWidget(Static):
|
|
156
|
+
"""Shows the current action with animated spinner."""
|
|
157
|
+
|
|
158
|
+
action: reactive[str] = reactive("")
|
|
159
|
+
detail: reactive[str] = reactive("")
|
|
160
|
+
spinner_frame: reactive[int] = reactive(0)
|
|
161
|
+
|
|
162
|
+
SPINNERS = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
163
|
+
|
|
164
|
+
def render(self) -> Text:
|
|
165
|
+
text = Text()
|
|
166
|
+
if self.action:
|
|
167
|
+
spinner = self.SPINNERS[self.spinner_frame % len(self.SPINNERS)]
|
|
168
|
+
text.append(f"{spinner} ", style="#83a598")
|
|
169
|
+
text.append(self.action, style="bold #ebdbb2")
|
|
170
|
+
if self.detail:
|
|
171
|
+
detail = self.detail
|
|
172
|
+
width = self.size.width or 0
|
|
173
|
+
if width:
|
|
174
|
+
reserved = len(self.action) + 4
|
|
175
|
+
max_detail = max(width - reserved, 0)
|
|
176
|
+
if max_detail and len(detail) > max_detail:
|
|
177
|
+
if max_detail > 3:
|
|
178
|
+
detail = detail[: max_detail - 3] + "..."
|
|
179
|
+
else:
|
|
180
|
+
detail = ""
|
|
181
|
+
if not detail:
|
|
182
|
+
return text
|
|
183
|
+
text.append(f": {detail}", style="#928374")
|
|
184
|
+
else:
|
|
185
|
+
text.append("○ Idle", style="#504945")
|
|
186
|
+
return text
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class FilesPanelWidget(Static):
|
|
190
|
+
"""Panel showing files that have been read/written."""
|
|
191
|
+
|
|
192
|
+
def __init__(self, **kwargs):
|
|
193
|
+
super().__init__(**kwargs)
|
|
194
|
+
self._files: list[tuple[str, str]] = []
|
|
195
|
+
|
|
196
|
+
def add_file(self, action: str, path: str) -> None:
|
|
197
|
+
"""Add a file operation."""
|
|
198
|
+
entry = (action, path)
|
|
199
|
+
if entry not in self._files:
|
|
200
|
+
self._files.append(entry)
|
|
201
|
+
self.refresh()
|
|
202
|
+
|
|
203
|
+
def render(self) -> Text:
|
|
204
|
+
width = self.size.width or 24
|
|
205
|
+
divider_width = max(width - 1, 1)
|
|
206
|
+
text = Text()
|
|
207
|
+
text.append("Files\n", style="bold #928374")
|
|
208
|
+
text.append("─" * divider_width + "\n", style="#504945")
|
|
209
|
+
|
|
210
|
+
if not self._files:
|
|
211
|
+
text.append("(none yet)", style="#504945 italic")
|
|
212
|
+
else:
|
|
213
|
+
# Show last 20 files
|
|
214
|
+
for action, path in self._files[-20:]:
|
|
215
|
+
display_path = path
|
|
216
|
+
if "/" in display_path:
|
|
217
|
+
parts = display_path.split("/")
|
|
218
|
+
display_path = "/".join(parts[-2:])
|
|
219
|
+
max_len = max(width - 4, 1)
|
|
220
|
+
if len(display_path) > max_len:
|
|
221
|
+
if max_len > 3:
|
|
222
|
+
display_path = display_path[: max_len - 3] + "..."
|
|
223
|
+
else:
|
|
224
|
+
display_path = display_path[:max_len]
|
|
225
|
+
icon = "✏️" if action == "write" else "📖"
|
|
226
|
+
color = "#b8bb26" if action == "write" else "#83a598"
|
|
227
|
+
text.append(f"{icon} ", style=color)
|
|
228
|
+
text.append(f"{display_path}\n", style="#ebdbb2")
|
|
229
|
+
|
|
230
|
+
return text
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class ErrorPanelWidget(Static):
|
|
234
|
+
"""Dedicated panel for showing current error prominently."""
|
|
235
|
+
|
|
236
|
+
error: reactive[str | None] = reactive(None)
|
|
237
|
+
details: reactive[str | None] = reactive(None)
|
|
238
|
+
|
|
239
|
+
def render(self) -> Panel | Text:
|
|
240
|
+
if not self.error:
|
|
241
|
+
return Text("")
|
|
242
|
+
|
|
243
|
+
# Build error content
|
|
244
|
+
content = Text()
|
|
245
|
+
content.append(self.error, style="bold #fb4934")
|
|
246
|
+
|
|
247
|
+
if self.details:
|
|
248
|
+
# Truncate details if too long
|
|
249
|
+
details = self.details
|
|
250
|
+
max_lines = 5
|
|
251
|
+
lines = details.split("\n")
|
|
252
|
+
if len(lines) > max_lines:
|
|
253
|
+
details = "\n".join(lines[:max_lines]) + "\n..."
|
|
254
|
+
|
|
255
|
+
content.append("\n\n", style="")
|
|
256
|
+
content.append(details, style="#ebdbb2")
|
|
257
|
+
|
|
258
|
+
return Panel(
|
|
259
|
+
content,
|
|
260
|
+
title="[bold #fb4934]Error[/]",
|
|
261
|
+
border_style="#cc241d",
|
|
262
|
+
padding=(0, 1),
|
|
263
|
+
)
|