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.
Files changed (79) hide show
  1. galangal/__init__.py +36 -0
  2. galangal/__main__.py +6 -0
  3. galangal/ai/__init__.py +167 -0
  4. galangal/ai/base.py +159 -0
  5. galangal/ai/claude.py +352 -0
  6. galangal/ai/codex.py +370 -0
  7. galangal/ai/gemini.py +43 -0
  8. galangal/ai/subprocess.py +254 -0
  9. galangal/cli.py +371 -0
  10. galangal/commands/__init__.py +27 -0
  11. galangal/commands/complete.py +367 -0
  12. galangal/commands/github.py +355 -0
  13. galangal/commands/init.py +177 -0
  14. galangal/commands/init_wizard.py +762 -0
  15. galangal/commands/list.py +20 -0
  16. galangal/commands/pause.py +34 -0
  17. galangal/commands/prompts.py +89 -0
  18. galangal/commands/reset.py +41 -0
  19. galangal/commands/resume.py +30 -0
  20. galangal/commands/skip.py +62 -0
  21. galangal/commands/start.py +530 -0
  22. galangal/commands/status.py +44 -0
  23. galangal/commands/switch.py +28 -0
  24. galangal/config/__init__.py +15 -0
  25. galangal/config/defaults.py +183 -0
  26. galangal/config/loader.py +163 -0
  27. galangal/config/schema.py +330 -0
  28. galangal/core/__init__.py +33 -0
  29. galangal/core/artifacts.py +136 -0
  30. galangal/core/state.py +1097 -0
  31. galangal/core/tasks.py +454 -0
  32. galangal/core/utils.py +116 -0
  33. galangal/core/workflow/__init__.py +68 -0
  34. galangal/core/workflow/core.py +789 -0
  35. galangal/core/workflow/engine.py +781 -0
  36. galangal/core/workflow/pause.py +35 -0
  37. galangal/core/workflow/tui_runner.py +1322 -0
  38. galangal/exceptions.py +36 -0
  39. galangal/github/__init__.py +31 -0
  40. galangal/github/client.py +427 -0
  41. galangal/github/images.py +324 -0
  42. galangal/github/issues.py +298 -0
  43. galangal/logging.py +364 -0
  44. galangal/prompts/__init__.py +5 -0
  45. galangal/prompts/builder.py +527 -0
  46. galangal/prompts/defaults/benchmark.md +34 -0
  47. galangal/prompts/defaults/contract.md +35 -0
  48. galangal/prompts/defaults/design.md +54 -0
  49. galangal/prompts/defaults/dev.md +89 -0
  50. galangal/prompts/defaults/docs.md +104 -0
  51. galangal/prompts/defaults/migration.md +59 -0
  52. galangal/prompts/defaults/pm.md +110 -0
  53. galangal/prompts/defaults/pm_questions.md +53 -0
  54. galangal/prompts/defaults/preflight.md +32 -0
  55. galangal/prompts/defaults/qa.md +65 -0
  56. galangal/prompts/defaults/review.md +90 -0
  57. galangal/prompts/defaults/review_codex.md +99 -0
  58. galangal/prompts/defaults/security.md +84 -0
  59. galangal/prompts/defaults/test.md +91 -0
  60. galangal/results.py +176 -0
  61. galangal/ui/__init__.py +5 -0
  62. galangal/ui/console.py +126 -0
  63. galangal/ui/tui/__init__.py +56 -0
  64. galangal/ui/tui/adapters.py +168 -0
  65. galangal/ui/tui/app.py +902 -0
  66. galangal/ui/tui/entry.py +24 -0
  67. galangal/ui/tui/mixins.py +196 -0
  68. galangal/ui/tui/modals.py +339 -0
  69. galangal/ui/tui/styles/app.tcss +86 -0
  70. galangal/ui/tui/styles/modals.tcss +197 -0
  71. galangal/ui/tui/types.py +107 -0
  72. galangal/ui/tui/widgets.py +263 -0
  73. galangal/validation/__init__.py +5 -0
  74. galangal/validation/runner.py +1072 -0
  75. galangal_orchestrate-0.13.0.dist-info/METADATA +985 -0
  76. galangal_orchestrate-0.13.0.dist-info/RECORD +79 -0
  77. galangal_orchestrate-0.13.0.dist-info/WHEEL +4 -0
  78. galangal_orchestrate-0.13.0.dist-info/entry_points.txt +2 -0
  79. galangal_orchestrate-0.13.0.dist-info/licenses/LICENSE +674 -0
@@ -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
+ }