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
galangal/ui/tui/entry.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Entry points for TUI-based stage execution.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_stage_with_tui(
|
|
7
|
+
task_name: str,
|
|
8
|
+
stage: str,
|
|
9
|
+
branch: str,
|
|
10
|
+
attempt: int,
|
|
11
|
+
prompt: str,
|
|
12
|
+
) -> tuple[bool, str]:
|
|
13
|
+
"""Run a single stage with TUI."""
|
|
14
|
+
from galangal.ui.tui.app import StageTUIApp
|
|
15
|
+
|
|
16
|
+
app = StageTUIApp(
|
|
17
|
+
task_name=task_name,
|
|
18
|
+
stage=stage,
|
|
19
|
+
branch=branch,
|
|
20
|
+
attempt=attempt,
|
|
21
|
+
prompt=prompt,
|
|
22
|
+
)
|
|
23
|
+
app.run()
|
|
24
|
+
return app.result
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mixin classes for WorkflowTUIApp functionality.
|
|
3
|
+
|
|
4
|
+
These mixins separate concerns while keeping the app class cohesive:
|
|
5
|
+
- WidgetAccessMixin: Safe widget access patterns
|
|
6
|
+
- WorkflowControlMixin: Workflow control state and actions
|
|
7
|
+
- PromptsMixin: Modal prompts and text input
|
|
8
|
+
- DiscoveryMixin: Discovery Q&A session handling
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
16
|
+
|
|
17
|
+
from textual.widgets import RichLog
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from textual.widget import Widget
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T", bound="Widget")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WidgetAccessMixin:
|
|
26
|
+
"""
|
|
27
|
+
Safe widget access patterns for TUI apps.
|
|
28
|
+
|
|
29
|
+
Provides helper methods that gracefully handle widget access
|
|
30
|
+
during screen transitions and shutdown.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def _safe_query(self, selector: str, widget_type: type[T]) -> T | None:
|
|
34
|
+
"""
|
|
35
|
+
Safely query a widget, returning None if not found.
|
|
36
|
+
|
|
37
|
+
Use this instead of query_one() when the widget might not exist
|
|
38
|
+
(e.g., during screen transitions or shutdown).
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
selector: CSS selector for the widget.
|
|
42
|
+
widget_type: Expected widget type.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The widget if found, None otherwise.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
return self.query_one(selector, widget_type)
|
|
49
|
+
except Exception:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def _safe_update(self, fn: Callable[[], None]) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Safely execute a UI update function.
|
|
55
|
+
|
|
56
|
+
Tries call_from_thread first (for background thread calls),
|
|
57
|
+
then falls back to direct call. Silently ignores errors
|
|
58
|
+
that occur during screen transitions.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
fn: Function to execute for UI update.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
self.call_from_thread(fn)
|
|
65
|
+
except Exception:
|
|
66
|
+
try:
|
|
67
|
+
fn()
|
|
68
|
+
except Exception:
|
|
69
|
+
pass # Silently ignore errors during transitions
|
|
70
|
+
|
|
71
|
+
def _safe_log_write(self, message: str) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Safely write to the activity log.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
message: Rich-formatted message to write.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def _write():
|
|
80
|
+
log = self._safe_query("#activity-log", RichLog)
|
|
81
|
+
if log:
|
|
82
|
+
log.write(message)
|
|
83
|
+
|
|
84
|
+
self._safe_update(_write)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class WorkflowControlMixin:
|
|
88
|
+
"""
|
|
89
|
+
Workflow control state and related actions.
|
|
90
|
+
|
|
91
|
+
Manages the control flags that coordinate between the TUI
|
|
92
|
+
and the background workflow thread.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def _init_control_state(self) -> None:
|
|
96
|
+
"""Initialize workflow control state flags."""
|
|
97
|
+
self._paused = False
|
|
98
|
+
self._interrupt_requested = False
|
|
99
|
+
self._skip_stage_requested = False
|
|
100
|
+
self._back_stage_requested = False
|
|
101
|
+
self._manual_edit_requested = False
|
|
102
|
+
self._workflow_result: str | None = None
|
|
103
|
+
|
|
104
|
+
def _reset_control_flags(self) -> None:
|
|
105
|
+
"""Reset all control flags to default state."""
|
|
106
|
+
self._paused = False
|
|
107
|
+
self._interrupt_requested = False
|
|
108
|
+
self._skip_stage_requested = False
|
|
109
|
+
self._back_stage_requested = False
|
|
110
|
+
self._manual_edit_requested = False
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def is_paused(self) -> bool:
|
|
114
|
+
"""Check if workflow is paused."""
|
|
115
|
+
return self._paused
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def has_pending_action(self) -> bool:
|
|
119
|
+
"""Check if any control action is pending."""
|
|
120
|
+
return (
|
|
121
|
+
self._interrupt_requested
|
|
122
|
+
or self._skip_stage_requested
|
|
123
|
+
or self._back_stage_requested
|
|
124
|
+
or self._manual_edit_requested
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class PromptsMixin:
|
|
129
|
+
"""
|
|
130
|
+
Modal prompts and text input handling.
|
|
131
|
+
|
|
132
|
+
Provides both callback-based and async versions of prompt methods.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def _init_prompt_state(self) -> None:
|
|
136
|
+
"""Initialize prompt-related state."""
|
|
137
|
+
from galangal.ui.tui.adapters import PromptType
|
|
138
|
+
|
|
139
|
+
self._prompt_type = PromptType.NONE
|
|
140
|
+
self._prompt_callback: Callable | None = None
|
|
141
|
+
self._active_prompt_screen = None
|
|
142
|
+
self._input_callback: Callable | None = None
|
|
143
|
+
self._active_input_screen = None
|
|
144
|
+
|
|
145
|
+
def _text_input_active(self) -> bool:
|
|
146
|
+
"""Check if text input is currently active and should capture keys."""
|
|
147
|
+
return self._input_callback is not None or self._active_input_screen is not None
|
|
148
|
+
|
|
149
|
+
def _prompt_active(self) -> bool:
|
|
150
|
+
"""Check if a prompt modal is currently active."""
|
|
151
|
+
return self._prompt_callback is not None or self._active_prompt_screen is not None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class DiscoveryMixin:
|
|
155
|
+
"""
|
|
156
|
+
Discovery Q&A session handling.
|
|
157
|
+
|
|
158
|
+
Provides async methods for the PM stage discovery Q&A flow.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
async def _run_qa_session(self, questions: list[str]) -> list[str] | None:
|
|
162
|
+
"""
|
|
163
|
+
Run a Q&A session and return answers.
|
|
164
|
+
|
|
165
|
+
This is a convenience wrapper around question_answer_session_async
|
|
166
|
+
that handles the modal display and result collection.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
questions: List of questions to ask.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
List of answers, or None if cancelled.
|
|
173
|
+
"""
|
|
174
|
+
future: asyncio.Future[list[str] | None] = asyncio.Future()
|
|
175
|
+
|
|
176
|
+
def _show():
|
|
177
|
+
from galangal.ui.tui.modals import QuestionAnswerModal
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
|
|
181
|
+
def _handle(result: list[str] | None) -> None:
|
|
182
|
+
if not future.done():
|
|
183
|
+
future.set_result(result)
|
|
184
|
+
|
|
185
|
+
screen = QuestionAnswerModal(questions)
|
|
186
|
+
self.push_screen(screen, _handle)
|
|
187
|
+
except Exception:
|
|
188
|
+
if not future.done():
|
|
189
|
+
future.set_result(None)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
self.call_from_thread(_show)
|
|
193
|
+
except Exception:
|
|
194
|
+
_show()
|
|
195
|
+
|
|
196
|
+
return await future
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Modal screens for TUI prompts and inputs.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Vertical
|
|
11
|
+
from textual.screen import ModalScreen
|
|
12
|
+
from textual.widgets import Input, Static, TextArea
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class PromptOption:
|
|
17
|
+
"""Option for a prompt modal."""
|
|
18
|
+
|
|
19
|
+
key: str
|
|
20
|
+
label: str
|
|
21
|
+
result: str
|
|
22
|
+
color: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PromptModal(ModalScreen[str]):
|
|
26
|
+
"""Modal prompt for multi-choice selections."""
|
|
27
|
+
|
|
28
|
+
CSS_PATH = "styles/modals.tcss"
|
|
29
|
+
|
|
30
|
+
BINDINGS = [
|
|
31
|
+
Binding("1", "choose_1", show=False),
|
|
32
|
+
Binding("2", "choose_2", show=False),
|
|
33
|
+
Binding("3", "choose_3", show=False),
|
|
34
|
+
Binding("4", "choose_4", show=False),
|
|
35
|
+
Binding("5", "choose_5", show=False),
|
|
36
|
+
Binding("6", "choose_6", show=False),
|
|
37
|
+
Binding("y", "choose_yes", show=False),
|
|
38
|
+
Binding("n", "choose_no", show=False),
|
|
39
|
+
Binding("q", "choose_quit", show=False),
|
|
40
|
+
Binding("escape", "choose_quit", show=False),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, options: list[PromptOption]):
|
|
44
|
+
super().__init__()
|
|
45
|
+
self._message = message
|
|
46
|
+
self._options = options
|
|
47
|
+
self._key_map = {option.key: option.result for option in options}
|
|
48
|
+
|
|
49
|
+
def compose(self) -> ComposeResult:
|
|
50
|
+
options_text = "\n".join(
|
|
51
|
+
f"[{option.color}]{option.key}[/] {option.label}" for option in self._options
|
|
52
|
+
)
|
|
53
|
+
# Dynamic hint based on number of options
|
|
54
|
+
max_key = max((int(o.key) for o in self._options if o.key.isdigit()), default=3)
|
|
55
|
+
hint = f"Press 1-{max_key} to choose, Esc to cancel"
|
|
56
|
+
with Vertical(id="prompt-dialog"):
|
|
57
|
+
yield Static(self._message, id="prompt-message")
|
|
58
|
+
yield Static(Text.from_markup(options_text), id="prompt-options")
|
|
59
|
+
yield Static(hint, id="prompt-hint")
|
|
60
|
+
|
|
61
|
+
def _submit_key(self, key: str) -> None:
|
|
62
|
+
result = self._key_map.get(key)
|
|
63
|
+
if result:
|
|
64
|
+
self.dismiss(result)
|
|
65
|
+
|
|
66
|
+
def action_choose_1(self) -> None:
|
|
67
|
+
self._submit_key("1")
|
|
68
|
+
|
|
69
|
+
def action_choose_2(self) -> None:
|
|
70
|
+
self._submit_key("2")
|
|
71
|
+
|
|
72
|
+
def action_choose_3(self) -> None:
|
|
73
|
+
self._submit_key("3")
|
|
74
|
+
|
|
75
|
+
def action_choose_4(self) -> None:
|
|
76
|
+
self._submit_key("4")
|
|
77
|
+
|
|
78
|
+
def action_choose_5(self) -> None:
|
|
79
|
+
self._submit_key("5")
|
|
80
|
+
|
|
81
|
+
def action_choose_6(self) -> None:
|
|
82
|
+
self._submit_key("6")
|
|
83
|
+
|
|
84
|
+
def action_choose_yes(self) -> None:
|
|
85
|
+
self.dismiss("yes")
|
|
86
|
+
|
|
87
|
+
def action_choose_no(self) -> None:
|
|
88
|
+
self.dismiss("no")
|
|
89
|
+
|
|
90
|
+
def action_choose_quit(self) -> None:
|
|
91
|
+
self.dismiss("quit")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TextInputModal(ModalScreen[str | None]):
|
|
95
|
+
"""Modal for collecting short text input."""
|
|
96
|
+
|
|
97
|
+
CSS_PATH = "styles/modals.tcss"
|
|
98
|
+
|
|
99
|
+
BINDINGS = [
|
|
100
|
+
Binding("escape", "cancel", show=False),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
def __init__(self, label: str, default: str = ""):
|
|
104
|
+
super().__init__()
|
|
105
|
+
self._label = label
|
|
106
|
+
self._default = default
|
|
107
|
+
|
|
108
|
+
def compose(self) -> ComposeResult:
|
|
109
|
+
with Vertical(id="text-input-dialog"):
|
|
110
|
+
yield Static(self._label, id="text-input-label")
|
|
111
|
+
yield Input(value=self._default, placeholder=self._label, id="text-input-field")
|
|
112
|
+
yield Static("Press Enter to submit, Esc to cancel", id="text-input-hint")
|
|
113
|
+
|
|
114
|
+
def on_mount(self) -> None:
|
|
115
|
+
field = self.query_one("#text-input-field", Input)
|
|
116
|
+
self.set_focus(field)
|
|
117
|
+
field.cursor_position = len(field.value)
|
|
118
|
+
|
|
119
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
120
|
+
if event.input.id == "text-input-field":
|
|
121
|
+
value = event.value.strip()
|
|
122
|
+
self.dismiss(value if value else None)
|
|
123
|
+
|
|
124
|
+
def action_cancel(self) -> None:
|
|
125
|
+
self.dismiss(None)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class QuestionAnswerModal(ModalScreen[list[str] | None]):
|
|
129
|
+
"""Modal for Q&A sessions - displays questions and collects answers sequentially."""
|
|
130
|
+
|
|
131
|
+
CSS_PATH = "styles/modals.tcss"
|
|
132
|
+
|
|
133
|
+
BINDINGS = [
|
|
134
|
+
Binding("escape", "cancel", show=False),
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
def __init__(self, questions: list[str]):
|
|
138
|
+
super().__init__()
|
|
139
|
+
self._questions = questions
|
|
140
|
+
self._answers: list[str] = []
|
|
141
|
+
self._current_index = 0
|
|
142
|
+
|
|
143
|
+
def compose(self) -> ComposeResult:
|
|
144
|
+
# Format all questions for display
|
|
145
|
+
questions_display = "\n".join(f" {i + 1}. {q}" for i, q in enumerate(self._questions))
|
|
146
|
+
|
|
147
|
+
with Vertical(id="qa-dialog"):
|
|
148
|
+
yield Static("Discovery Questions", id="qa-title")
|
|
149
|
+
yield Static(questions_display, id="qa-questions")
|
|
150
|
+
yield Static(self._get_current_question_text(), id="qa-current-question")
|
|
151
|
+
yield Input(placeholder="Your answer...", id="qa-input-field")
|
|
152
|
+
yield Static(self._get_progress_text(), id="qa-progress")
|
|
153
|
+
yield Static("Press Enter to submit answer, Esc to cancel", id="qa-hint")
|
|
154
|
+
|
|
155
|
+
def _get_current_question_text(self) -> str:
|
|
156
|
+
if self._current_index < len(self._questions):
|
|
157
|
+
return f"→ Q{self._current_index + 1}: {self._questions[self._current_index]}"
|
|
158
|
+
return "All questions answered!"
|
|
159
|
+
|
|
160
|
+
def _get_progress_text(self) -> str:
|
|
161
|
+
return f"Question {self._current_index + 1} of {len(self._questions)}"
|
|
162
|
+
|
|
163
|
+
def on_mount(self) -> None:
|
|
164
|
+
field = self.query_one("#qa-input-field", Input)
|
|
165
|
+
self.set_focus(field)
|
|
166
|
+
|
|
167
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
168
|
+
if event.input.id == "qa-input-field":
|
|
169
|
+
answer = event.value.strip()
|
|
170
|
+
if not answer:
|
|
171
|
+
return # Require non-empty answer
|
|
172
|
+
|
|
173
|
+
self._answers.append(answer)
|
|
174
|
+
self._current_index += 1
|
|
175
|
+
|
|
176
|
+
if self._current_index >= len(self._questions):
|
|
177
|
+
# All questions answered
|
|
178
|
+
self.dismiss(self._answers)
|
|
179
|
+
else:
|
|
180
|
+
# Update UI for next question
|
|
181
|
+
self._update_question_display()
|
|
182
|
+
event.input.value = ""
|
|
183
|
+
|
|
184
|
+
def _update_question_display(self) -> None:
|
|
185
|
+
question_widget = self.query_one("#qa-current-question", Static)
|
|
186
|
+
question_widget.update(self._get_current_question_text())
|
|
187
|
+
|
|
188
|
+
progress_widget = self.query_one("#qa-progress", Static)
|
|
189
|
+
progress_widget.update(self._get_progress_text())
|
|
190
|
+
|
|
191
|
+
def action_cancel(self) -> None:
|
|
192
|
+
self.dismiss(None)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class UserQuestionsModal(ModalScreen[list[str] | None]):
|
|
196
|
+
"""Modal for user to enter their own questions."""
|
|
197
|
+
|
|
198
|
+
CSS_PATH = "styles/modals.tcss"
|
|
199
|
+
|
|
200
|
+
BINDINGS = [
|
|
201
|
+
Binding("escape", "cancel", show=False),
|
|
202
|
+
Binding("ctrl+s", "submit", "Submit", show=True, priority=True),
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
def __init__(self) -> None:
|
|
206
|
+
super().__init__()
|
|
207
|
+
|
|
208
|
+
def compose(self) -> ComposeResult:
|
|
209
|
+
with Vertical(id="user-questions-dialog"):
|
|
210
|
+
yield Static("Enter your questions (one per line):", id="user-questions-label")
|
|
211
|
+
yield TextArea("", id="user-questions-field")
|
|
212
|
+
yield Static("Ctrl+S to submit, Esc to cancel", id="user-questions-hint")
|
|
213
|
+
|
|
214
|
+
def on_mount(self) -> None:
|
|
215
|
+
field = self.query_one("#user-questions-field", TextArea)
|
|
216
|
+
self.set_focus(field)
|
|
217
|
+
|
|
218
|
+
def action_submit(self) -> None:
|
|
219
|
+
field = self.query_one("#user-questions-field", TextArea)
|
|
220
|
+
text = field.text.strip()
|
|
221
|
+
if not text:
|
|
222
|
+
self.dismiss(None)
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
# Parse questions (one per line, skip empty lines)
|
|
226
|
+
questions = [q.strip() for q in text.split("\n") if q.strip()]
|
|
227
|
+
self.dismiss(questions if questions else None)
|
|
228
|
+
|
|
229
|
+
def action_cancel(self) -> None:
|
|
230
|
+
self.dismiss(None)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class MultilineInputModal(ModalScreen[str | None]):
|
|
234
|
+
"""Modal for collecting multi-line text input (task descriptions, briefs)."""
|
|
235
|
+
|
|
236
|
+
CSS_PATH = "styles/modals.tcss"
|
|
237
|
+
|
|
238
|
+
BINDINGS = [
|
|
239
|
+
Binding("escape", "cancel", show=False),
|
|
240
|
+
Binding("ctrl+s", "submit", "Submit", show=True, priority=True),
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
def __init__(self, label: str, default: str = ""):
|
|
244
|
+
super().__init__()
|
|
245
|
+
self._label = label
|
|
246
|
+
self._default = default
|
|
247
|
+
|
|
248
|
+
def compose(self) -> ComposeResult:
|
|
249
|
+
with Vertical(id="multiline-input-dialog"):
|
|
250
|
+
yield Static(self._label, id="multiline-input-label")
|
|
251
|
+
yield TextArea(self._default, id="multiline-input-field")
|
|
252
|
+
yield Static("Ctrl+S to submit, Esc to cancel", id="multiline-input-hint")
|
|
253
|
+
|
|
254
|
+
def on_mount(self) -> None:
|
|
255
|
+
field = self.query_one("#multiline-input-field", TextArea)
|
|
256
|
+
self.set_focus(field)
|
|
257
|
+
# Move cursor to end of text
|
|
258
|
+
field.move_cursor(field.document.end)
|
|
259
|
+
|
|
260
|
+
def action_submit(self) -> None:
|
|
261
|
+
field = self.query_one("#multiline-input-field", TextArea)
|
|
262
|
+
value = field.text.strip()
|
|
263
|
+
self.dismiss(value if value else None)
|
|
264
|
+
|
|
265
|
+
def action_cancel(self) -> None:
|
|
266
|
+
self.dismiss(None)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass
|
|
270
|
+
class GitHubIssueOption:
|
|
271
|
+
"""A GitHub issue option for selection."""
|
|
272
|
+
|
|
273
|
+
number: int
|
|
274
|
+
title: str
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class GitHubIssueSelectModal(ModalScreen[int | None]):
|
|
278
|
+
"""Modal for selecting a GitHub issue from a list."""
|
|
279
|
+
|
|
280
|
+
CSS_PATH = "styles/modals.tcss"
|
|
281
|
+
|
|
282
|
+
BINDINGS = [
|
|
283
|
+
Binding("escape", "cancel", show=False),
|
|
284
|
+
Binding("up", "move_up", show=False),
|
|
285
|
+
Binding("down", "move_down", show=False),
|
|
286
|
+
Binding("enter", "select", show=False),
|
|
287
|
+
Binding("k", "move_up", show=False),
|
|
288
|
+
Binding("j", "move_down", show=False),
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
def __init__(self, issues: list[GitHubIssueOption]):
|
|
292
|
+
super().__init__()
|
|
293
|
+
self._issues = issues
|
|
294
|
+
self._selected_index = 0
|
|
295
|
+
|
|
296
|
+
def compose(self) -> ComposeResult:
|
|
297
|
+
with Vertical(id="issue-select-dialog"):
|
|
298
|
+
yield Static("Select GitHub Issue", id="issue-select-title")
|
|
299
|
+
yield Static(self._render_issue_list(), id="issue-select-list")
|
|
300
|
+
yield Static(
|
|
301
|
+
"↑/↓ or j/k to navigate, Enter to select, Esc to cancel", id="issue-select-hint"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def _render_issue_list(self) -> str:
|
|
305
|
+
if not self._issues:
|
|
306
|
+
return "[dim]No issues found[/]"
|
|
307
|
+
|
|
308
|
+
lines = []
|
|
309
|
+
for i, issue in enumerate(self._issues):
|
|
310
|
+
prefix = "→ " if i == self._selected_index else " "
|
|
311
|
+
color = "#b8bb26" if i == self._selected_index else "#ebdbb2"
|
|
312
|
+
# Truncate title if too long
|
|
313
|
+
title = issue.title[:50] + "..." if len(issue.title) > 50 else issue.title
|
|
314
|
+
lines.append(f"[{color}]{prefix}#{issue.number} {title}[/]")
|
|
315
|
+
|
|
316
|
+
return "\n".join(lines)
|
|
317
|
+
|
|
318
|
+
def _update_display(self) -> None:
|
|
319
|
+
list_widget = self.query_one("#issue-select-list", Static)
|
|
320
|
+
list_widget.update(Text.from_markup(self._render_issue_list()))
|
|
321
|
+
|
|
322
|
+
def action_move_up(self) -> None:
|
|
323
|
+
if self._selected_index > 0:
|
|
324
|
+
self._selected_index -= 1
|
|
325
|
+
self._update_display()
|
|
326
|
+
|
|
327
|
+
def action_move_down(self) -> None:
|
|
328
|
+
if self._selected_index < len(self._issues) - 1:
|
|
329
|
+
self._selected_index += 1
|
|
330
|
+
self._update_display()
|
|
331
|
+
|
|
332
|
+
def action_select(self) -> None:
|
|
333
|
+
if self._issues:
|
|
334
|
+
self.dismiss(self._issues[self._selected_index].number)
|
|
335
|
+
else:
|
|
336
|
+
self.dismiss(None)
|
|
337
|
+
|
|
338
|
+
def action_cancel(self) -> None:
|
|
339
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Main TUI Application Styles
|
|
3
|
+
* Gruvbox Dark color scheme
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
Screen {
|
|
7
|
+
background: #282828;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
#workflow-root {
|
|
11
|
+
layout: grid;
|
|
12
|
+
grid-size: 1;
|
|
13
|
+
grid-rows: 2 2 1fr 1 auto;
|
|
14
|
+
height: 100%;
|
|
15
|
+
width: 100%;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#header {
|
|
19
|
+
background: #3c3836;
|
|
20
|
+
padding: 0 2;
|
|
21
|
+
border-bottom: solid #504945;
|
|
22
|
+
content-align: left middle;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#progress {
|
|
26
|
+
background: #282828;
|
|
27
|
+
padding: 0 2;
|
|
28
|
+
content-align: center middle;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#main-content {
|
|
32
|
+
layout: vertical;
|
|
33
|
+
height: 100%;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#error-panel {
|
|
37
|
+
background: #282828;
|
|
38
|
+
padding: 0 1;
|
|
39
|
+
max-height: 8;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#error-panel.hidden {
|
|
43
|
+
height: 0;
|
|
44
|
+
padding: 0;
|
|
45
|
+
display: none;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#content-area {
|
|
49
|
+
layout: horizontal;
|
|
50
|
+
height: 1fr;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#activity-container {
|
|
54
|
+
width: 75%;
|
|
55
|
+
border-right: solid #504945;
|
|
56
|
+
height: 100%;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#files-container {
|
|
60
|
+
width: 25%;
|
|
61
|
+
padding: 0 1;
|
|
62
|
+
background: #1d2021;
|
|
63
|
+
height: 100%;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#activity-log {
|
|
67
|
+
background: #282828;
|
|
68
|
+
scrollbar-color: #fe8019;
|
|
69
|
+
scrollbar-background: #3c3836;
|
|
70
|
+
height: 100%;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#current-action {
|
|
74
|
+
background: #3c3836;
|
|
75
|
+
padding: 0 2;
|
|
76
|
+
border-top: solid #504945;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
Footer {
|
|
80
|
+
background: #1d2021;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
Footer > .footer--key {
|
|
84
|
+
background: #d3869b;
|
|
85
|
+
color: #1d2021;
|
|
86
|
+
}
|