417 lines
15 KiB
QML
417 lines
15 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import Quickshell
|
|
import Quickshell.Wayland
|
|
import "root:/config"
|
|
import "root:/components"
|
|
import "root:/popouts"
|
|
import "root:/services"
|
|
|
|
// The ask panel. It comes down off the top edge, takes the keyboard, and hands
|
|
// a question to Claude.
|
|
//
|
|
// Deliberately not the launcher: the launcher is a list you pick from in one
|
|
// keystroke, this is a conversation you read. The shape says so — full width of
|
|
// the question, answer below it, and the thing drops from the top rather than
|
|
// rising into the middle so the two are never mistaken for each other mid-type.
|
|
PanelWindow {
|
|
id: root
|
|
|
|
required property var modelData
|
|
screen: modelData
|
|
|
|
// One screen at a time, latched at open — same reason as the launcher: the
|
|
// window takes focus, and a prompt should not hop monitors mid-question.
|
|
readonly property bool open: Actions.askOpen
|
|
&& Actions.askScreen === modelData.name
|
|
visible: root.open || closeAnim.running
|
|
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.namespace: "takemi-ask"
|
|
WlrLayershell.keyboardFocus: root.open
|
|
? WlrKeyboardFocus.Exclusive
|
|
: WlrKeyboardFocus.None
|
|
|
|
anchors { top: true; bottom: true; left: true; right: true }
|
|
color: "transparent"
|
|
exclusionMode: ExclusionMode.Ignore
|
|
|
|
readonly property int panelWidth: Math.min(860, root.width - Theme.padXL * 2)
|
|
readonly property int maxTranscript: Math.round(root.height * 0.55)
|
|
|
|
onOpenChanged: {
|
|
if (root.open) {
|
|
PopupCoordinator.dismissAll();
|
|
input.text = Actions.askSeed;
|
|
openAnim.restart();
|
|
input.forceActiveFocus();
|
|
input.cursorPosition = input.text.length;
|
|
if (Actions.askSeed !== "") root.send();
|
|
} else {
|
|
closeAnim.restart();
|
|
}
|
|
}
|
|
|
|
function send(): void {
|
|
const q = input.text.trim();
|
|
if (q === "" || Ask.busy) return;
|
|
Ask.ask(q);
|
|
input.text = "";
|
|
}
|
|
|
|
function copyLast(): void {
|
|
for (let i = Ask.turns.length - 1; i >= 0; i--) {
|
|
if (Ask.turns[i].role === "assistant" && Ask.turns[i].text !== "") {
|
|
Actions.copyText(Ask.turns[i].text);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------- backdrop
|
|
Rectangle {
|
|
id: backdrop
|
|
anchors.fill: parent
|
|
color: Theme.alpha(Theme.base, 0.6)
|
|
opacity: 0
|
|
|
|
TapHandler { onTapped: Actions.closeAsk() }
|
|
}
|
|
|
|
// ----------------------------------------------------------------- panel
|
|
Item {
|
|
id: shell
|
|
|
|
width: root.panelWidth
|
|
height: header.height + divider.height + body.height + hint.height
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
y: 0
|
|
|
|
opacity: 0
|
|
// The drop. The offset is animated, never bound: binding it to the
|
|
// panel's own height means every height change — every streamed line —
|
|
// snaps the panel back off the top of the screen.
|
|
transform: Translate { id: drop; y: 0 }
|
|
|
|
PopoutSurface {
|
|
anchors.fill: parent
|
|
tone: Ask.busy ? Theme.accent : Theme.primary
|
|
padding: 0
|
|
slashWidth: Ask.busy ? 8 : 5
|
|
|
|
// -------------------------------------------------------- header
|
|
Item {
|
|
id: header
|
|
width: parent.width
|
|
height: 62
|
|
anchors.top: parent.top
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: Theme.padL
|
|
anchors.rightMargin: Theme.padL
|
|
spacing: Theme.padM
|
|
|
|
// Pulses rather than spins. Native text rendering is
|
|
// subpixel-antialiased, so a rotated glyph smears into
|
|
// colour fringes; scale keeps it on the pixel grid.
|
|
Icon {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: ""
|
|
color: Ask.busy ? Theme.accent : Theme.primary
|
|
font.pixelSize: Theme.fsTitle
|
|
pulsing: Ask.busy
|
|
pulseScale: 1.12
|
|
}
|
|
|
|
TextInput {
|
|
id: input
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width - x - modelChip.width - Theme.padM
|
|
color: Theme.text
|
|
font.family: Theme.fontMono
|
|
font.pixelSize: Theme.fsLarge
|
|
selectionColor: Theme.accent
|
|
selectedTextColor: Theme.ink
|
|
clip: true
|
|
focus: true
|
|
renderType: Text.NativeRendering
|
|
|
|
Keys.priority: Keys.BeforeItem
|
|
Keys.onPressed: event => root.handleKey(event)
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: input.text === ""
|
|
text: Ask.busy ? "thinking…"
|
|
: Ask.turns.length > 0 ? "follow up…" : "ask anything"
|
|
color: Theme.muted
|
|
font: input.font
|
|
renderType: Text.NativeRendering
|
|
}
|
|
}
|
|
|
|
// The model, which is also the control for it: Ctrl+M walks
|
|
// the ring and writes the choice back to ask.json, so the
|
|
// panel and the file never disagree.
|
|
Item {
|
|
id: modelChip
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: modelText.width + Theme.padM * 2
|
|
height: 24
|
|
|
|
Skew {
|
|
anchors.fill: parent
|
|
color: Theme.surface
|
|
}
|
|
|
|
Text {
|
|
id: modelText
|
|
anchors.centerIn: parent
|
|
text: Ask.model.toUpperCase()
|
|
color: Theme.accent
|
|
font.family: Theme.fontMono
|
|
font.pixelSize: Theme.fsMicro
|
|
font.weight: Font.Bold
|
|
font.letterSpacing: 2
|
|
renderType: Text.NativeRendering
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: Ask.cycleModel()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Skew {
|
|
id: divider
|
|
anchors.top: header.bottom
|
|
width: parent.width
|
|
height: Theme.stroke
|
|
color: Theme.alpha(Theme.outline, 0.8)
|
|
lean: 0
|
|
}
|
|
|
|
// --------------------------------------------------- transcript
|
|
Item {
|
|
id: body
|
|
anchors.top: divider.bottom
|
|
width: parent.width
|
|
height: Math.min(transcript.contentHeight + Theme.padM * 2,
|
|
root.maxTranscript)
|
|
visible: Ask.turns.length > 0 || Ask.error !== ""
|
|
clip: true
|
|
|
|
Behavior on height {
|
|
NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutExpo }
|
|
}
|
|
|
|
ListView {
|
|
id: transcript
|
|
anchors.fill: parent
|
|
anchors.margins: Theme.padM
|
|
anchors.leftMargin: Theme.padL
|
|
anchors.rightMargin: Theme.padL
|
|
model: Ask.turns
|
|
spacing: Theme.padM
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
// Stay at the bottom while an answer streams in, but leave
|
|
// the view alone the moment it is scrolled by hand.
|
|
property bool pinned: true
|
|
onMovementEnded: pinned = atYEnd
|
|
onContentHeightChanged: if (pinned) positionViewAtEnd()
|
|
|
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
|
|
delegate: Column {
|
|
required property var modelData
|
|
width: transcript.width
|
|
spacing: Theme.padS
|
|
|
|
Text {
|
|
visible: modelData.role === "user"
|
|
text: "YOU"
|
|
color: Theme.muted
|
|
font.family: Theme.fontMono
|
|
font.pixelSize: Theme.fsMicro
|
|
font.letterSpacing: 3
|
|
renderType: Text.NativeRendering
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: modelData.text
|
|
textFormat: modelData.role === "assistant"
|
|
? Text.MarkdownText : Text.PlainText
|
|
color: modelData.role === "user" ? Theme.subtext : Theme.text
|
|
linkColor: Theme.accent
|
|
font.family: Theme.fontMono
|
|
font.pixelSize: Theme.fsBody
|
|
wrapMode: Text.Wrap
|
|
renderType: Text.NativeRendering
|
|
onLinkActivated: link => Actions.run(["xdg-open", link])
|
|
}
|
|
}
|
|
|
|
footer: Text {
|
|
width: transcript.width
|
|
visible: Ask.error !== ""
|
|
topPadding: visible ? Theme.padM : 0
|
|
text: Ask.error
|
|
color: Theme.alarm
|
|
font.family: Theme.fontMono
|
|
font.pixelSize: Theme.fsSmall
|
|
wrapMode: Text.Wrap
|
|
renderType: Text.NativeRendering
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------- hints
|
|
Item {
|
|
id: hint
|
|
anchors.top: body.visible ? body.bottom : divider.bottom
|
|
width: parent.width
|
|
height: 28
|
|
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: Theme.padL
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: Theme.muted
|
|
font.family: Theme.fontMono
|
|
font.pixelSize: Theme.fsMicro
|
|
font.letterSpacing: 3
|
|
renderType: Text.NativeRendering
|
|
text: Ask.busy
|
|
? "⏎ QUEUE CTRL+S STOP ESC HIDE"
|
|
: "⏎ ASK CTRL+M MODEL CTRL+Y COPY CTRL+L NEW ESC HIDE"
|
|
}
|
|
|
|
Text {
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: Theme.padL
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: Ask.turns.length > 0
|
|
text: Math.floor(Ask.turns.length / 2) + " TURN"
|
|
+ (Ask.turns.length > 2 ? "S" : "")
|
|
color: Theme.muted
|
|
font.family: Theme.fontMono
|
|
font.pixelSize: Theme.fsMicro
|
|
font.letterSpacing: 1
|
|
renderType: Text.NativeRendering
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ keys
|
|
function handleKey(event: var): void {
|
|
if (!root.open) return;
|
|
|
|
const ctrl = (event.modifiers & Qt.ControlModifier) !== 0;
|
|
|
|
if (event.key === Qt.Key_Escape || (ctrl && event.key === Qt.Key_C)) {
|
|
// Hide, do not clear. The thread outlives the panel so that closing
|
|
// it to read something and reopening to ask about that is one
|
|
// conversation, not two.
|
|
Actions.closeAsk();
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
|
// Shift+Enter is a newline; a multi-line question is still one
|
|
// question. Plain Enter sends.
|
|
if ((event.modifiers & Qt.ShiftModifier) === 0) {
|
|
root.send();
|
|
event.accepted = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (ctrl && event.key === Qt.Key_L) {
|
|
Ask.clear();
|
|
input.text = "";
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
if (ctrl && event.key === Qt.Key_S) {
|
|
Ask.stop();
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
if (ctrl && event.key === Qt.Key_M) {
|
|
Ask.cycleModel();
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
if (ctrl && event.key === Qt.Key_Y) {
|
|
root.copyLast();
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
if (ctrl && event.key === Qt.Key_U) {
|
|
input.text = "";
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
// The transcript scrolls on the arrows, because the input is one line
|
|
// and has nowhere for them to go.
|
|
if (event.key === Qt.Key_PageDown || (ctrl && event.key === Qt.Key_J)) {
|
|
transcript.flick(0, -900);
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
if (event.key === Qt.Key_PageUp || (ctrl && event.key === Qt.Key_K)) {
|
|
transcript.flick(0, 900);
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------- animation
|
|
ParallelAnimation {
|
|
id: openAnim
|
|
NumberAnimation {
|
|
target: backdrop; property: "opacity"
|
|
to: 1; duration: Theme.durBase; easing.type: Easing.OutQuad
|
|
}
|
|
NumberAnimation {
|
|
target: shell; property: "opacity"
|
|
to: 1; duration: Theme.durFast
|
|
}
|
|
NumberAnimation {
|
|
target: drop; property: "y"
|
|
from: -shell.height; to: 0
|
|
duration: Theme.durSlow; easing.type: Easing.OutBack
|
|
}
|
|
}
|
|
|
|
ParallelAnimation {
|
|
id: closeAnim
|
|
NumberAnimation {
|
|
target: backdrop; property: "opacity"
|
|
to: 0; duration: Theme.durFast
|
|
}
|
|
NumberAnimation {
|
|
target: shell; property: "opacity"
|
|
to: 0; duration: Theme.durFast
|
|
}
|
|
NumberAnimation {
|
|
target: drop; property: "y"
|
|
to: -shell.height; duration: Theme.durFast; easing.type: Easing.InQuad
|
|
}
|
|
}
|
|
}
|