shell: owned agent surface — tool vocabulary and card
First step of TASK-72's owned destination. modules/souveraine/agent/ is original Souveraine code; no further agent-surface work lands in ii-base. ToolVocabulary transcribes src/ui/chat/tool_renderers.rs — the same event stream, solved once already for the TUI. All 19 registered tools have a kind, an icon and a summary. The vendor card knew 8: bash read write edit grep glob list_dir memory. The 11 it could not name are the interiority verbs. ToolCard groups by what the act is rather than which subsystem serves it, so acts on the machine and acts on herself read as different families. Status is legible without opening the payload; collapsed by default except running or failed. Composed by the manifest, imported by nothing. Registration is a later step.
This commit is contained in:
parent
5a20307ddd
commit
3def2920b8
4 changed files with 403 additions and 0 deletions
|
|
@ -87,6 +87,9 @@ modules/souveraine/island/Island.qml souveraine/modules/souveraine/island/Island
|
|||
modules/souveraine/island/IslandExpansion.qml souveraine/modules/souveraine/island/IslandExpansion.qml
|
||||
modules/souveraine/island/AgentSessionPanel.qml souveraine/modules/souveraine/island/AgentSessionPanel.qml
|
||||
modules/souveraine/island/qmldir souveraine/modules/souveraine/island/qmldir
|
||||
modules/souveraine/agent/ToolVocabulary.qml souveraine/modules/souveraine/agent/ToolVocabulary.qml
|
||||
modules/souveraine/agent/ToolCard.qml souveraine/modules/souveraine/agent/ToolCard.qml
|
||||
modules/souveraine/agent/qmldir souveraine/modules/souveraine/agent/qmldir
|
||||
services/SessionEvents.qml souveraine/services/SessionEvents.qml
|
||||
services/SessiondBridge.qml souveraine/services/SessiondBridge.qml
|
||||
services/SessiondPolicy.qml souveraine/services/SessiondPolicy.qml
|
||||
|
|
|
|||
177
surfaces/quickshell/modules/souveraine/agent/ToolCard.qml
Normal file
177
surfaces/quickshell/modules/souveraine/agent/ToolCard.qml
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
/*
|
||||
* One tool call, drawn as itself.
|
||||
*
|
||||
* Owned Souveraine surface — the vendor snapshot's ToolCallBlock is what this
|
||||
* replaces. Differences that are deliberate, not incidental:
|
||||
*
|
||||
* - every tool in the registry has a name, an icon and a summary, including
|
||||
* the interiority verbs the borrowed card could not see;
|
||||
* - acts on the machine and acts on herself are visually distinct families,
|
||||
* because they are not the same kind of event;
|
||||
* - status is legible without opening the payload (TASK-72 ask 3);
|
||||
* - collapsed by default, except while running or on failure — the two
|
||||
* cases where the payload is the point (TASK-72 ask 2).
|
||||
*
|
||||
* It draws only. It reads no state and calls nothing; the segment is handed
|
||||
* to it whole. Ai.qml stays the data boundary and does not choose pixels.
|
||||
*/
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var segment: ({})
|
||||
|
||||
readonly property string name: String(segment.name ?? "tool")
|
||||
readonly property string status: String(segment.status ?? "running")
|
||||
readonly property string output: String(segment.output ?? "")
|
||||
readonly property bool failed: segment.failed === true
|
||||
readonly property bool running: status === "running"
|
||||
|
||||
readonly property string kind: ToolVocabulary.kindOf(name)
|
||||
readonly property string summary: ToolVocabulary.summarize(name, segment.arguments)
|
||||
|
||||
// Acts on herself read in the secondary accent; acts on the machine in the
|
||||
// primary. Sensors are deliberately quiet — she looks constantly, and a
|
||||
// card per glance should not shout.
|
||||
readonly property color accent: {
|
||||
if (root.failed) return Appearance.colors.colError;
|
||||
switch (root.kind) {
|
||||
case "sensor": return Appearance.colors.colSubtext;
|
||||
case "memory":
|
||||
case "presence":
|
||||
case "intent":
|
||||
case "reach": return Appearance.colors.colSecondary;
|
||||
case "control": return Appearance.colors.colError;
|
||||
default: return Appearance.colors.colPrimary;
|
||||
}
|
||||
}
|
||||
|
||||
property bool expanded: root.running || root.failed
|
||||
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: card.implicitHeight
|
||||
|
||||
function statusLabel() {
|
||||
if (root.running) return Translation.tr("running");
|
||||
if (root.status === "unresolved") return Translation.tr("unresolved");
|
||||
if (root.failed) return Translation.tr("failed");
|
||||
return Translation.tr("done");
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
width: parent.width
|
||||
implicitHeight: cardLayout.implicitHeight + 14
|
||||
radius: Appearance.rounding.small
|
||||
color: root.failed ? Appearance.colors.colErrorContainer : Appearance.colors.colLayer2
|
||||
border.width: 1
|
||||
border.color: root.failed ? Appearance.colors.colError : Appearance.colors.colOutlineVariant
|
||||
|
||||
// The family stripe. Cheaper to read than an icon and it survives
|
||||
// being glanced at sideways in a scrolling column.
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: 1
|
||||
width: 2
|
||||
radius: 1
|
||||
color: root.accent
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: cardLayout
|
||||
anchors.fill: parent
|
||||
anchors.margins: 7
|
||||
anchors.leftMargin: 10
|
||||
spacing: 5
|
||||
|
||||
MouseArea {
|
||||
id: header
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: headerRow.implicitHeight
|
||||
hoverEnabled: true
|
||||
// Nothing to open is not the same as refusing to open.
|
||||
enabled: root.output.length > 0
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: root.expanded = !root.expanded
|
||||
|
||||
RowLayout {
|
||||
id: headerRow
|
||||
anchors.fill: parent
|
||||
spacing: 7
|
||||
|
||||
MaterialSymbol {
|
||||
text: ToolVocabulary.iconOf(root.name)
|
||||
iconSize: Appearance.font.pixelSize.large
|
||||
color: root.accent
|
||||
}
|
||||
StyledText {
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
font.bold: true
|
||||
text: root.name
|
||||
color: Appearance.colors.colOnLayer2
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
text: root.summary
|
||||
color: Appearance.colors.colSubtext
|
||||
}
|
||||
MaterialSymbol {
|
||||
visible: root.running
|
||||
text: "sync"
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: root.accent
|
||||
RotationAnimation on rotation {
|
||||
running: root.running
|
||||
from: 0
|
||||
to: 360
|
||||
duration: 900
|
||||
loops: Animation.Infinite
|
||||
}
|
||||
}
|
||||
StyledText {
|
||||
visible: !root.running
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
text: root.statusLabel()
|
||||
color: root.failed ? Appearance.colors.colError : Appearance.colors.colSubtext
|
||||
}
|
||||
MaterialSymbol {
|
||||
visible: header.enabled
|
||||
text: root.expanded ? "expand_less" : "expand_more"
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.colors.colSubtext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextArea {
|
||||
Layout.fillWidth: true
|
||||
visible: root.expanded && root.output.length > 0
|
||||
implicitHeight: visible ? Math.min(contentHeight + topPadding + bottomPadding, 240) : 0
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: TextArea.Wrap
|
||||
textFormat: TextEdit.PlainText
|
||||
text: root.output
|
||||
font.family: Appearance.font.family.monospace
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
color: root.failed ? Appearance.colors.colOnErrorContainer : Appearance.colors.colOnLayer2
|
||||
background: Rectangle {
|
||||
radius: Appearance.rounding.small / 2
|
||||
color: Appearance.colors.colLayer1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
221
surfaces/quickshell/modules/souveraine/agent/ToolVocabulary.qml
Normal file
221
surfaces/quickshell/modules/souveraine/agent/ToolVocabulary.qml
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
pragma Singleton
|
||||
|
||||
import Quickshell
|
||||
|
||||
/*
|
||||
* The Panel's vocabulary for Souveraine's sensors.
|
||||
*
|
||||
* Transcribed from src/ui/chat/tool_renderers.rs — the TUI solved this once
|
||||
* already, for the same event stream. Where a summary here differs from the
|
||||
* Rust, the Rust is right and this is a bug.
|
||||
*
|
||||
* The vendor snapshot's ToolCallBlock knew 8 tools: bash, read, write, edit,
|
||||
* grep, glob, list_dir, memory. Those are the tools any coding agent has.
|
||||
* The 11 it could not name — outfit, nickname, subagent, atmosphere, reach,
|
||||
* consult, itinerary, todo, schedule, halt, intrusive — are precisely the
|
||||
* ones that make her someone rather than something. They rendered as
|
||||
* anonymous grey blocks.
|
||||
*
|
||||
* Kinds group by what the act *is*, not by which subsystem implements it:
|
||||
* shell — she acts on the machine
|
||||
* sensor — she looks
|
||||
* file — she changes something durable outside herself
|
||||
* memory — she changes herself
|
||||
* presence — how she appears, who she is with
|
||||
* intent — what she has committed to, where she is in it
|
||||
* reach — she addresses someone else
|
||||
* control — she interrupts her own flow
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var kinds: ({
|
||||
"bash": "shell",
|
||||
"read": "sensor",
|
||||
"glob": "sensor",
|
||||
"grep": "sensor",
|
||||
"list_dir": "sensor",
|
||||
"write": "file",
|
||||
"edit": "file",
|
||||
"memory": "memory",
|
||||
"outfit": "presence",
|
||||
"atmosphere": "presence",
|
||||
"nickname": "presence",
|
||||
"todo": "intent",
|
||||
"itinerary": "intent",
|
||||
"schedule": "intent",
|
||||
"reach": "reach",
|
||||
"consult": "reach",
|
||||
"subagent": "reach",
|
||||
"halt": "control",
|
||||
"intrusive": "control"
|
||||
})
|
||||
|
||||
readonly property var icons: ({
|
||||
"bash": "terminal",
|
||||
"read": "article",
|
||||
"glob": "folder_open",
|
||||
"grep": "manage_search",
|
||||
"list_dir": "folder",
|
||||
"write": "edit_note",
|
||||
"edit": "edit_note",
|
||||
"memory": "psychology",
|
||||
"outfit": "checkroom",
|
||||
"atmosphere": "palette",
|
||||
"nickname": "badge",
|
||||
"todo": "checklist",
|
||||
"itinerary": "route",
|
||||
"schedule": "schedule",
|
||||
"reach": "hub",
|
||||
"consult": "forum",
|
||||
"subagent": "account_tree",
|
||||
"halt": "pan_tool",
|
||||
"intrusive": "bolt"
|
||||
})
|
||||
|
||||
function kindOf(name) {
|
||||
return root.kinds[String(name ?? "")] ?? "unknown";
|
||||
}
|
||||
|
||||
function iconOf(name) {
|
||||
return root.icons[String(name ?? "")] ?? "sensors";
|
||||
}
|
||||
|
||||
// An unknown tool is a real event, not a defect to hide. It renders as
|
||||
// itself with a generic icon — and it is legible as unknown, which is how
|
||||
// a new sensor announces that this file needs a line.
|
||||
function isKnown(name) {
|
||||
return root.kinds[String(name ?? "")] !== undefined;
|
||||
}
|
||||
|
||||
function clip(text, max) {
|
||||
const value = String(text ?? "");
|
||||
return value.length > max ? value.slice(0, Math.max(0, max - 1)) + "…" : value;
|
||||
}
|
||||
|
||||
function parseArguments(raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(raw ?? "{}"));
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function str(args, key) {
|
||||
const value = args[key];
|
||||
return (typeof value === "string" && value.length > 0) ? value : null;
|
||||
}
|
||||
|
||||
// Every key: value pair, joined. The fallback, matching summarize_generic.
|
||||
function generic(args) {
|
||||
const parts = [];
|
||||
for (const key in args) {
|
||||
const value = args[key];
|
||||
const text = (typeof value === "string") ? root.clip(value, 60) : root.clip(JSON.stringify(value), 60);
|
||||
parts.push(key + ": " + text);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/*
|
||||
* One line describing what this call actually did.
|
||||
* Mirrors summarize_tool_args(). Falls through to generic() exactly where
|
||||
* the Rust does — including for a tool whose expected field is missing,
|
||||
* which is a real case and must not render as empty.
|
||||
*/
|
||||
function summarize(name, rawArguments) {
|
||||
const args = root.parseArguments(rawArguments);
|
||||
const tool = String(name ?? "");
|
||||
|
||||
switch (tool) {
|
||||
case "bash": {
|
||||
const command = root.str(args, "command");
|
||||
if (command === null) return root.generic(args);
|
||||
return "$ " + root.clip(command, 80) + (args.run_in_background === true ? " [bg]" : "");
|
||||
}
|
||||
case "read": {
|
||||
const path = root.str(args, "path");
|
||||
if (path === null) return root.generic(args);
|
||||
return root.clip(path, 60) + (args.force === true ? " [force]" : "");
|
||||
}
|
||||
case "write": {
|
||||
const path = root.str(args, "path");
|
||||
if (path === null) return root.generic(args);
|
||||
const mode = root.str(args, "mode");
|
||||
return (mode === "append" ? "append → " : "write → ") + root.clip(path, 60);
|
||||
}
|
||||
case "edit": {
|
||||
const path = root.str(args, "path");
|
||||
if (path === null) return root.generic(args);
|
||||
return "edit → " + root.clip(path, 60) + (args.replace_all === true ? " [all]" : "");
|
||||
}
|
||||
case "grep": {
|
||||
const pattern = root.str(args, "pattern");
|
||||
if (pattern === null) return root.generic(args);
|
||||
const where = root.str(args, "path");
|
||||
return "\"" + root.clip(pattern, 48) + "\"" + (where !== null ? " in " + root.clip(where, 30) : "");
|
||||
}
|
||||
case "glob": {
|
||||
const pattern = root.str(args, "pattern");
|
||||
return pattern === null ? root.generic(args) : root.clip(pattern, 80);
|
||||
}
|
||||
case "list_dir":
|
||||
return root.clip(root.str(args, "path") ?? ".", 80);
|
||||
case "memory": {
|
||||
const command = root.str(args, "command") ?? "list";
|
||||
const path = root.str(args, "path");
|
||||
return path === null ? command : command + " " + root.clip(path, 40);
|
||||
}
|
||||
case "todo": {
|
||||
const action = root.str(args, "action") ?? "list";
|
||||
if (action === "list") return "list";
|
||||
const what = root.str(args, "text") ?? root.str(args, "id");
|
||||
return what === null ? action : action + ": " + root.clip(what, 50);
|
||||
}
|
||||
case "itinerary": {
|
||||
const action = root.str(args, "action") ?? "describe";
|
||||
let title = root.str(args, "title");
|
||||
if (title === null && Array.isArray(args.stops) && args.stops.length > 0) {
|
||||
const first = args.stops[0];
|
||||
// A stop is an object with a name; older callers passed a bare string.
|
||||
title = (first && typeof first === "object") ? (first.name ?? null) : (typeof first === "string" ? first : null);
|
||||
}
|
||||
return title === null ? action : action + ": " + root.clip(title, 40);
|
||||
}
|
||||
case "schedule": {
|
||||
const action = root.str(args, "action") ?? "list";
|
||||
const named = root.str(args, "name");
|
||||
return named === null ? action : action + ": " + root.clip(named, 30);
|
||||
}
|
||||
case "nickname": {
|
||||
const action = root.str(args, "action") ?? "get";
|
||||
const named = root.str(args, "name");
|
||||
return named === null ? action : action + ": " + root.clip(named, 30);
|
||||
}
|
||||
case "atmosphere":
|
||||
case "outfit": {
|
||||
const named = root.str(args, "name");
|
||||
// An empty name is meaningful for both: it is a return to default.
|
||||
if (named === null) return (args.name === "") ? "default" : root.generic(args);
|
||||
return named;
|
||||
}
|
||||
case "subagent": {
|
||||
const prompt = root.str(args, "prompt");
|
||||
const subType = root.str(args, "subagent_type");
|
||||
let out = "";
|
||||
if (args.run_in_background === true) out += "[bg] ";
|
||||
if (subType !== null && subType !== "general-purpose") out += "[" + subType + "] ";
|
||||
if (prompt !== null) out += root.clip(prompt, 50);
|
||||
return out.length > 0 ? out : root.generic(args);
|
||||
}
|
||||
case "reach":
|
||||
case "consult": {
|
||||
const target = root.str(args, "target");
|
||||
return target === null ? root.generic(args) : root.clip(target, 40);
|
||||
}
|
||||
default:
|
||||
return root.generic(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
surfaces/quickshell/modules/souveraine/agent/qmldir
Normal file
2
surfaces/quickshell/modules/souveraine/agent/qmldir
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
singleton ToolVocabulary 1.0 ToolVocabulary.qml
|
||||
ToolCard 1.0 ToolCard.qml
|
||||
Loading…
Reference in a new issue