context-forge-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. context_forge_cli-0.1.0.dist-info/METADATA +340 -0
  2. context_forge_cli-0.1.0.dist-info/RECORD +37 -0
  3. context_forge_cli-0.1.0.dist-info/WHEEL +4 -0
  4. context_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. contextforge/__init__.py +3 -0
  6. contextforge/adapters/__init__.py +3 -0
  7. contextforge/adapters/altimate_code.py +179 -0
  8. contextforge/adapters/base.py +44 -0
  9. contextforge/adapters/claude_code.py +393 -0
  10. contextforge/adapters/claude_desktop.py +306 -0
  11. contextforge/adapters/codex.py +220 -0
  12. contextforge/adapters/registry.py +31 -0
  13. contextforge/cli.py +523 -0
  14. contextforge/core/__init__.py +0 -0
  15. contextforge/core/analytics.py +226 -0
  16. contextforge/core/compactor.py +182 -0
  17. contextforge/core/db.py +181 -0
  18. contextforge/core/injector.py +94 -0
  19. contextforge/core/scanner.py +72 -0
  20. contextforge/core/summarizer.py +155 -0
  21. contextforge/core/token_analyzer.py +91 -0
  22. contextforge/models/__init__.py +4 -0
  23. contextforge/models/config.py +34 -0
  24. contextforge/models/session.py +37 -0
  25. contextforge/tui/__init__.py +0 -0
  26. contextforge/tui/app.py +204 -0
  27. contextforge/tui/styles.tcss +50 -0
  28. contextforge/tui/widgets/__init__.py +0 -0
  29. contextforge/tui/widgets/session_detail.py +191 -0
  30. contextforge/tui/widgets/session_table.py +277 -0
  31. contextforge/tui/widgets/stats_panel.py +340 -0
  32. contextforge/tui/widgets/status_bar.py +102 -0
  33. contextforge/tui/widgets/tokens_panel.py +134 -0
  34. contextforge/tui/widgets/transfer_panel.py +119 -0
  35. contextforge/utils/__init__.py +0 -0
  36. contextforge/utils/display.py +105 -0
  37. contextforge/utils/tokens.py +17 -0
