feat(ai): auto-resize input with scroll (#3148)

This commit is contained in:
Minh
2026-03-27 22:56:30 +01:00
committed by GitHub
@@ -497,199 +497,206 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
RowLayout { // Input field and send button RowLayout { // Input field and send button
id: inputFieldRowLayout id: inputFieldRowLayout
anchors { anchors {
top: attachedFileIndicator.bottom bottom: commandButtonsRow.top
left: parent.left left: parent.left
right: parent.right right: parent.right
topMargin: 5 bottomMargin: 5
} }
spacing: 0 spacing: 0
StyledTextArea { // The actual TextArea ScrollView {
id: messageInputField id: inputScrollView
wrapMode: TextArea.Wrap
Layout.fillWidth: true Layout.fillWidth: true
padding: 10 Layout.preferredHeight: Math.min(root.height * 3/5, messageInputField.height)
color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant clip: true
placeholderText: Translation.tr('Message the model... "%1" for commands').arg(root.commandPrefix) ScrollBar.vertical.policy: ScrollBar.AsNeeded
background: null StyledTextArea { // The actual TextArea (inside ScrollView to enable scrolling)
id: messageInputField
anchors.fill: parent
wrapMode: TextArea.Wrap
padding: 10
color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant
placeholderText: Translation.tr('Message the model... "%1" for commands').arg(root.commandPrefix)
onTextChanged: { background: null
// Handle suggestions
if (messageInputField.text.length === 0) { onTextChanged: {
root.suggestionQuery = ""; // Handle suggestions
root.suggestionList = []; if (messageInputField.text.length === 0) {
return; root.suggestionQuery = "";
} else if (messageInputField.text.startsWith(`${root.commandPrefix}model`)) { root.suggestionList = [];
root.suggestionQuery = messageInputField.text.split(" ")[1] ?? ""; return;
const modelResults = Fuzzy.go(root.suggestionQuery, Ai.modelList.map(model => { } else if (messageInputField.text.startsWith(`${root.commandPrefix}model`)) {
return { root.suggestionQuery = messageInputField.text.split(" ")[1] ?? "";
name: Fuzzy.prepare(model), const modelResults = Fuzzy.go(root.suggestionQuery, Ai.modelList.map(model => {
obj: model return {
}; name: Fuzzy.prepare(model),
}), { obj: model
all: true, };
key: "name" }), {
}); all: true,
root.suggestionList = modelResults.map(model => { key: "name"
return { });
name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "model ") : ""}${model.target}`, root.suggestionList = modelResults.map(model => {
displayName: `${Ai.models[model.target].name}`, return {
description: `${Ai.models[model.target].description}` name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "model ") : ""}${model.target}`,
}; displayName: `${Ai.models[model.target].name}`,
}); description: `${Ai.models[model.target].description}`
} else if (messageInputField.text.startsWith(`${root.commandPrefix}prompt`)) { };
root.suggestionQuery = messageInputField.text.split(" ")[1] ?? ""; });
const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.promptFiles.map(file => { } else if (messageInputField.text.startsWith(`${root.commandPrefix}prompt`)) {
return { root.suggestionQuery = messageInputField.text.split(" ")[1] ?? "";
name: Fuzzy.prepare(file), const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.promptFiles.map(file => {
obj: file return {
}; name: Fuzzy.prepare(file),
}), { obj: file
all: true, };
key: "name" }), {
}); all: true,
root.suggestionList = promptFileResults.map(file => { key: "name"
return { });
name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "prompt ") : ""}${file.target}`, root.suggestionList = promptFileResults.map(file => {
displayName: `${FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target))}`, return {
description: Translation.tr("Load prompt from %1").arg(file.target) name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "prompt ") : ""}${file.target}`,
}; displayName: `${FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target))}`,
}); description: Translation.tr("Load prompt from %1").arg(file.target)
} else if (messageInputField.text.startsWith(`${root.commandPrefix}save`)) { };
root.suggestionQuery = messageInputField.text.split(" ")[1] ?? ""; });
const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.savedChats.map(file => { } else if (messageInputField.text.startsWith(`${root.commandPrefix}save`)) {
return { root.suggestionQuery = messageInputField.text.split(" ")[1] ?? "";
name: Fuzzy.prepare(file), const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.savedChats.map(file => {
obj: file return {
}; name: Fuzzy.prepare(file),
}), { obj: file
all: true, };
key: "name" }), {
}); all: true,
root.suggestionList = promptFileResults.map(file => { key: "name"
const chatName = FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target)).trim(); });
return { root.suggestionList = promptFileResults.map(file => {
name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "save ") : ""}${chatName}`, const chatName = FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target)).trim();
displayName: `${chatName}`, return {
description: Translation.tr("Save chat to %1").arg(chatName) name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "save ") : ""}${chatName}`,
}; displayName: `${chatName}`,
}); description: Translation.tr("Save chat to %1").arg(chatName)
} else if (messageInputField.text.startsWith(`${root.commandPrefix}load`)) { };
root.suggestionQuery = messageInputField.text.split(" ")[1] ?? ""; });
const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.savedChats.map(file => { } else if (messageInputField.text.startsWith(`${root.commandPrefix}load`)) {
return { root.suggestionQuery = messageInputField.text.split(" ")[1] ?? "";
name: Fuzzy.prepare(file), const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.savedChats.map(file => {
obj: file return {
}; name: Fuzzy.prepare(file),
}), { obj: file
all: true, };
key: "name" }), {
}); all: true,
root.suggestionList = promptFileResults.map(file => { key: "name"
const chatName = FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target)).trim(); });
return { root.suggestionList = promptFileResults.map(file => {
name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "load ") : ""}${chatName}`, const chatName = FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target)).trim();
displayName: `${chatName}`, return {
description: Translation.tr(`Load chat from %1`).arg(file.target) name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "load ") : ""}${chatName}`,
}; displayName: `${chatName}`,
}); description: Translation.tr(`Load chat from %1`).arg(file.target)
} else if (messageInputField.text.startsWith(`${root.commandPrefix}tool`)) { };
root.suggestionQuery = messageInputField.text.split(" ")[1] ?? ""; });
const toolResults = Fuzzy.go(root.suggestionQuery, Ai.availableTools.map(tool => { } else if (messageInputField.text.startsWith(`${root.commandPrefix}tool`)) {
return { root.suggestionQuery = messageInputField.text.split(" ")[1] ?? "";
name: Fuzzy.prepare(tool), const toolResults = Fuzzy.go(root.suggestionQuery, Ai.availableTools.map(tool => {
obj: tool return {
}; name: Fuzzy.prepare(tool),
}), { obj: tool
all: true, };
key: "name" }), {
}); all: true,
root.suggestionList = toolResults.map(tool => { key: "name"
const toolName = tool.target; });
return { root.suggestionList = toolResults.map(tool => {
name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "tool ") : ""}${tool.target}`, const toolName = tool.target;
displayName: toolName, return {
description: Ai.toolDescriptions[toolName] name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "tool ") : ""}${tool.target}`,
}; displayName: toolName,
}); description: Ai.toolDescriptions[toolName]
} else if (messageInputField.text.startsWith(root.commandPrefix)) { };
root.suggestionQuery = messageInputField.text; });
root.suggestionList = root.allCommands.filter(cmd => cmd.name.startsWith(messageInputField.text.substring(1))).map(cmd => { } else if (messageInputField.text.startsWith(root.commandPrefix)) {
return { root.suggestionQuery = messageInputField.text;
name: `${root.commandPrefix}${cmd.name}`, root.suggestionList = root.allCommands.filter(cmd => cmd.name.startsWith(messageInputField.text.substring(1))).map(cmd => {
description: `${cmd.description}` return {
}; name: `${root.commandPrefix}${cmd.name}`,
}); description: `${cmd.description}`
};
});
}
} }
}
function accept() { function accept() {
root.handleInput(text); root.handleInput(text);
text = ""; text = "";
} }
Keys.onPressed: event => { Keys.onPressed: event => {
if (event.key === Qt.Key_Tab) { if (event.key === Qt.Key_Tab) {
suggestions.acceptSelectedWord(); suggestions.acceptSelectedWord();
event.accepted = true;
} else if (event.key === Qt.Key_Up && suggestions.visible) {
suggestions.selectedIndex = Math.max(0, suggestions.selectedIndex - 1);
event.accepted = true;
} else if (event.key === Qt.Key_Down && suggestions.visible) {
suggestions.selectedIndex = Math.min(root.suggestionList.length - 1, suggestions.selectedIndex + 1);
event.accepted = true;
} else if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return)) {
if (event.modifiers & Qt.ShiftModifier) {
// Insert newline
messageInputField.insert(messageInputField.cursorPosition, "\n");
event.accepted = true; event.accepted = true;
} else { } else if (event.key === Qt.Key_Up && suggestions.visible) {
// Accept text suggestions.selectedIndex = Math.max(0, suggestions.selectedIndex - 1);
const inputText = messageInputField.text;
messageInputField.clear();
root.handleInput(inputText);
event.accepted = true; event.accepted = true;
} } else if (event.key === Qt.Key_Down && suggestions.visible) {
} else if ((event.modifiers & Qt.ControlModifier) && event.key === Qt.Key_V) { suggestions.selectedIndex = Math.min(root.suggestionList.length - 1, suggestions.selectedIndex + 1);
// Intercept Ctrl+V to handle image/file pasting
if (event.modifiers & Qt.ShiftModifier) {
// Let Shift+Ctrl+V = plain paste
messageInputField.text += Quickshell.clipboardText;
event.accepted = true; event.accepted = true;
return; } else if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return)) {
} if (event.modifiers & Qt.ShiftModifier) {
// Try image paste first // Insert newline
const currentClipboardEntry = Cliphist.entries[0]; messageInputField.insert(messageInputField.cursorPosition, "\n");
const cleanCliphistEntry = StringUtils.cleanCliphistEntry(currentClipboardEntry); event.accepted = true;
if (/^\d+\t\[\[.*binary data.*\d+x\d+.*\]\]$/.test(currentClipboardEntry)) { } else {
// First entry = currently copied entry = image? // Accept text
decodeImageAndAttachProc.handleEntry(currentClipboardEntry); const inputText = messageInputField.text;
event.accepted = true; messageInputField.clear();
return; root.handleInput(inputText);
} else if (cleanCliphistEntry.startsWith("file://")) { event.accepted = true;
// First entry = currently copied entry = image? }
const fileName = decodeURIComponent(cleanCliphistEntry); } else if ((event.modifiers & Qt.ControlModifier) && event.key === Qt.Key_V) {
Ai.attachFile(fileName); // Intercept Ctrl+V to handle image/file pasting
event.accepted = true; if (event.modifiers & Qt.ShiftModifier) {
return; // Let Shift+Ctrl+V = plain paste
} messageInputField.text += Quickshell.clipboardText;
event.accepted = false; // No image, let text pasting proceed event.accepted = true;
} else if (event.key === Qt.Key_Escape) { return;
// Esc to detach file }
if (Ai.pendingFilePath.length > 0) { // Try image paste first
Ai.attachFile(""); const currentClipboardEntry = Cliphist.entries[0];
event.accepted = true; const cleanCliphistEntry = StringUtils.cleanCliphistEntry(currentClipboardEntry);
} else { if (/^\d+\t\[\[.*binary data.*\d+x\d+.*\]\]$/.test(currentClipboardEntry)) {
event.accepted = false; // First entry = currently copied entry = image?
decodeImageAndAttachProc.handleEntry(currentClipboardEntry);
event.accepted = true;
return;
} else if (cleanCliphistEntry.startsWith("file://")) {
// First entry = currently copied entry = image?
const fileName = decodeURIComponent(cleanCliphistEntry);
Ai.attachFile(fileName);
event.accepted = true;
return;
}
event.accepted = false; // No image, let text pasting proceed
} else if (event.key === Qt.Key_Escape) {
// Esc to detach file
if (Ai.pendingFilePath.length > 0) {
Ai.attachFile("");
event.accepted = true;
} else {
event.accepted = false;
}
} }
} }
} }
} }
RippleButton { // Send button RippleButton { // Send button
id: sendButton id: sendButton
Layout.alignment: Qt.AlignTop Layout.alignment: Qt.AlignBottom
Layout.rightMargin: 5 Layout.rightMargin: 5
implicitWidth: 40 implicitWidth: 40
implicitHeight: 40 implicitHeight: 40