"""The pieces the window is built from: messages, tool cards, the composer,
the header controls and the empty state. Styling lives in theme.stylesheet();
these set object names and properties for it to hook onto."""
from __future__ import annotations
import html
import math
import re
from PySide6.QtCore import QEvent, QRectF, QSize, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QFontDatabase, QPainter, QTextCursor, QTextDocument, QTextOption
from PySide6.QtWidgets import (
QButtonGroup, QFrame, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton,
QSizePolicy, QVBoxLayout, QWidget,
)
from . import icons, theme
# Tool name (as tools.py reports it) -> icon.
TOOL_ICONS = {
"Shell": "shell", "Read": "read", "List": "list", "Write": "write",
"Edit": "edit", "Pet": "blocked", "File": "file",
}
COLLAPSED_LINES = 6
def _label(text: str = "", name: str = "", wrap: bool = True, selectable: bool = False) -> QLabel:
label = QLabel(text)
if name:
label.setObjectName(name)
label.setWordWrap(wrap)
if selectable:
label.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse)
label.setCursor(Qt.IBeamCursor)
return label
def repolish(widget: QWidget) -> None:
"""Re-apply the stylesheet after a dynamic property changed."""
widget.style().unpolish(widget)
widget.style().polish(widget)
widget.update()
def markdown_html(text: str, font) -> str:
"""Markdown -> the HTML QLabel renders, with code in the app's mono font
and a warm tint instead of Qt's generic 'monospace' (usually Courier)."""
doc = QTextDocument()
doc.setDefaultFont(font)
doc.setMarkdown(text)
out = doc.toHtml()
code_style = f"font-family:'{theme.mono_font_family()}'; color:{theme.CODE};"
# Qt writes the platform's fixed font for code: 'Courier New' on Windows,
# 'monospace' elsewhere.
for fixed in {QFontDatabase.systemFont(QFontDatabase.FixedFont).family(), "monospace", "Courier New"}:
out = out.replace(f"font-family:'{fixed}';", code_style)
return out.replace("font-family:'Sans Serif';", "")
# ── transcript entries ──────────────────────────────────────────────────────
class UserMessage(QWidget):
def __init__(self, text: str, parent=None):
super().__init__(parent)
row = QHBoxLayout(self)
row.setContentsMargins(80, 6, 0, 6)
row.addStretch(1)
bubble = QFrame()
bubble.setObjectName("UserBubble")
inner = QVBoxLayout(bubble)
inner.setContentsMargins(16, 10, 16, 10)
label = _label(text, selectable=True)
label.setTextFormat(Qt.PlainText)
# A word-wrapped QLabel's size hint is a guess that wraps short text
# early; give it the width its longest line actually needs.
widest = max(label.fontMetrics().horizontalAdvance(line) for line in text.splitlines() or [""])
label.setMinimumWidth(min(widest + 4, 588))
inner.addWidget(label)
bubble.setMaximumWidth(620)
bubble.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
row.addWidget(bubble)
class AssistantMessage(QWidget):
"""Bolt's reply. Streamed sentences are appended; a sentence that starts a
list item or heading goes on its own line so the markdown still renders."""
_BLOCK_START = re.compile(r"^(?:[-*+] |\d+[.)] |#{1,6} |```|>)")
def __init__(self, text: str = "", parent=None):
super().__init__(parent)
self._text = ""
row = QHBoxLayout(self)
row.setContentsMargins(0, 8, 0, 8)
row.setSpacing(14)
avatar = icons.IconLabel("bolt", theme.ACCENT_INK, 26, tile=theme.ACCENT)
row.addWidget(avatar, 0, Qt.AlignTop)
self.label = _label(name="AssistantText", selectable=True)
self.label.setTextFormat(Qt.RichText)
self.label.setOpenExternalLinks(True)
self.label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
row.addWidget(self.label, 1)
if text:
self.set_text(text)
def set_text(self, text: str) -> None:
self._text = text
self.label.setText(markdown_html(text, self.label.font()))
def append(self, sentence: str) -> None:
if not self._text:
joiner = ""
elif self._BLOCK_START.match(sentence) or self._text.endswith("\n"):
joiner = "\n\n" if not self._text.endswith("\n") else ""
else:
joiner = " "
self.set_text(self._text + joiner + sentence)
def text(self) -> str:
return self._text
class HoldingLine(QWidget):
def __init__(self, text: str, parent=None):
super().__init__(parent)
row = QHBoxLayout(self)
row.setContentsMargins(40, 0, 0, 2)
row.addWidget(_label(text, "Holding"))
class Notice(QWidget):
def __init__(self, level: str, text: str, parent=None):
super().__init__(parent)
row = QHBoxLayout(self)
row.setContentsMargins(40, 4, 0, 4)
label = _label(text, "NoticeError" if level == "error" else "Notice", selectable=True)
label.setTextFormat(Qt.PlainText)
if level == "warn":
label.setStyleSheet(f"color: {theme.ACCENT};")
row.addWidget(label, 1)
class Pill(QLabel):
TEXT = {"running": "Running", "ok": "Done", "error": "Failed",
"waiting": "Needs approval", "declined": "Declined"}
def __init__(self, kind: str = "running", parent=None):
super().__init__(parent)
self.setObjectName("Pill")
self.set_kind(kind)
def set_kind(self, kind: str) -> None:
self.setProperty("kind", kind)
self.setText(self.TEXT.get(kind, kind))
repolish(self)
class _ClickableFrame(QFrame):
clicked = Signal()
def mouseReleaseEvent(self, event) -> None:
if event.button() == Qt.LeftButton and self.rect().contains(event.position().toPoint()):
self.clicked.emit()
super().mouseReleaseEvent(event)
class ToolCard(QFrame):
"""One relayed command: header (icon, name, argument, status), optional
diff, output that expands on click, and an inline approval bar."""
answered = Signal(str) # yes / no / always
def __init__(self, name: str, argument: str, parent=None):
super().__init__(parent)
self.setObjectName("ToolCard")
self._output = ""
self._expanded = False
self._error = False
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
self.header = _ClickableFrame()
self.header.setObjectName("ToolHeader")
self.header.setCursor(Qt.PointingHandCursor)
head = QHBoxLayout(self.header)
head.setContentsMargins(12, 9, 12, 9)
head.setSpacing(10)
self.icon = icons.IconLabel(TOOL_ICONS.get(name, "shell"), theme.TEXT_DIM, 16)
head.addWidget(self.icon)
title = _label(name, "ToolName", wrap=False)
head.addWidget(title)
shown = argument if len(argument) <= 110 else argument[:107] + "..."
arg = _label(shown, "ToolArg", wrap=False)
arg.setTextFormat(Qt.PlainText)
arg.setToolTip(argument)
arg.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
head.addWidget(arg, 1)
self.pill = Pill("running")
head.addWidget(self.pill)
self.chevron = icons.IconLabel("chevron", theme.TEXT_FAINT, 14)
self.chevron.hide()
head.addWidget(self.chevron)
self.header.clicked.connect(self.toggle)
outer.addWidget(self.header)
self.diff = _label(name="ToolOutput", wrap=True, selectable=True)
self.diff.setTextFormat(Qt.RichText)
self.diff.hide()
outer.addWidget(self.diff)
self.output = _label(name="ToolOutput", wrap=True, selectable=True)
self.output.setTextFormat(Qt.PlainText)
self.output.hide()
outer.addWidget(self.output)
self.approval = QFrame()
self.approval.setObjectName("ApprovalBar")
bar = QHBoxLayout(self.approval)
bar.setContentsMargins(14, 10, 10, 10)
bar.setSpacing(8)
self.question = _label("", "ApprovalQuestion", wrap=False)
bar.addWidget(self.question, 1)
for text, value, style in (("Deny", "no", "Ghost"), ("Always allow", "always", "Secondary"),
("Allow", "yes", "Primary")):
button = QPushButton(text)
button.setObjectName(style)
button.setCursor(Qt.PointingHandCursor)
button.clicked.connect(lambda _=False, v=value: self._answer(v))
bar.addWidget(button)
if value == "yes":
self.allow_button = button
self.approval.hide()
outer.addWidget(self.approval)
# ── state ────────────────────────────────────────────────────────────
def set_result(self, text: str, error: bool) -> None:
if self.pill.property("kind") == "declined":
return
self._output, self._error = text.rstrip(), error
self.pill.set_kind("error" if error else "ok")
self.icon.set_icon(self.icon._name, theme.RED if error else theme.TEXT_DIM)
self._render_output()
def set_diff(self, old: str, new: str, limit: int = 40) -> None:
rows = []
for sign, text, fg, bg in (("-", old, theme.RED, theme.DIFF_DEL_BG),
("+", new, theme.GREEN, theme.DIFF_ADD_BG)):
lines = text.splitlines() or [""]
for line in lines[:limit]:
rows.append(
f'
| {sign} {html.escape(line) or " "} |
')
if len(lines) > limit:
rows.append(f'| '
f' … {len(lines) - limit} more lines |
')
self.diff.setText('")
self.diff.show()
def ask(self, question: str) -> None:
self.question.setText(question)
self.pill.set_kind("waiting")
self.setProperty("state", "waiting")
repolish(self)
self.approval.show()
self.allow_button.setFocus()
def _answer(self, value: str) -> None:
self.approval.hide()
self.setProperty("state", "")
repolish(self)
if value == "no":
self.pill.set_kind("declined")
else:
self.pill.set_kind("running")
self.answered.emit(value)
def toggle(self) -> None:
if self._output:
self._expanded = not self._expanded
self._render_output()
def _render_output(self) -> None:
lines = align_columns(self._output.splitlines())
if not lines:
self.output.hide()
self.chevron.hide()
return
long = len(lines) > COLLAPSED_LINES
shown = lines if (self._expanded or not long) else lines[:COLLAPSED_LINES]
text = "\n".join(shown)
if long and not self._expanded:
text += f"\n… {len(lines) - COLLAPSED_LINES} more lines — click to expand"
self.output.setText(text)
self.output.setStyleSheet(f"color: {theme.RED};" if self._error else "")
self.output.show()
self.chevron.setVisible(long)
def align_columns(lines):
"""Tab-separated output (filectl list's "namesize") into padded
columns — a QLabel's tab stops don't line up with anything."""
rows = [line.split(" ") for line in lines]
if not any(len(r) > 1 for r in rows):
return lines
width = max(len(r[0]) for r in rows if len(r) > 1)
return [r[0].ljust(width + 2) + " ".join(r[1:]) if len(r) > 1 else r[0] for r in rows]
class ThinkingIndicator(QWidget):
"""Three dots breathing in sequence, next to a quiet 'Thinking'."""
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedHeight(34)
self._phase = 0.0
self._timer = QTimer(self)
self._timer.setInterval(40)
self._timer.timeout.connect(self._tick)
self.label = "Thinking"
def start(self) -> None:
self._phase = 0.0
self.show()
self._timer.start()
def stop(self) -> None:
self._timer.stop()
self.hide()
def _tick(self) -> None:
self._phase += 0.12
self.update()
def paintEvent(self, _event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
x, y = 50.0, self.height() / 2
for i in range(3):
level = (math.sin(self._phase - i * 0.9) + 1) / 2
colour = QColor(theme.ACCENT)
colour.setAlphaF(0.25 + 0.75 * level)
p.setBrush(colour)
r = 2.6 + 1.0 * level
p.drawEllipse(QRectF(x + i * 12 - r, y - r, 2 * r, 2 * r))
p.setPen(QColor(theme.TEXT_FAINT))
p.drawText(QRectF(x + 34, 0, 300, self.height()), Qt.AlignVCenter | Qt.AlignLeft, self.label)
# ── composer ────────────────────────────────────────────────────────────────
class _SendButton(QPushButton):
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(34, 34)
self.setCursor(Qt.PointingHandCursor)
self.setToolTip("Send (Enter)")
self.setFlat(True)
def paintEvent(self, _event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
enabled = self.isEnabled()
fill = QColor(theme.ACCENT_HI if self.underMouse() and enabled else theme.ACCENT)
if not enabled:
fill = QColor(theme.BORDER_HI)
p.setBrush(fill)
p.drawEllipse(QRectF(self.rect()).adjusted(1, 1, -1, -1))
icons.paint(p, "send", QRectF(self.rect()).adjusted(8, 8, -8, -8),
theme.ACCENT_INK if enabled else theme.TEXT_FAINT)
class MicButton(QPushButton):
"""Dictation toggle. idle: a quiet mic outline. recording: red, with a
ring that swells with your voice, and a stop square. finishing: an amber
arc while the last words come back. unavailable: faint, and the tooltip
says what's missing."""
TIPS = {
"idle": "Dictate (Ctrl+M)\nClick, talk, click again (or pause) to stop",
"recording": "Listening. Click or pause to stop, Esc to cancel",
"finishing": "Transcribing...",
}
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(34, 34)
self.setCursor(Qt.PointingHandCursor)
self.setFlat(True)
self.state = "idle"
self._level = 0.0
self._phase = 0.0
self._timer = QTimer(self)
self._timer.setInterval(33)
self._timer.timeout.connect(self._tick)
self.set_state("idle")
def set_state(self, state: str, reason: str = "") -> None:
self.state = state
self.setToolTip(reason if state == "unavailable" else self.TIPS.get(state, ""))
if state in ("recording", "finishing"):
self._timer.start()
else:
self._timer.stop()
self._level = 0.0
self.update()
def set_level(self, level: float) -> None:
# Fast attack, slow release, so the ring breathes instead of flickering.
self._level = level if level > self._level else self._level * 0.85 + level * 0.15
def _tick(self) -> None:
self._phase += 0.2
self._level *= 0.97
self.update()
def paintEvent(self, _event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
rect = QRectF(self.rect()).adjusted(1, 1, -1, -1)
centre = rect.center()
if self.state == "recording":
ring = QColor(theme.RED)
ring.setAlphaF(0.22 + 0.3 * self._level)
p.setPen(Qt.NoPen)
p.setBrush(ring)
p.drawEllipse(centre, 16.0, 16.0)
p.setBrush(QColor(theme.RED))
radius = 10.5 + 3.5 * self._level
p.drawEllipse(centre, radius, radius)
icons.paint(p, "stop", QRectF(centre.x() - 8, centre.y() - 8, 16, 16), "#ffffff")
return
if self.state == "finishing":
pen = QColor(theme.ACCENT)
p.setPen(icons._pen(pen, 2.2))
p.drawArc(rect.adjusted(4, 4, -4, -4), int(-self._phase * 16 * 57), 100 * 16)
icons.paint(p, "mic", rect.adjusted(9, 9, -9, -9), theme.TEXT_DIM)
return
if self.state == "idle" and self.underMouse():
p.setPen(Qt.NoPen)
p.setBrush(QColor(theme.SURFACE_HI))
p.drawEllipse(rect)
colour = theme.TEXT_FAINT if self.state == "unavailable" else (
theme.TEXT if self.underMouse() else theme.TEXT_DIM)
icons.paint(p, "mic", rect.adjusted(8, 8, -8, -8), colour)
class _ComposerEdit(QPlainTextEdit):
submitted = Signal()
def keyPressEvent(self, event) -> None:
if event.key() in (Qt.Key_Return, Qt.Key_Enter) and not (event.modifiers() & Qt.ShiftModifier):
self.submitted.emit()
return
super().keyPressEvent(event)
class Composer(QFrame):
"""Rounded input that grows with its text. Enter sends, Shift+Enter
starts a new line. While a turn runs you can keep typing; sending waits."""
submitted = Signal(str)
MAX_LINES = 9
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("Composer")
row = QHBoxLayout(self)
row.setContentsMargins(18, 10, 10, 10)
row.setSpacing(10)
self.edit = _ComposerEdit()
self.edit.setObjectName("ComposerEdit")
self.edit.setPlaceholderText("Ask Bolt to read, change or run something…")
self.edit.setWordWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere)
self.edit.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.edit.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.edit.document().setDocumentMargin(2)
self.edit.textChanged.connect(self._resize)
self.edit.submitted.connect(self._submit)
self.edit.installEventFilter(self)
row.addWidget(self.edit, 1, Qt.AlignVCenter)
self.mic = MicButton()
row.addWidget(self.mic, 0, Qt.AlignBottom)
self._dictation_prefix = None # composer text from before a recording
self.send = _SendButton()
self.send.clicked.connect(self._submit)
row.addWidget(self.send, 0, Qt.AlignBottom)
self._busy = False
self._resize()
self._sync_button()
self.edit.textChanged.connect(self._sync_button)
def eventFilter(self, obj, event) -> bool:
if obj is self.edit and event.type() in (QEvent.FocusIn, QEvent.FocusOut):
self.setProperty("focused", event.type() == QEvent.FocusIn)
repolish(self)
return False
def set_busy(self, busy: bool) -> None:
self._busy = busy
self._sync_button()
def set_text(self, text: str) -> None:
self.edit.setPlainText(text)
cursor = self.edit.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
self.edit.setTextCursor(cursor)
self.edit.setFocus()
# ── dictation: the words appear in the box as you speak ──────────────
def begin_dictation(self) -> None:
self._dictation_prefix = self.edit.toPlainText().rstrip()
self.edit.setReadOnly(True) # typing into text that's being rewritten would be lost
self._sync_button()
def show_dictation(self, final: str, interim: str = "") -> None:
if self._dictation_prefix is None:
return
spoken = " ".join(part for part in (final, interim) if part)
self._show(" ".join(part for part in (self._dictation_prefix, spoken) if part))
def end_dictation(self, text: str) -> None:
if self._dictation_prefix is None:
return
self._show(" ".join(part for part in (self._dictation_prefix, text.strip()) if part))
self._dictation_prefix = None
self.edit.setReadOnly(False)
self.edit.setFocus()
self._sync_button()
@property
def dictating(self) -> bool:
return self._dictation_prefix is not None
def _show(self, text: str) -> None:
self.edit.setPlainText(text)
cursor = self.edit.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
self.edit.setTextCursor(cursor)
def _sync_button(self) -> None:
ready = not self._busy and not self.dictating and bool(self.edit.toPlainText().strip())
self.send.setEnabled(ready)
self.send.update()
def _resize(self) -> None:
line = self.edit.fontMetrics().lineSpacing()
lines = max(1, min(self.MAX_LINES, int(self.edit.document().size().height())))
self.edit.setFixedHeight(int(lines * line + 8))
def _submit(self) -> None:
text = self.edit.toPlainText().strip()
if not text or self._busy or self.dictating:
return
self.edit.clear()
self.submitted.emit(text)
# ── header ──────────────────────────────────────────────────────────────────
class StatusDot(QWidget):
COLOURS = {"unknown": theme.TEXT_FAINT, "ok": theme.GREEN, "error": theme.RED}
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(QSize(10, 10))
self._state = "unknown"
def set_state(self, state: str, tooltip: str) -> None:
self._state = state
self.setToolTip(tooltip)
self.update()
def paintEvent(self, _event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
p.setBrush(QColor(self.COLOURS.get(self._state, theme.TEXT_FAINT)))
p.drawEllipse(QRectF(1, 1, 8, 8))
class FolderChip(QPushButton):
"""A pill button with a painted icon on the left (the folder by default)."""
def __init__(self, parent=None, icon_name: str = "folder", text: str = ""):
super().__init__(text, parent)
self._icon_name = icon_name
self.setObjectName("Chip")
self.setCursor(Qt.PointingHandCursor)
self.setFixedHeight(30)
def set_path(self, path: str) -> None:
name = path.rstrip("\\/").replace("\\", "/").split("/")[-1] or path
self.setText(name)
self.setToolTip(f"Working directory: {path}\nClick to change")
def paintEvent(self, event) -> None:
super().paintEvent(event)
p = QPainter(self)
paint_colour = theme.TEXT if self.underMouse() else theme.TEXT_DIM
if not self.isEnabled():
paint_colour = theme.TEXT_FAINT
icons.paint(p, self._icon_name, QRectF(10, (self.height() - 15) / 2, 15, 15), paint_colour)
class ModeSwitch(QFrame):
"""Segmented control: ask before changes, or run everything."""
changed = Signal(str)
def __init__(self, mode: str, parent=None):
super().__init__(parent)
self.setObjectName("Segment")
self.setFixedHeight(30)
row = QHBoxLayout(self)
row.setContentsMargins(2, 2, 2, 2)
row.setSpacing(2)
self._group = QButtonGroup(self)
self.buttons = {}
for value, text, tip in (
("ask", "Ask first", "Confirm shell commands, writes and edits"),
("auto", "Auto", "Run everything Bolt asks for without confirming"),
):
button = QPushButton(text)
button.setObjectName("SegmentButton")
button.setCheckable(True)
button.setCursor(Qt.PointingHandCursor)
button.setToolTip(tip)
if value == "auto":
button.setProperty("danger", True)
self._group.addButton(button)
row.addWidget(button)
self.buttons[value] = button
button.clicked.connect(lambda _=False, v=value: self.changed.emit(v))
self.set_mode(mode)
def set_mode(self, mode: str) -> None:
self.buttons.get(mode, self.buttons["ask"]).setChecked(True)
# ── empty state ─────────────────────────────────────────────────────────────
SUGGESTIONS = (
"What's in this folder?",
"Summarise the README",
"Git status and recent commits",
"Find TODO comments in this project",
)
class EmptyState(QWidget):
picked = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
col = QVBoxLayout(self)
col.setContentsMargins(0, 0, 0, 0)
col.addStretch(3)
col.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 52, tile=theme.ACCENT), 0, Qt.AlignHCenter)
col.addSpacing(18)
hero = _label("What are we working on?", "Hero")
hero.setAlignment(Qt.AlignHCenter)
col.addWidget(hero)
self.sub = _label("", "HeroSub")
self.sub.setAlignment(Qt.AlignHCenter)
col.addWidget(self.sub)
col.addSpacing(26)
grid = QVBoxLayout()
grid.setSpacing(10)
for i in range(0, len(SUGGESTIONS), 2):
pair = QHBoxLayout()
pair.setSpacing(10)
for text in SUGGESTIONS[i:i + 2]:
button = QPushButton(text)
button.setObjectName("Suggestion")
button.setCursor(Qt.PointingHandCursor)
button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
button.clicked.connect(lambda _=False, t=text: self.picked.emit(t))
pair.addWidget(button)
grid.addLayout(pair)
wrap = QHBoxLayout()
wrap.addStretch(1)
holder = QWidget()
holder.setLayout(grid)
holder.setMaximumWidth(560)
holder.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
wrap.addWidget(holder, 4)
wrap.addStretch(1)
col.addLayout(wrap)
col.addStretch(4)
def set_folder(self, path: str) -> None:
self.sub.setText(f"Bolt can read, edit and run things in {path}")