forgeo-cli 0.3.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.
- forgeo/__init__.py +10 -0
- forgeo/__main__.py +6 -0
- forgeo/agent.py +332 -0
- forgeo/backlog.py +206 -0
- forgeo/central.py +620 -0
- forgeo/cli.py +759 -0
- forgeo/config.py +36 -0
- forgeo/daemon.py +209 -0
- forgeo/forgeo.py +446 -0
- forgeo/git.py +100 -0
- forgeo/instances.py +187 -0
- forgeo/io.py +30 -0
- forgeo/models.py +252 -0
- forgeo/notify.py +77 -0
- forgeo/runs.py +73 -0
- forgeo/setup.py +185 -0
- forgeo/web/central/central.css +380 -0
- forgeo/web/central/central.js +780 -0
- forgeo/web/central/index.html +41 -0
- forgeo/web/central/instance.html +189 -0
- forgeo/web/style.css +656 -0
- forgeo/web_common.py +92 -0
- forgeo_cli-0.3.0.dist-info/METADATA +146 -0
- forgeo_cli-0.3.0.dist-info/RECORD +27 -0
- forgeo_cli-0.3.0.dist-info/WHEEL +4 -0
- forgeo_cli-0.3.0.dist-info/entry_points.txt +2 -0
- forgeo_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
forgeo/runs.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Durable run history: one JSON line per finished cycle in ``runs.jsonl``.
|
|
2
|
+
|
|
3
|
+
The file lives next to the backlog (``runs.jsonl`` beside ``backlog.json``)
|
|
4
|
+
so Forgeo, the CLI, and the web API all read the same records. Reading
|
|
5
|
+
tolerates a missing file and skips corrupt lines with a warning, so a broken
|
|
6
|
+
``runs.jsonl`` never breaks a cycle or the API.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
|
|
16
|
+
from forgeo.models import RunRecord
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def runs_path_for(backlog_path: str | Path) -> Path:
|
|
22
|
+
"""The ``runs.jsonl`` path that sits next to the backlog file."""
|
|
23
|
+
return Path(backlog_path).with_name("runs.jsonl")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RunRecorder:
|
|
27
|
+
"""Appends :class:`RunRecord` rows to a JSON-lines file and reads them back."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, path: str | Path) -> None:
|
|
30
|
+
self.path = Path(path)
|
|
31
|
+
|
|
32
|
+
def append(self, record: RunRecord) -> None:
|
|
33
|
+
"""Append ``record`` as one JSON line.
|
|
34
|
+
|
|
35
|
+
A write failure is logged and never raised, so recording can never
|
|
36
|
+
break a Forgeo cycle.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
41
|
+
handle.write(record.model_dump_json() + "\n")
|
|
42
|
+
except OSError as exc:
|
|
43
|
+
logger.error("Could not write run record to %s: %s", self.path, exc)
|
|
44
|
+
|
|
45
|
+
def read(self, limit: int | None = None) -> list[RunRecord]:
|
|
46
|
+
"""Return the newest ``limit`` records, newest first.
|
|
47
|
+
|
|
48
|
+
A missing file yields an empty list; corrupt lines are skipped with a
|
|
49
|
+
warning. ``limit=None`` returns every readable record.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
text = self.path.read_text(encoding="utf-8", errors="replace")
|
|
53
|
+
except OSError:
|
|
54
|
+
return []
|
|
55
|
+
records: list[RunRecord] = []
|
|
56
|
+
for line_no, line in enumerate(text.splitlines(), start=1):
|
|
57
|
+
if not line.strip():
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
records.append(RunRecord.model_validate_json(line))
|
|
61
|
+
except ValidationError:
|
|
62
|
+
logger.warning(
|
|
63
|
+
"Skipping corrupt run record in %s on line %s", self.path, line_no
|
|
64
|
+
)
|
|
65
|
+
records.sort(key=lambda record: record.finished_at, reverse=True)
|
|
66
|
+
if limit is None:
|
|
67
|
+
return records
|
|
68
|
+
return records[: max(0, limit)]
|
|
69
|
+
|
|
70
|
+
def read_last(self) -> RunRecord | None:
|
|
71
|
+
"""Return the most recent record, or ``None`` when none exists."""
|
|
72
|
+
records = self.read(limit=1)
|
|
73
|
+
return records[0] if records else None
|
forgeo/setup.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Guided first-time setup: ``forgeo init``.
|
|
2
|
+
|
|
3
|
+
Walks the user through the three decisions Forgeo needs before it can
|
|
4
|
+
work on a repository:
|
|
5
|
+
|
|
6
|
+
1. Forgeo folder — where the backlog, ``BLOCKER.md`` and the log live
|
|
7
|
+
(inside the project, gitignored by default);
|
|
8
|
+
2. the coding agent command — any shell command that reads ``$FORGEO_TASK``
|
|
9
|
+
and works in the repository (e.g. ``claude -p "$FORGEO_TASK"``);
|
|
10
|
+
3. the refactoring prompt — the default is offered; a custom one can be
|
|
11
|
+
pasted instead.
|
|
12
|
+
|
|
13
|
+
The result is written as ``forgeo.yaml`` next to the project.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.markup import escape
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
from rich.prompt import Confirm, Prompt
|
|
26
|
+
|
|
27
|
+
from forgeo.models import DEFAULT_REFACTOR_PROMPT
|
|
28
|
+
|
|
29
|
+
DEFAULT_FORGEO_DIR = ".forgeo"
|
|
30
|
+
DEFAULT_AGENT_COMMAND = 'aider --message "$FORGEO_TASK"'
|
|
31
|
+
|
|
32
|
+
SetupInput = Callable[[str], str]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _ask_text(input_fn: SetupInput | None, prompt: str, default: str | None = None) -> str:
|
|
36
|
+
"""Free-text question; ``input_fn`` replaces the terminal in tests."""
|
|
37
|
+
if input_fn is not None:
|
|
38
|
+
return input_fn(prompt)
|
|
39
|
+
if default is None:
|
|
40
|
+
return Prompt.ask(prompt)
|
|
41
|
+
return Prompt.ask(prompt, default=default)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ask_yes_no(input_fn: SetupInput | None, prompt: str, default: bool = True) -> bool:
|
|
45
|
+
"""Yes/no question; ``input_fn`` replaces the terminal in tests."""
|
|
46
|
+
if input_fn is not None:
|
|
47
|
+
return input_fn(prompt).strip().lower() in ("y", "yes")
|
|
48
|
+
return Confirm.ask(prompt, default=default)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ask_multiline(input_fn: SetupInput | None, prompt: str, console: Console) -> str:
|
|
52
|
+
"""Multi-line answer; an empty line finishes it."""
|
|
53
|
+
if input_fn is None:
|
|
54
|
+
console.print(prompt)
|
|
55
|
+
prompt = "[dim](paste a line; an empty line finishes)[/dim]"
|
|
56
|
+
lines = []
|
|
57
|
+
while True:
|
|
58
|
+
line = input_fn(prompt) if input_fn is not None else Prompt.ask(prompt)
|
|
59
|
+
if not line.strip():
|
|
60
|
+
break
|
|
61
|
+
lines.append(line.strip())
|
|
62
|
+
return "\n".join(lines)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def add_gitignore(project_root: Path, line: str) -> bool:
|
|
66
|
+
"""Append ``line`` to ``<project_root>/.gitignore`` when absent."""
|
|
67
|
+
path = project_root / ".gitignore"
|
|
68
|
+
if path.exists():
|
|
69
|
+
content = path.read_text(encoding="utf-8")
|
|
70
|
+
if line in content.splitlines():
|
|
71
|
+
return False
|
|
72
|
+
content = content.rstrip("\n") + "\n" + line + "\n"
|
|
73
|
+
else:
|
|
74
|
+
content = line + "\n"
|
|
75
|
+
path.write_text(content, encoding="utf-8")
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def run_setup(
|
|
80
|
+
base_dir: Path,
|
|
81
|
+
config_path: Path,
|
|
82
|
+
*,
|
|
83
|
+
console: Console | None = None,
|
|
84
|
+
input_fn: SetupInput | None = None,
|
|
85
|
+
) -> dict[str, object] | None:
|
|
86
|
+
"""Interactively collect the configuration and write it to ``config_path``.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
base_dir: Directory the config lives in (the project root); all
|
|
90
|
+
generated paths are relative to it.
|
|
91
|
+
config_path: Where to write the YAML config.
|
|
92
|
+
console: Rich console for output (a new one when omitted).
|
|
93
|
+
input_fn: Replacement for the terminal prompts (tests).
|
|
94
|
+
|
|
95
|
+
Returns the written YAML payload, or ``None`` when the setup was aborted.
|
|
96
|
+
"""
|
|
97
|
+
out = console or Console()
|
|
98
|
+
root = base_dir.resolve()
|
|
99
|
+
if not (root / ".git").exists():
|
|
100
|
+
out.print(
|
|
101
|
+
"[yellow]Warning: no .git directory here — Forgeo works on a git "
|
|
102
|
+
"repository.[/yellow]"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
forgeo_dir = _ask_text(
|
|
106
|
+
input_fn,
|
|
107
|
+
f"[bold]Forgeo folder[/bold] for backlog, BLOCKER.md and logs "
|
|
108
|
+
f"[default {DEFAULT_FORGEO_DIR}]",
|
|
109
|
+
default=DEFAULT_FORGEO_DIR,
|
|
110
|
+
).strip()
|
|
111
|
+
forgeo_dir = forgeo_dir.removeprefix("./").rstrip("/") or DEFAULT_FORGEO_DIR
|
|
112
|
+
if Path(forgeo_dir).is_absolute():
|
|
113
|
+
out.print("[red]Forgeo folder must live inside the project. Aborting.[/red]")
|
|
114
|
+
return None
|
|
115
|
+
if ".." in Path(forgeo_dir).parts:
|
|
116
|
+
out.print(
|
|
117
|
+
"[yellow]Note: Forgeo folder escapes the project root — the "
|
|
118
|
+
"gitignore rule will not protect it.[/yellow]"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
command = _ask_text(
|
|
122
|
+
input_fn,
|
|
123
|
+
f"[bold]Coding agent command[/bold] [default {DEFAULT_AGENT_COMMAND}]",
|
|
124
|
+
default=DEFAULT_AGENT_COMMAND,
|
|
125
|
+
).strip() or DEFAULT_AGENT_COMMAND
|
|
126
|
+
if "$FORGEO_TASK" not in command:
|
|
127
|
+
out.print(
|
|
128
|
+
"[yellow]Note: the command never references $FORGEO_TASK, so the "
|
|
129
|
+
"agent will not receive the task text.[/yellow]"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if _ask_yes_no(input_fn, "[bold]Use the default refactor prompt?[/bold]", default=True):
|
|
133
|
+
refactor_prompt = DEFAULT_REFACTOR_PROMPT
|
|
134
|
+
else:
|
|
135
|
+
out.print("[bold]Your refactor prompt[/bold] (used when the backlog is empty):")
|
|
136
|
+
refactor_prompt = (
|
|
137
|
+
_ask_multiline(input_fn, "[dim](paste a line; empty line finishes)[/dim]", out)
|
|
138
|
+
or DEFAULT_REFACTOR_PROMPT
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if _ask_yes_no(
|
|
142
|
+
input_fn,
|
|
143
|
+
f"[bold]Add '{escape(forgeo_dir)}/' to .gitignore?[/bold]",
|
|
144
|
+
default=True,
|
|
145
|
+
):
|
|
146
|
+
if add_gitignore(root, forgeo_dir + "/"):
|
|
147
|
+
out.print(f"[green]Added {forgeo_dir}/ to .gitignore.[/green]")
|
|
148
|
+
else:
|
|
149
|
+
out.print(f"[dim]{forgeo_dir}/ already in .gitignore.[/dim]")
|
|
150
|
+
|
|
151
|
+
payload = {
|
|
152
|
+
"name": root.name or "my-forgeo",
|
|
153
|
+
"repo": ".",
|
|
154
|
+
"interval_minutes": 60,
|
|
155
|
+
"branch": "main",
|
|
156
|
+
"backlog": f"{forgeo_dir}/backlog.json",
|
|
157
|
+
"blocker_file": f"{forgeo_dir}/BLOCKER.md",
|
|
158
|
+
"agent_command": command,
|
|
159
|
+
"refactor_prompt": refactor_prompt,
|
|
160
|
+
"log_file": f"{forgeo_dir}/forgeo.log",
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
body = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
|
|
165
|
+
config_path.write_text(
|
|
166
|
+
"# Forgeo configuration — generated by `forgeo init`.\n"
|
|
167
|
+
"# Relative paths resolve against this file's directory.\n"
|
|
168
|
+
"# Re-run `forgeo init --force` to regenerate. See README.md for all keys.\n\n"
|
|
169
|
+
+ body,
|
|
170
|
+
encoding="utf-8",
|
|
171
|
+
)
|
|
172
|
+
(root / forgeo_dir).mkdir(parents=True, exist_ok=True)
|
|
173
|
+
|
|
174
|
+
out.print(
|
|
175
|
+
Panel.fit(
|
|
176
|
+
f"[bold]Forgeo configured[/bold] in {config_path}\n"
|
|
177
|
+
f"[bold]Repo:[/bold] {root}\n"
|
|
178
|
+
f"[bold]Backlog:[/bold] {(root / forgeo_dir) / 'backlog.json'}\n"
|
|
179
|
+
f"[bold]Agent:[/bold] {escape(command)}\n"
|
|
180
|
+
f"[bold]Next:[/bold] forgeo start --config {config_path.name}",
|
|
181
|
+
title="Forgeo",
|
|
182
|
+
border_style="green",
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
return payload
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/* Extra component styles for the central dashboard (home + instance pages).
|
|
2
|
+
The dark theme, board, status columns and task cards come from style.css. */
|
|
3
|
+
|
|
4
|
+
/* ---------- Instance list (home) ---------- */
|
|
5
|
+
|
|
6
|
+
.instance-list {
|
|
7
|
+
flex: 1;
|
|
8
|
+
padding: 32px 0 40px;
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-direction: column;
|
|
11
|
+
gap: 14px;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.instance-card {
|
|
15
|
+
display: block;
|
|
16
|
+
background: var(--surface);
|
|
17
|
+
border: 1px solid var(--border);
|
|
18
|
+
border-radius: var(--radius);
|
|
19
|
+
padding: 16px 18px;
|
|
20
|
+
color: var(--text);
|
|
21
|
+
text-decoration: none;
|
|
22
|
+
transition: transform 0.15s ease, border-color 0.18s ease, background 0.18s ease;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.instance-card:hover {
|
|
26
|
+
transform: translateY(-1px);
|
|
27
|
+
background: var(--surface-hover);
|
|
28
|
+
border-color: var(--border-strong);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.instance-card__head {
|
|
32
|
+
display: flex;
|
|
33
|
+
align-items: baseline;
|
|
34
|
+
justify-content: space-between;
|
|
35
|
+
gap: 10px;
|
|
36
|
+
margin-bottom: 12px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.instance-card__name {
|
|
40
|
+
font-size: 16px;
|
|
41
|
+
font-weight: 600;
|
|
42
|
+
letter-spacing: 0.01em;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.instance-card__grid {
|
|
46
|
+
display: grid;
|
|
47
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
48
|
+
gap: 14px;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.instance-card__info {
|
|
52
|
+
display: flex;
|
|
53
|
+
flex-direction: column;
|
|
54
|
+
gap: 2px;
|
|
55
|
+
min-width: 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.instance-card__label {
|
|
59
|
+
font-size: 11px;
|
|
60
|
+
text-transform: uppercase;
|
|
61
|
+
letter-spacing: 0.09em;
|
|
62
|
+
color: var(--text-faint);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.instance-card__value {
|
|
66
|
+
font-size: 13px;
|
|
67
|
+
color: var(--text-muted);
|
|
68
|
+
font-variant-numeric: tabular-nums;
|
|
69
|
+
white-space: nowrap;
|
|
70
|
+
overflow: hidden;
|
|
71
|
+
text-overflow: ellipsis;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.instance-card__counts {
|
|
75
|
+
display: flex;
|
|
76
|
+
gap: 8px;
|
|
77
|
+
flex-wrap: wrap;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.count-chip {
|
|
81
|
+
font-size: 11px;
|
|
82
|
+
font-weight: 600;
|
|
83
|
+
letter-spacing: 0.06em;
|
|
84
|
+
padding: 3px 9px;
|
|
85
|
+
border-radius: 999px;
|
|
86
|
+
border: 1px solid var(--border);
|
|
87
|
+
color: var(--text-muted);
|
|
88
|
+
font-variant-numeric: tabular-nums;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.count-chip--OPEN {
|
|
92
|
+
color: var(--open);
|
|
93
|
+
background: rgba(143, 183, 255, 0.1);
|
|
94
|
+
border-color: transparent;
|
|
95
|
+
}
|
|
96
|
+
.count-chip--BLOCKED {
|
|
97
|
+
color: var(--blocked);
|
|
98
|
+
background: rgba(240, 168, 77, 0.16);
|
|
99
|
+
border-color: transparent;
|
|
100
|
+
}
|
|
101
|
+
.count-chip--COMPLETED {
|
|
102
|
+
color: var(--completed);
|
|
103
|
+
background: rgba(111, 206, 147, 0.1);
|
|
104
|
+
border-color: transparent;
|
|
105
|
+
}
|
|
106
|
+
.count-chip--FAILED {
|
|
107
|
+
color: var(--failed);
|
|
108
|
+
background: rgba(224, 108, 117, 0.1);
|
|
109
|
+
border-color: transparent;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ---------- Daemon badge (instance page header) ---------- */
|
|
113
|
+
|
|
114
|
+
.daemon-badge {
|
|
115
|
+
font-size: 11px;
|
|
116
|
+
font-weight: 600;
|
|
117
|
+
letter-spacing: 0.08em;
|
|
118
|
+
text-transform: uppercase;
|
|
119
|
+
padding: 3px 9px;
|
|
120
|
+
border-radius: 999px;
|
|
121
|
+
white-space: nowrap;
|
|
122
|
+
color: var(--text-muted);
|
|
123
|
+
border: 1px solid var(--border);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.daemon-badge--running {
|
|
127
|
+
color: var(--completed);
|
|
128
|
+
background: rgba(111, 206, 147, 0.1);
|
|
129
|
+
border-color: rgba(111, 206, 147, 0.35);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.daemon-badge--stopped {
|
|
133
|
+
color: var(--failed);
|
|
134
|
+
background: rgba(224, 108, 117, 0.1);
|
|
135
|
+
border-color: rgba(224, 108, 117, 0.35);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* ---------- Tabs ---------- */
|
|
139
|
+
|
|
140
|
+
.tabs {
|
|
141
|
+
display: flex;
|
|
142
|
+
gap: 6px;
|
|
143
|
+
padding: 14px 0 0;
|
|
144
|
+
border-bottom: 1px solid var(--border);
|
|
145
|
+
overflow-x: auto;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.tab {
|
|
149
|
+
appearance: none;
|
|
150
|
+
background: none;
|
|
151
|
+
border: none;
|
|
152
|
+
border-bottom: 2px solid transparent;
|
|
153
|
+
color: var(--text-muted);
|
|
154
|
+
font: inherit;
|
|
155
|
+
font-size: 13px;
|
|
156
|
+
font-weight: 600;
|
|
157
|
+
letter-spacing: 0.04em;
|
|
158
|
+
padding: 10px 14px 12px;
|
|
159
|
+
cursor: pointer;
|
|
160
|
+
white-space: nowrap;
|
|
161
|
+
transition: color 0.15s ease, border-color 0.15s ease;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.tab:hover {
|
|
165
|
+
color: var(--text);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.tab.is-active {
|
|
169
|
+
color: var(--text);
|
|
170
|
+
border-bottom-color: var(--accent);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.tab-panel {
|
|
174
|
+
flex: 1;
|
|
175
|
+
padding: 20px 0 40px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.tab-panel[hidden] {
|
|
179
|
+
display: none;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ---------- Code panels (logs / blocker / config) ---------- */
|
|
183
|
+
|
|
184
|
+
.code-panel {
|
|
185
|
+
margin: 0;
|
|
186
|
+
background: var(--surface);
|
|
187
|
+
border: 1px solid var(--border);
|
|
188
|
+
border-radius: var(--radius);
|
|
189
|
+
padding: 16px 18px;
|
|
190
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
191
|
+
font-size: 12.5px;
|
|
192
|
+
line-height: 1.6;
|
|
193
|
+
color: var(--text-muted);
|
|
194
|
+
white-space: pre-wrap;
|
|
195
|
+
word-break: break-word;
|
|
196
|
+
max-height: 70vh;
|
|
197
|
+
overflow: auto;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* ---------- Run table ---------- */
|
|
201
|
+
|
|
202
|
+
.table-wrap {
|
|
203
|
+
background: var(--surface);
|
|
204
|
+
border: 1px solid var(--border);
|
|
205
|
+
border-radius: var(--radius);
|
|
206
|
+
overflow: auto;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.run-table {
|
|
210
|
+
width: 100%;
|
|
211
|
+
border-collapse: collapse;
|
|
212
|
+
font-size: 13px;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.run-table th,
|
|
216
|
+
.run-table td {
|
|
217
|
+
text-align: left;
|
|
218
|
+
padding: 10px 14px;
|
|
219
|
+
border-bottom: 1px solid var(--border);
|
|
220
|
+
white-space: nowrap;
|
|
221
|
+
font-variant-numeric: tabular-nums;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.run-table th {
|
|
225
|
+
font-size: 11px;
|
|
226
|
+
text-transform: uppercase;
|
|
227
|
+
letter-spacing: 0.09em;
|
|
228
|
+
color: var(--text-faint);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.run-table tbody tr:last-child td {
|
|
232
|
+
border-bottom: none;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.run-table tbody tr:hover {
|
|
236
|
+
background: var(--surface-hover);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.run-table .mono {
|
|
240
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
241
|
+
font-size: 12px;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* ---------- Footer ---------- */
|
|
245
|
+
|
|
246
|
+
.footer-link {
|
|
247
|
+
color: var(--accent);
|
|
248
|
+
font-weight: 600;
|
|
249
|
+
text-decoration: none;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.footer-link:hover {
|
|
253
|
+
text-decoration: underline;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* ---------- Task detail modal: edit mode ---------- */
|
|
257
|
+
|
|
258
|
+
#task-modal-view {
|
|
259
|
+
display: flex;
|
|
260
|
+
flex-direction: column;
|
|
261
|
+
gap: 16px;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#task-modal-view[hidden] {
|
|
265
|
+
display: none;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.modal__head-actions {
|
|
269
|
+
flex: none;
|
|
270
|
+
display: flex;
|
|
271
|
+
align-items: center;
|
|
272
|
+
gap: 8px;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.modal__btn {
|
|
276
|
+
appearance: none;
|
|
277
|
+
background: var(--surface-hover);
|
|
278
|
+
border: 1px solid var(--border);
|
|
279
|
+
border-radius: var(--radius-sm);
|
|
280
|
+
color: var(--text-muted);
|
|
281
|
+
font: inherit;
|
|
282
|
+
font-size: 13px;
|
|
283
|
+
font-weight: 600;
|
|
284
|
+
padding: 6px 14px;
|
|
285
|
+
cursor: pointer;
|
|
286
|
+
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.modal__btn:hover {
|
|
290
|
+
color: var(--text);
|
|
291
|
+
border-color: var(--border-strong);
|
|
292
|
+
background: var(--border);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.modal__btn--primary {
|
|
296
|
+
background: var(--accent-dim);
|
|
297
|
+
border-color: rgba(124, 140, 255, 0.45);
|
|
298
|
+
color: var(--accent);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.modal__btn--primary:hover {
|
|
302
|
+
background: rgba(124, 140, 255, 0.24);
|
|
303
|
+
border-color: var(--accent);
|
|
304
|
+
color: var(--accent);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.modal__edit {
|
|
308
|
+
display: flex;
|
|
309
|
+
flex-direction: column;
|
|
310
|
+
gap: 14px;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.modal__edit[hidden] {
|
|
314
|
+
display: none;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.modal__edit-field {
|
|
318
|
+
display: flex;
|
|
319
|
+
flex-direction: column;
|
|
320
|
+
gap: 5px;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.modal__edit-field label {
|
|
324
|
+
font-size: 11px;
|
|
325
|
+
font-weight: 600;
|
|
326
|
+
text-transform: uppercase;
|
|
327
|
+
letter-spacing: 0.09em;
|
|
328
|
+
color: var(--text-faint);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.modal__edit-field input,
|
|
332
|
+
.modal__edit-field textarea {
|
|
333
|
+
width: 100%;
|
|
334
|
+
background: var(--bg);
|
|
335
|
+
border: 1px solid var(--border);
|
|
336
|
+
border-radius: var(--radius-sm);
|
|
337
|
+
color: var(--text);
|
|
338
|
+
font: inherit;
|
|
339
|
+
font-size: 14px;
|
|
340
|
+
padding: 8px 10px;
|
|
341
|
+
outline: none;
|
|
342
|
+
resize: vertical;
|
|
343
|
+
transition: border-color 0.18s ease, background 0.18s ease;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.modal__edit-field input:focus,
|
|
347
|
+
.modal__edit-field textarea:focus {
|
|
348
|
+
border-color: var(--accent);
|
|
349
|
+
background: var(--surface-hover);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.modal__edit-actions {
|
|
353
|
+
display: flex;
|
|
354
|
+
justify-content: flex-end;
|
|
355
|
+
gap: 8px;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.modal__error {
|
|
359
|
+
margin: 0;
|
|
360
|
+
font-size: 13px;
|
|
361
|
+
color: var(--failed);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.modal__error[hidden] {
|
|
365
|
+
display: none;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* ---------- Responsive ---------- */
|
|
369
|
+
|
|
370
|
+
@media (max-width: 860px) {
|
|
371
|
+
.instance-card__grid {
|
|
372
|
+
grid-template-columns: 1fr 1fr;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
@media (max-width: 560px) {
|
|
377
|
+
.instance-card__grid {
|
|
378
|
+
grid-template-columns: 1fr;
|
|
379
|
+
}
|
|
380
|
+
}
|