Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-08-12 12:55:11 -04:00
commit 3def2920b8
4 changed files with 403 additions and 0 deletions

View file

@ -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

View 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
}
}
}
}
}

View 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);
}
}
}

View file

@ -0,0 +1,2 @@
singleton ToolVocabulary 1.0 ToolVocabulary.qml
ToolCard 1.0 ToolCard.qml