@@ -0,0 +1,72 @@
1
+ """Session discovery and indexing across all registered adapters."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ import sqlite_utils
8
+ from rich.progress import Progress, SpinnerColumn, TextColumn
9
+
10
+ from contextforge.adapters.registry import get_available_adapters
11
+ from contextforge.core.db import upsert_session
12
+ from contextforge.models.session import Session
13
+
14
+
15
+ @dataclass
16
+ class ScanResult:
17
+ new: int = 0
18
+ updated: int = 0
19
+ unchanged: int = 0
20
+ errors: list[str] = field(default_factory=list)
21
+ sessions: list[Session] = field(default_factory=list)
22
+
23
+ @property
24
+ def total(self) -> int:
25
+ return self.new + self.updated + self.unchanged
26
+
27
+
28
+ def scan(db: sqlite_utils.Database, quiet: bool = False) -> ScanResult:
29
+ result = ScanResult()
30
+ adapters = get_available_adapters()
31
+
32
+ if not adapters:
33
+ result.errors.append("No supported agentic tools found on this machine.")
34
+ return result
35
+
36
+ with Progress(
37
+ SpinnerColumn(),
38
+ TextColumn("[progress.description]{task.description}"),
39
+ disable=quiet,
40
+ ) as progress:
41
+ for adapter in adapters:
42
+ task = progress.add_task(f"Scanning {adapter.tool_name}...", total=None)
43
+ try:
44
+ sessions = adapter.discover_sessions()
45
+ except Exception as exc:
46
+ result.errors.append(f"{adapter.tool_name}: {exc}")
47
+ progress.remove_task(task)
48
+ continue
49
+
50
+ for session in sessions:
51
+ try:
52
+ existing = list(
53
+ db["sessions"].rows_where("id = ?", [session.id])
54
+ )
55
+ before_count = len(existing)
56
+ upsert_session(db, session)
57
+ after = list(db["sessions"].rows_where("id = ?", [session.id]))
58
+
59
+ if before_count == 0:
60
+ result.new += 1
61
+ elif existing[0]["updated_at"] < int(session.updated_at.timestamp() * 1000):
62
+ result.updated += 1
63
+ else:
64
+ result.unchanged += 1
65
+ result.sessions.append(session)
66
+ except Exception as exc:
67
+ result.errors.append(f"{session.id}: {exc}")
68
+
69
+ progress.update(task, description=f"[green]{adapter.tool_name}: {len(sessions)} sessions")
70
+ progress.remove_task(task)
71
+
72
+ return result
@@ -0,0 +1,155 @@
1
+ """LLM-based session summarization with caching."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from dataclasses import dataclass, field
6
+
7
+ import sqlite_utils
8
+
9
+ from contextforge.adapters.registry import get_adapter
10
+ from contextforge.core.db import get_session, get_sessions, update_summary
11
+ from contextforge.models.config import ForgeConfig
12
+ from contextforge.utils.tokens import count_tokens, truncate_to_budget
13
+
14
+ _SUMMARY_PROMPT = """\
15
+ You are summarizing a coding session for a developer. Read the conversation below and
16
+ write a 3–5 sentence summary covering:
17
+ - What was being built or investigated
18
+ - Key decisions made or problems solved
19
+ - Files and components touched
20
+ - Current state / what remains to be done
21
+
22
+ Be concise. Under 150 words. Plain prose, no bullet points.
23
+
24
+ --- SESSION ---
25
+ {transcript}
26
+ --- END SESSION ---
27
+
28
+ Summary:"""
29
+
30
+
31
+ @dataclass
32
+ class BatchSummaryResult:
33
+ summarized: int = 0
34
+ skipped: int = 0
35
+ errors: list[str] = field(default_factory=list)
36
+
37
+
38
+ def summarize_session(
39
+ db: sqlite_utils.Database,
40
+ session_id: str,
41
+ config: ForgeConfig,
42
+ force: bool = False,
43
+ ) -> str | None:
44
+ """Generate (or return cached) summary for a single session.
45
+
46
+ Priority order:
47
+ 1. Cached summary in DB (unless force=True)
48
+ 2. Tool-native rollup summary (e.g. Codex stage1_outputs) — zero LLM cost
49
+ 3. LLM-generated summary (requires ANTHROPIC_API_KEY)
50
+ 4. Graceful degradation: first user message preview
51
+ """
52
+ row = get_session(db, session_id)
53
+ if row is None:
54
+ return None
55
+
56
+ # Return cached summary unless force-refresh
57
+ if row.get("summary") and not force:
58
+ return row["summary"]
59
+
60
+ # Check for tool-native rollup summary first (e.g. Codex pre-computed)
61
+ try:
62
+ adapter = get_adapter(row["tool"])
63
+ rollup = adapter.get_rollup_summary(session_id)
64
+ if rollup:
65
+ update_summary(db, session_id, rollup)
66
+ return rollup
67
+ except Exception:
68
+ pass
69
+
70
+ # Load messages for LLM path
71
+ try:
72
+ messages = adapter.load_messages(session_id)
73
+ except Exception:
74
+ messages = []
75
+
76
+ if not messages:
77
+ return None
78
+
79
+ # Build transcript
80
+ lines = []
81
+ for m in messages:
82
+ prefix = "User" if m.role == "user" else "Assistant"
83
+ lines.append(f"{prefix}: {m.content}")
84
+ transcript = "\n\n".join(lines)
85
+
86
+ # Respect input token budget
87
+ max_input = config.llm.max_input_tokens
88
+ prompt_overhead = count_tokens(_SUMMARY_PROMPT.format(transcript=""))
89
+ transcript = truncate_to_budget(transcript, max_input - prompt_overhead - 200)
90
+
91
+ api_key = config.llm.api_key or os.environ.get("ANTHROPIC_API_KEY")
92
+ if not api_key:
93
+ # Graceful degradation: return first user message as preview
94
+ for m in messages:
95
+ if m.role == "user":
96
+ preview = m.content[:200]
97
+ update_summary(db, session_id, preview)
98
+ return preview
99
+ return None
100
+
101
+ try:
102
+ import anthropic
103
+
104
+ client = anthropic.Anthropic(api_key=api_key)
105
+ prompt = _SUMMARY_PROMPT.format(transcript=transcript)
106
+ response = client.messages.create(
107
+ model=config.llm.model,
108
+ max_tokens=300,
109
+ messages=[{"role": "user", "content": prompt}],
110
+ )
111
+ summary = response.content[0].text.strip()
112
+ except Exception:
113
+ return None
114
+
115
+ update_summary(db, session_id, summary)
116
+ return summary
117
+
118
+
119
+ def batch_summarize(
120
+ db: sqlite_utils.Database,
121
+ config: ForgeConfig,
122
+ session_ids: list[str] | None = None,
123
+ force: bool = False,
124
+ on_progress: "Callable[[str, str | None], None] | None" = None,
125
+ ) -> BatchSummaryResult:
126
+ """Summarize multiple sessions.
127
+
128
+ If session_ids is None, summarizes all sessions without a cached summary
129
+ (or all sessions if force=True).
130
+
131
+ on_progress(session_id, summary_or_None) is called after each session.
132
+ """
133
+ result = BatchSummaryResult()
134
+
135
+ if session_ids is None:
136
+ rows = get_sessions(db, limit=10_000)
137
+ if not force:
138
+ rows = [r for r in rows if not r.get("summary")]
139
+ session_ids = [r["id"] for r in rows]
140
+
141
+ for sid in session_ids:
142
+ try:
143
+ summary = summarize_session(db, sid, config, force=force)
144
+ if summary:
145
+ result.summarized += 1
146
+ else:
147
+ result.skipped += 1
148
+ if on_progress:
149
+ on_progress(sid, summary)
150
+ except Exception as exc:
151
+ result.errors.append(f"{sid}: {exc}")
152
+ if on_progress:
153
+ on_progress(sid, None)
154
+
155
+ return result
@@ -0,0 +1,91 @@
1
+ """Per-turn token analysis for a session."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+
6
+ import sqlite_utils
7
+
8
+ from contextforge.adapters.registry import get_adapter
9
+ from contextforge.core.db import get_session
10
+ from contextforge.models.session import Message
11
+ from contextforge.utils.tokens import count_tokens
12
+
13
+
14
+ @dataclass
15
+ class TurnStats:
16
+ turn: int
17
+ role: str
18
+ tokens: int
19
+ content_preview: str # first 80 chars, single line
20
+ cumulative: int = 0
21
+
22
+
23
+ @dataclass
24
+ class SessionTokenReport:
25
+ session_id: str
26
+ tool: str
27
+ title: str
28
+ turns: list[TurnStats] = field(default_factory=list)
29
+
30
+ @property
31
+ def total(self) -> int:
32
+ return sum(t.tokens for t in self.turns)
33
+
34
+ @property
35
+ def user_total(self) -> int:
36
+ return sum(t.tokens for t in self.turns if t.role == "user")
37
+
38
+ @property
39
+ def assistant_total(self) -> int:
40
+ return sum(t.tokens for t in self.turns if t.role == "assistant")
41
+
42
+ @property
43
+ def turn_count(self) -> int:
44
+ return len(self.turns)
45
+
46
+ @property
47
+ def avg_user(self) -> float:
48
+ user = [t.tokens for t in self.turns if t.role == "user"]
49
+ return sum(user) / len(user) if user else 0.0
50
+
51
+ @property
52
+ def avg_assistant(self) -> float:
53
+ asst = [t.tokens for t in self.turns if t.role == "assistant"]
54
+ return sum(asst) / len(asst) if asst else 0.0
55
+
56
+ @property
57
+ def max_turn(self) -> TurnStats | None:
58
+ return max(self.turns, key=lambda t: t.tokens) if self.turns else None
59
+
60
+
61
+ def analyze_tokens(
62
+ db: sqlite_utils.Database,
63
+ session_id: str,
64
+ ) -> SessionTokenReport | None:
65
+ row = get_session(db, session_id)
66
+ if row is None:
67
+ return None
68
+
69
+ adapter = get_adapter(row["tool"])
70
+ messages = adapter.load_messages(session_id)
71
+
72
+ turns: list[TurnStats] = []
73
+ cumulative = 0
74
+ for i, msg in enumerate(messages, 1):
75
+ tokens = count_tokens(msg.content)
76
+ cumulative += tokens
77
+ preview = msg.content.replace("\n", " ").strip()[:80]
78
+ turns.append(TurnStats(
79
+ turn=i,
80
+ role=msg.role,
81
+ tokens=tokens,
82
+ content_preview=preview,
83
+ cumulative=cumulative,
84
+ ))
85
+
86
+ return SessionTokenReport(
87
+ session_id=session_id,
88
+ tool=row["tool"],
89
+ title=row.get("title") or session_id[:16],
90
+ turns=turns,
91
+ )
@@ -0,0 +1,4 @@
1
+ from .session import ContextBundle, Message, Session
2
+ from .config import ForgeConfig
3
+
4
+ __all__ = ["Session", "Message", "ContextBundle", "ForgeConfig"]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class LLMConfig(BaseModel):
9
+ api_key: str | None = None
10
+ model: str = "claude-haiku-4-5-20251001"
11
+ max_input_tokens: int = 60_000
12
+
13
+
14
+ class ScannerConfig(BaseModel):
15
+ max_sessions_per_tool: int = 200
16
+
17
+
18
+ class CompactorConfig(BaseModel):
19
+ default_strategy: str = "summary_only"
20
+ default_token_budget: int = 4096
21
+ file_injection_token_budget: int = 32_768
22
+
23
+
24
+ class ToolConfig(BaseModel):
25
+ enabled: bool = True
26
+ custom_path: str | None = None
27
+
28
+
29
+ class ForgeConfig(BaseModel):
30
+ db_path: Path = Field(default_factory=lambda: Path.home() / ".contextforge" / "contextforge.db")
31
+ llm: LLMConfig = Field(default_factory=LLMConfig)
32
+ scanner: ScannerConfig = Field(default_factory=ScannerConfig)
33
+ compactor: CompactorConfig = Field(default_factory=CompactorConfig)
34
+ tools: dict[str, ToolConfig] = Field(default_factory=dict)
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class Message(BaseModel):
10
+ role: Literal["user", "assistant", "tool", "system"]
11
+ content: str
12
+ timestamp: datetime | None = None
13
+ token_count: int | None = None
14
+
15
+
16
+ class Session(BaseModel):
17
+ id: str
18
+ tool: str
19
+ title: str | None = None
20
+ cwd: str | None = None
21
+ created_at: datetime
22
+ updated_at: datetime
23
+ messages: list[Message] = Field(default_factory=list)
24
+ token_count: int | None = None
25
+ raw_path: str | None = None
26
+ status: str = "unknown"
27
+ summary: str | None = None
28
+ tags: list[str] = Field(default_factory=list)
29
+
30
+
31
+ class ContextBundle(BaseModel):
32
+ name: str
33
+ source_sessions: list[str]
34
+ compacted_text: str
35
+ token_count: int
36
+ strategy: str
37
+ target_tool: str | None = None
File without changes
@@ -0,0 +1,204 @@
1
+ """ContextForge TUI — full Textual dashboard."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from textual.app import App, ComposeResult
7
+ from textual.binding import Binding
8
+ from textual.containers import Horizontal, Vertical
9
+ from textual.widgets import Footer, Header
10
+
11
+ from contextforge.tui.widgets.session_detail import SessionDetail
12
+ from contextforge.tui.widgets.session_table import SessionTable
13
+ from contextforge.tui.widgets.status_bar import StatusBar
14
+ from contextforge.tui.widgets.tokens_panel import TokensPanel
15
+ from contextforge.tui.widgets.transfer_panel import TransferPanel
16
+
17
+
18
+ class ContextForgeApp(App):
19
+ """ContextForge dashboard — manage agentic CLI sessions."""
20
+
21
+ TITLE = "ContextForge"
22
+ CSS_PATH = "styles.tcss"
23
+
24
+ BINDINGS = [
25
+ Binding("q", "quit", "Quit"),
26
+ Binding("r", "rescan", "Rescan"),
27
+ Binding("s", "summarize", "Summarize"),
28
+ Binding("t", "transfer", "Transfer"),
29
+ Binding("c", "compact", "Compact"),
30
+ Binding("x", "tokens", "Tokens"),
31
+ Binding("a", "analytics", "Analytics"),
32
+ Binding("/", "filter", "Filter"),
33
+ ]
34
+
35
+ def __init__(self, db_path: Path | None = None) -> None:
36
+ super().__init__()
37
+ from contextforge.models.config import ForgeConfig
38
+ cfg = ForgeConfig()
39
+ self.db_path: Path = db_path or cfg.db_path
40
+
41
+ # ── Layout ────────────────────────────────────────────────────────────────
42
+
43
+ def compose(self) -> ComposeResult:
44
+ yield Header()
45
+ with Horizontal(id="main-horizontal"):
46
+ yield SessionTable(id="session-table")
47
+ yield SessionDetail(id="session-detail")
48
+ yield StatusBar(id="status-bar")
49
+ yield Footer()
50
+
51
+ def on_mount(self) -> None:
52
+ self.query_one(StatusBar).refresh_stats()
53
+
54
+ # ── Session selection ──────────────────────────────────────────────────────
55
+
56
+ def on_session_table_row_selected(self, message: SessionTable.RowSelected) -> None:
57
+ self.query_one(SessionDetail).load(message.session_id)
58
+ self._current_session_id = message.session_id
59
+ self._current_tool = message.tool
60
+
61
+ def on_session_table_filter_changed(self, message: SessionTable.FilterChanged) -> None:
62
+ self.query_one(StatusBar).set_filter_indicator(
63
+ message.text, message.tool, message.match_count
64
+ )
65
+
66
+ # ── Actions ───────────────────────────────────────────────────────────────
67
+
68
+ def action_rescan(self) -> None:
69
+ """Re-scan all tools and refresh the table."""
70
+ from contextforge.core.db import get_db
71
+ from contextforge.core.scanner import scan
72
+
73
+ db = get_db(self.db_path)
74
+ result = scan(db, quiet=True)
75
+ self.query_one(SessionTable).reload()
76
+ self.query_one(StatusBar).refresh_stats()
77
+ self.notify(
78
+ f"Scan complete: {result.new} new, {result.updated} updated",
79
+ title="Rescan",
80
+ severity="information",
81
+ )
82
+
83
+ def action_summarize(self) -> None:
84
+ """Generate/refresh summary for the selected session."""
85
+ sid = getattr(self, "_current_session_id", None)
86
+ if sid is None:
87
+ self.notify("Select a session first.", severity="warning")
88
+ return
89
+
90
+ from contextforge.core.db import get_db
91
+ from contextforge.core.summarizer import summarize_session
92
+ from contextforge.models.config import ForgeConfig
93
+
94
+ db = get_db(self.db_path)
95
+ summary = summarize_session(db, sid, ForgeConfig())
96
+ if summary:
97
+ self.query_one(SessionDetail)._current_id = None # bust cache
98
+ self.query_one(SessionDetail).load(sid)
99
+ self.notify("Summary generated.", title="Summarize")
100
+ else:
101
+ self.notify(
102
+ "Could not generate summary (no messages or no API key).",
103
+ severity="warning",
104
+ )
105
+
106
+ def action_transfer(self) -> None:
107
+ """Open the transfer panel for the selected session."""
108
+ sid = getattr(self, "_current_session_id", None)
109
+ if sid is None:
110
+ self.notify("Select a session first.", severity="warning")
111
+ return
112
+
113
+ from contextforge.core.db import get_db, get_session
114
+ db = get_db(self.db_path)
115
+ row = get_session(db, sid)
116
+ title = (row.get("title") or sid[:16]) if row else sid[:16]
117
+
118
+ def on_dismiss(result) -> None:
119
+ if result is None:
120
+ return
121
+ self._run_transfer(
122
+ session_id=sid,
123
+ target_tool=result["tool"],
124
+ strategy=result["strategy"],
125
+ execute=result["execute"],
126
+ )
127
+
128
+ self.push_screen(TransferPanel(sid, title), callback=on_dismiss)
129
+
130
+ def _run_transfer(
131
+ self,
132
+ session_id: str,
133
+ target_tool: str,
134
+ strategy: str,
135
+ execute: bool,
136
+ ) -> None:
137
+ from contextforge.core.compactor import compact
138
+ from contextforge.core.db import get_db, save_bundle
139
+ from contextforge.core.injector import build_inject_command, execute_transfer
140
+
141
+ db = get_db(self.db_path)
142
+ bundle = compact(
143
+ db=db,
144
+ session_ids=[session_id],
145
+ strategy=strategy,
146
+ token_budget=4096,
147
+ target_tool=target_tool,
148
+ )
149
+ cmd, method = build_inject_command(bundle, target_tool)
150
+
151
+ if execute:
152
+ bundle_id = save_bundle(db, bundle)
153
+ execute_transfer(
154
+ db=db,
155
+ bundle=bundle,
156
+ bundle_id=bundle_id,
157
+ target_tool=target_tool,
158
+ )
159
+ self.notify(
160
+ f"Launched {target_tool} with {bundle.token_count} tokens of context.",
161
+ title="Transfer executed",
162
+ )
163
+ else:
164
+ self.notify(
165
+ f"[cyan]{cmd}[/cyan]",
166
+ title=f"Preview → {target_tool} ({method})",
167
+ timeout=12,
168
+ )
169
+
170
+ def action_compact(self) -> None:
171
+ """Compact the selected session and show the bundle."""
172
+ sid = getattr(self, "_current_session_id", None)
173
+ if sid is None:
174
+ self.notify("Select a session first.", severity="warning")
175
+ return
176
+
177
+ from contextforge.core.compactor import compact
178
+ from contextforge.core.db import get_db
179
+
180
+ db = get_db(self.db_path)
181
+ bundle = compact(db=db, session_ids=[sid], strategy="summary_only", token_budget=4096)
182
+ self.notify(
183
+ f"Bundle: {bundle.token_count} tokens ({bundle.strategy})\n"
184
+ f"{bundle.compacted_text[:200]}…",
185
+ title="Compact",
186
+ timeout=10,
187
+ )
188
+
189
+ def action_tokens(self) -> None:
190
+ """Open the token analysis modal for the selected session."""
191
+ sid = getattr(self, "_current_session_id", None)
192
+ if sid is None:
193
+ self.notify("Select a session first.", severity="warning")
194
+ return
195
+ self.push_screen(TokensPanel(session_id=sid, db_path=self.db_path))
196
+
197
+ def action_analytics(self) -> None:
198
+ """Open the analytics dashboard modal."""
199
+ from contextforge.tui.widgets.stats_panel import StatsPanel
200
+ self.push_screen(StatsPanel())
201
+
202
+ def action_filter(self) -> None:
203
+ """Toggle the filter bar in the session table."""
204
+ self.query_one(SessionTable).toggle_filter()
@@ -0,0 +1,50 @@
1
+ /* ContextForge TUI Styles */
2
+
3
+ /* ── Layout ─────────────────────────────────────────────── */
4
+
5
+ Screen {
6
+ layout: vertical;
7
+ }
8
+
9
+ #main-horizontal {
10
+ height: 1fr;
11
+ layout: horizontal;
12
+ }
13
+
14
+ /* ── Transfer modal ─────────────────────────────────────── */
15
+
16
+ TransferPanel {
17
+ align: center middle;
18
+ }
19
+
20
+ TransferPanel > Vertical {
21
+ width: 64;
22
+ height: auto;
23
+ max-height: 38;
24
+ border: thick $primary;
25
+ background: $surface;
26
+ padding: 1 2;
27
+ }
28
+
29
+ #panel-title {
30
+ text-style: bold;
31
+ color: $accent;
32
+ margin-bottom: 1;
33
+ }
34
+
35
+ .section-label {
36
+ text-style: bold;
37
+ margin-top: 1;
38
+ color: $text-muted;
39
+ }
40
+
41
+ #btn-row {
42
+ margin-top: 2;
43
+ height: auto;
44
+ layout: horizontal;
45
+ align: right middle;
46
+ }
47
+
48
+ #btn-row Button {
49
+ margin-left: 1;
50
+ }
File without changes