@@ -8,28 +8,13 @@ import Gio from 'gi://Gio';
|
||||
import GLib from 'gi://GLib';
|
||||
import St from 'gi://St';
|
||||
import Clutter from 'gi://Clutter';
|
||||
import Pango from 'gi://Pango';
|
||||
import { installShellService } from './shell-dbus.js';
|
||||
import { ConversationView, JarvisOsd, ComputerUseChrome, SessionPanel, coerceText, safeText, shortError } from './ui.js';
|
||||
|
||||
const BUS = 'io.qvac.Jarvis';
|
||||
const PATH = '/io/qvac/Jarvis';
|
||||
const IFACE = 'io.qvac.Jarvis.Session';
|
||||
const OVERLAY_WIDTH = 720;
|
||||
const STATES = new Set(['ARMED', 'LISTENING', 'THINKING', 'SPEAKING', 'SLEEPING']);
|
||||
const coerceText = (value) => {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string') return value === '[object Object]' ? '' : value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (typeof value === 'object') {
|
||||
if (typeof value.text === 'string') return value.text;
|
||||
if (typeof value.message === 'string') return value.message;
|
||||
if (typeof value.deep_unpack === 'function') return coerceText(value.deep_unpack());
|
||||
}
|
||||
const fallback = String(value);
|
||||
return fallback === '[object Object]' ? '' : fallback;
|
||||
};
|
||||
const safeText = (value) => coerceText(value).replace(/[<>]/g, '');
|
||||
const shortError = (value) => safeText(value).split('\n')[0].replace(/\s+/g, ' ').slice(0, 140);
|
||||
const GLYPHS = { ARMED: '◯', LISTENING: '◌', THINKING: '◉', SPEAKING: '◎', SLEEPING: '◐' };
|
||||
|
||||
class JarvisProxy {
|
||||
async connect() {
|
||||
@@ -56,175 +41,133 @@ class JarvisProxy {
|
||||
}
|
||||
}
|
||||
|
||||
class ArcOverlay {
|
||||
constructor() {
|
||||
this.root = new St.BoxLayout({ style_class: 'jarvis-arc', vertical: true, reactive: true, can_focus: true, track_hover: true, x_expand: true });
|
||||
this.root.accessible_name = 'Jarvis ARC voice assistant';
|
||||
this.header = new St.BoxLayout({ style_class: 'jarvis-arc-header', x_expand: true });
|
||||
this.title = new St.Label({ text: '◉ JARVIS', style_class: 'jarvis-title', x_align: Clutter.ActorAlign.START }); this.title.accessible_name = 'Jarvis status';
|
||||
if (this.title.clutter_text) this.title.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
|
||||
this.status = new St.Label({ text: 'LOCAL', style_class: 'jarvis-local' }); this.status.accessible_name = 'Local model status';
|
||||
if (this.status.clutter_text) this.status.clutter_text.ellipsize = Pango.EllipsizeMode.END;
|
||||
this.header.add_child(this.title); this.header.add_child(new St.Widget({ x_expand: true })); this.header.add_child(this.status);
|
||||
this.statusLine = new St.Label({ text: 'Super+Shift+J · Hold Talk to speak', style_class: 'jarvis-status-line' });
|
||||
this.thinkingToggle = new St.Button({ label: 'Thinking', style_class: 'jarvis-thinking-toggle', visible: false, can_focus: true, reactive: true, x_align: Clutter.ActorAlign.START });
|
||||
this.thinkingToggle.accessible_name = 'Show or hide Jarvis thinking';
|
||||
this.thinkingToggle.connect('clicked', () => this.toggleThinking());
|
||||
this.thinking = new St.Label({ text: '', style_class: 'jarvis-thinking', visible: false, x_expand: true, can_focus: true });
|
||||
this.thinking.accessible_name = 'Jarvis thinking';
|
||||
if (this.thinking.clutter_text) { this.thinking.clutter_text.line_wrap = true; this.thinking.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; }
|
||||
this.notice = new St.Label({ text: '', style_class: 'jarvis-notice', visible: false, x_expand: true });
|
||||
this.notice.accessible_name = 'Jarvis notice';
|
||||
this.job = new St.Label({ text: '', style_class: 'jarvis-job', can_focus: true, visible: false }); this.job.accessible_name = 'Jarvis job progress';
|
||||
this.target = new St.Label({ text: '', style_class: 'jarvis-target', can_focus: true, visible: false }); this.target.accessible_name = 'Computer use target';
|
||||
this.cursor = new St.Label({ text: '', style_class: 'jarvis-agent-cursor', can_focus: false, visible: false }); this.cursor.accessible_name = 'Visible computer use cursor';
|
||||
this.confirm = new St.BoxLayout({ style_class: 'jarvis-confirm', visible: false, x_expand: true });
|
||||
this.confirmLabel = new St.Label({ text: '', style_class: 'jarvis-confirm-label', x_expand: true });
|
||||
this.confirm.add_child(this.confirmLabel);
|
||||
for (const [label, decision] of [['Allow', 'allow'], ['Deny', 'deny']]) {
|
||||
const button = new St.Button({ label, style_class: 'jarvis-chip', reactive: true, can_focus: true });
|
||||
button.connect('clicked', () => this._answerConfirm(decision));
|
||||
this.confirm.add_child(button);
|
||||
}
|
||||
this.scroll = new St.ScrollView({ style_class: 'jarvis-transcript-scroll', overlay_scrollbars: true, x_expand: true });
|
||||
try { this.scroll.hscrollbar_policy = St.PolicyType.NEVER; this.scroll.vscrollbar_policy = St.PolicyType.AUTOMATIC; } catch {}
|
||||
this.transcript = new St.BoxLayout({ style_class: 'jarvis-transcript', vertical: true, x_expand: true }); this.transcript.accessible_name = 'Conversation transcript';
|
||||
if (typeof this.scroll.set_child === 'function') this.scroll.set_child(this.transcript); else this.scroll.add_child(this.transcript);
|
||||
this.wave = new St.BoxLayout({ style_class: 'jarvis-wave', visible: false }); this.bars = [];
|
||||
for (let i = 0; i < 48; i++) { const bar = new St.Widget({ style_class: 'jarvis-wave-bar', height: 4, y_align: Clutter.ActorAlign.CENTER }); this.wave.add_child(bar); this.bars.push(bar); }
|
||||
this.chipScroll = new St.ScrollView({ style_class: 'jarvis-chip-scroll', overlay_scrollbars: true, x_expand: true });
|
||||
try { this.chipScroll.vscrollbar_policy = St.PolicyType.NEVER; this.chipScroll.hscrollbar_policy = St.PolicyType.AUTOMATIC; } catch {}
|
||||
this.chips = new St.BoxLayout({ style_class: 'jarvis-chips' }); this.chips.accessible_name = 'Suggested actions';
|
||||
if (typeof this.chipScroll.set_child === 'function') this.chipScroll.set_child(this.chips); else this.chipScroll.add_child(this.chips);
|
||||
for (const mode of ['Chat', 'Files', 'Vision', 'Imagine', 'Compose', 'Translate', 'Dictate', 'Computer', 'Lab']) {
|
||||
const chip = new St.Button({ label: mode, style_class: 'jarvis-chip', can_focus: true, reactive: true }); chip.accessible_name = `${mode} mode`;
|
||||
chip.connect('clicked', () => this.onMode?.(mode.toLowerCase())); this.chips.add_child(chip);
|
||||
}
|
||||
this.root.add_child(this.header); this.root.add_child(this.statusLine); this.root.add_child(this.thinkingToggle); this.root.add_child(this.thinking); this.root.add_child(this.notice); this.root.add_child(this.confirm); this.root.add_child(this.job); this.root.add_child(this.target); this.root.add_child(this.cursor); this.root.add_child(this.wave); this.root.add_child(this.scroll); this.root.add_child(this.chipScroll);
|
||||
this.controls = new St.BoxLayout({ style_class: 'jarvis-controls' });
|
||||
this.talk = new St.Button({ label: 'Hold to talk', style_class: 'jarvis-chip jarvis-talk', reactive: true, can_focus: true });
|
||||
this.talk.accessible_name = 'Hold Space or Enter to talk';
|
||||
this.talk.connect('key-press-event', (_actor, event) => { if ([Clutter.KEY_space, Clutter.KEY_Return].includes(event.get_key_symbol())) { this.onTalk?.(true); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; });
|
||||
this.talk.connect('key-release-event', (_actor, event) => { if ([Clutter.KEY_space, Clutter.KEY_Return].includes(event.get_key_symbol())) { this.onTalk?.(false); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; });
|
||||
this.talk.connect('key-focus-out', () => this.onTalk?.(false));
|
||||
this.talk.connect('leave-event', () => { this.onTalk?.(false); return Clutter.EVENT_PROPAGATE; });
|
||||
this.talk.connect('button-press-event', () => { this.onTalk?.(true); return Clutter.EVENT_STOP; });
|
||||
this.talk.connect('button-release-event', () => { this.onTalk?.(false); return Clutter.EVENT_STOP; });
|
||||
this.controls.add_child(this.talk);
|
||||
for (const [label, action] of [['Stop', () => this.onStop?.()], ['Reset context', () => this.onReset?.()], ['Minimize', () => this.minimize()], ['Close', () => this.hide()]]) {
|
||||
const button = new St.Button({ label, style_class: 'jarvis-chip jarvis-chip-quiet', reactive: true, can_focus: true });
|
||||
button.connect('clicked', action); this.controls.add_child(button);
|
||||
}
|
||||
this.entry = new St.Entry({ hint_text: 'Ask Jarvis…', can_focus: true, x_expand: true });
|
||||
this.entry.clutter_text.connect('activate', () => { const text = this.entry.get_text().trim(); if (text) { this.onAsk?.(text); this.entry.set_text(''); } });
|
||||
this.root.add_child(this.entry); this.root.add_child(this.controls);
|
||||
this.reducedMotion = false;
|
||||
this.streamingReply = false;
|
||||
this.replyFinalized = false;
|
||||
this.minimized = false;
|
||||
this._state = 'ARMED';
|
||||
}
|
||||
attach() { Main.layoutManager.addChrome(this.root, { affectsStruts: false, trackFullscreen: false }); this.halo = new St.Widget({ style_class: 'jarvis-halo', reactive: false }); Main.layoutManager.addChrome(this.halo, { affectsStruts: false, trackFullscreen: false }); this.halo.hide(); this.hide(); }
|
||||
show(force = false) {
|
||||
if (this.minimized && !force) return;
|
||||
this.minimized = false;
|
||||
const monitor = Main.layoutManager.primaryMonitor;
|
||||
if (monitor) {
|
||||
const width = Math.min(OVERLAY_WIDTH, monitor.width - 80);
|
||||
this.root.set_width(width);
|
||||
this.root.set_position(monitor.x + Math.max(0, Math.round((monitor.width - width) / 2)), monitor.y + 48);
|
||||
this.scroll.set_height(Math.max(80, Math.min(220, monitor.height - 420)));
|
||||
}
|
||||
const wasVisible = this.root.visible;
|
||||
this.root.visible = true;
|
||||
this.wave.visible = this._state === 'LISTENING' || this._state === 'SPEAKING';
|
||||
if (!wasVisible) this.entry.grab_key_focus();
|
||||
}
|
||||
_dismiss() { this.onTalk?.(false); this.root.visible = false; this.minimized = true; this.halo?.hide(); }
|
||||
hide() { this._dismiss(); }
|
||||
minimize() { this._dismiss(); }
|
||||
toggle() { this.root.visible ? this.hide() : this.show(true); }
|
||||
clear() { this.transcript.destroy_all_children(); this.thinking.text = ''; this.thinking.visible = false; this.thinkingToggle.visible = false; this.notice.visible = false; this.confirm.visible = false; this.streamingReply = false; this.replyFinalized = false; }
|
||||
setNotice(text) { const body = shortError(text); this.notice.text = body; this.notice.visible = Boolean(body); }
|
||||
_followConversation() {
|
||||
const adjustment = this.scroll?.get_vadjustment?.() || this.scroll?.vadjustment;
|
||||
if (!adjustment) return;
|
||||
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE || GLib.PRIORITY_DEFAULT, () => {
|
||||
try { adjustment.value = Math.max(0, adjustment.upper - adjustment.page_size); } catch {}
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
addRow(who, text) { const body = safeText(text); const row = new St.Label({ text: `${who === 'J' ? 'J' : 'YOU'} ${body}`, style_class: `jarvis-row jarvis-row-${who === 'J' ? 'jarvis' : 'user'}`, can_focus: true }); row.accessible_name = `${who === 'J' ? 'Jarvis' : 'You'}: ${body}`; if (row.clutter_text) { row.clutter_text.line_wrap = true; row.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; row.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; } this.transcript.add_child(row); if (this.transcript.get_n_children() > 100) this.transcript.get_first_child().destroy(); this._followConversation(); return row; }
|
||||
token(text) { const chunk = safeText(text); if (!chunk) return; let row = this.transcript.get_last_child?.(); if (!this.streamingReply || !row || !String(row.style_class || '').includes('jarvis-row-jarvis') || String(row.style_class || '').includes('jarvis-row-tool')) { row = this.addRow('J', ''); this.streamingReply = true; this.replyFinalized = false; } row.text = `${row.text}${chunk}`; row.accessible_name = `Jarvis: ${row.text}`; this._followConversation(); }
|
||||
updateThinking(text) { const chunk = safeText(text); if (!chunk) return; this.thinking.text = `${this.thinking.text || ''}${chunk}`; this.thinkingToggle.visible = true; this.thinkingToggle.label = 'Thinking ▾'; }
|
||||
toggleThinking() { this.thinking.visible = !this.thinking.visible; this.thinkingToggle.label = this.thinking.visible ? 'Thinking ▾' : 'Thinking ▸'; }
|
||||
finishThinking() { if (this.thinking.text) { this.thinkingToggle.visible = true; this.thinkingToggle.label = 'Thinking ▸'; this.thinking.visible = false; } }
|
||||
addToolCall(json) { this.streamingReply = false; this.replyFinalized = false; try { const call = JSON.parse(json); this._addToolRow(`Using ${safeText(call.name || 'tool')}…`); } catch { this._addToolRow(`Using ${safeText(json)}…`); } }
|
||||
addToolResult(json) { this.streamingReply = false; try { const result = JSON.parse(json); this._addToolRow(`${safeText(result.name || 'tool')} complete`); } catch { this._addToolRow('Tool complete'); } }
|
||||
_addToolRow(text) { const row = this.addRow('J', text); row.style_class = `${row.style_class} jarvis-row-tool`; }
|
||||
offerConfirm(tool, argsJson, pattern) {
|
||||
let jobId = ''; let toolCallId = '';
|
||||
try { const parsed = JSON.parse(argsJson); jobId = parsed.jobId || ''; toolCallId = parsed.toolCallId || ''; } catch {}
|
||||
this._confirmJob = jobId; this._confirmCall = toolCallId;
|
||||
this.confirmLabel.text = `Allow ${safeText(tool)}? ${safeText(pattern)}`.trim();
|
||||
this.confirm.visible = true;
|
||||
}
|
||||
_answerConfirm(decision) { this.confirm.visible = false; this.onConfirm?.(this._confirmJob || '', this._confirmCall || '', decision); }
|
||||
finalizeReply(text) {
|
||||
this.finishThinking();
|
||||
const spoken = safeText(text);
|
||||
if (this.replyFinalized) return;
|
||||
let row = this.transcript.get_last_child?.();
|
||||
if (this.streamingReply && row && String(row.style_class || '').includes('jarvis-row-jarvis') && !String(row.style_class || '').includes('jarvis-row-tool')) {
|
||||
const body = String(row.text || '').replace(/^J\s+/, '');
|
||||
if (spoken && !body.trim()) { row.text = `J ${spoken}`; row.accessible_name = `Jarvis: ${spoken}`; }
|
||||
this.streamingReply = false; this.replyFinalized = true; return;
|
||||
}
|
||||
if (spoken) this.addRow('J', spoken);
|
||||
this.replyFinalized = true;
|
||||
}
|
||||
setConnectionStatus(kind) {
|
||||
const labels = { local: 'LOCAL', offline: 'LOCAL · daemon unavailable', 'voice-unavailable': 'LOCAL · voice unavailable' };
|
||||
this.status.text = labels[kind] || labels.local;
|
||||
this.status.accessible_name = this.status.text;
|
||||
}
|
||||
setState(state) {
|
||||
const value = STATES.has(state) ? state : 'ARMED';
|
||||
this._state = value;
|
||||
this.statusLine.text = `${value.toLowerCase()} · Super+Shift+J · Hold Talk to speak`;
|
||||
this.title.text = `${value === 'SLEEPING' ? '⧸' : '◉'} JARVIS`;
|
||||
this.status.style_class = `jarvis-local jarvis-state-${value.toLowerCase()}`;
|
||||
this.wave.visible = !this.minimized && (value === 'LISTENING' || value === 'SPEAKING');
|
||||
if (value === 'LISTENING') this.showHalo();
|
||||
}
|
||||
level(rms) { if (this.minimized) return; this.wave.visible = true; const value = Math.max(0, Math.min(1, Number(rms) || 0)); for (let i = 0; i < this.bars.length; i++) this.bars[i].height = Math.max(4, Math.round(4 + value * 32 * (0.45 + Math.abs(Math.sin(i * 1.7)) * 0.55))); }
|
||||
addChip(id, label, payload) { const chip = new St.Button({ label: safeText(label), style_class: 'jarvis-chip jarvis-chip-suggested', can_focus: true }); chip.accessible_name = `Suggested action: ${safeText(label)}`; chip.connect('clicked', () => this.onSuggestion?.(id, payload)); this.chips.add_child(chip); }
|
||||
addStep(json) { try { const step = JSON.parse(json); this.addRow('J', `${step.n ? `${step.n}. ` : ''}${step.action || 'computer step'}`); } catch { this.addRow('J', json); } }
|
||||
addJob(id, pct, label) { this.job.text = `${safeText(label)} · ${Math.round(Number(pct) * 100)}%`; this.job.visible = true; }
|
||||
setTarget(json) { this.target.text = `⌾ ${safeText(json)}`; this.cursor.text = '◎'; this.cursor.visible = true; this.target.visible = true; }
|
||||
showHalo() { if (!this.halo || this.minimized || !this.root.visible) return; this.halo.show(); if (this._haloTimeout) GLib.Source.remove(this._haloTimeout); this._haloTimeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 400, () => { this._haloTimeout = 0; this.halo?.hide(); return GLib.SOURCE_REMOVE; }); }
|
||||
destroy() { if (this._haloTimeout) GLib.Source.remove(this._haloTimeout); this._haloTimeout = 0; this.halo?.destroy(); this.halo = null; this.root.destroy(); }
|
||||
}
|
||||
|
||||
export default class JarvisExtension extends Extension {
|
||||
enable() {
|
||||
this._theme = St.ThemeContext.get_for_stage(global.stage).get_theme(); this._stylesheet = Gio.File.new_for_path(`${this.path}/stylesheet.css`); try { this._theme.load_stylesheet(this._stylesheet); } catch (error) { log(`Jarvis stylesheet unavailable: ${error.message}`); }
|
||||
this.settings = this.getSettings(); this.proxy = new JarvisProxy(); this.overlay = new ArcOverlay(); this.overlay.attach(); this._applyAccessibility();
|
||||
this.settings = this.getSettings();
|
||||
this.proxy = new JarvisProxy();
|
||||
this.popup = new ConversationView({ compact: true });
|
||||
this.osd = new JarvisOsd(); this.osd.attach();
|
||||
this.cu = new ComputerUseChrome(); this.cu.attach();
|
||||
this.session = new SessionPanel(); this.session.attach();
|
||||
this._bindSurface(this.popup);
|
||||
this._bindSurface(this.session.view);
|
||||
this._applyAccessibility();
|
||||
this._removeShellService = installShellService();
|
||||
this._indicator = new PanelMenu.Button(0.0, 'Jarvis QVAC', false); this._indicator.accessible_name = 'Jarvis voice assistant';
|
||||
this._glyph = new St.Label({ text: '◯ Jarvis', style_class: 'jarvis-panel-glyph', y_align: Clutter.ActorAlign.CENTER }); this._glyph.accessible_name = 'Jarvis idle'; this._indicator.add_child(this._glyph);
|
||||
Main.panel.addToStatusArea('jarvis-qvac', this._indicator, 0, 'right');
|
||||
this._indicator.connect('button-press-event', (_actor, event) => { const button = event.get_button(); if (button === 2) { this._call('Arm'); return Clutter.EVENT_STOP; } if (button === 3) { this._buildMenu(); return Clutter.EVENT_STOP; } this.overlay.toggle(); return Clutter.EVENT_STOP; });
|
||||
this.overlay.onTalk = (pressed) => { if (pressed) { this._call('PushToTalk', '(b)', [true]); this._call('Arm'); } else { this._call('PushToTalk', '(b)', [false]); } };
|
||||
this.overlay.onStop = () => this._call('Cancel');
|
||||
this.overlay.onReset = () => { this.overlay.clear(); this.overlay.setNotice('New conversation'); this._call('ResetContext'); };
|
||||
this.overlay.onAsk = (text) => { this.overlay.addRow('U', text); this._call('Ask', '(s)', [text]); };
|
||||
this.overlay.onMode = (mode) => this._call('SetMode', '(s)', [mode]);
|
||||
this.overlay.onSuggestion = (id, payload) => this._call('Ask', '(s)', [payload?.text || payload?.prompt || id]);
|
||||
this.overlay.onConfirm = (jobId, toolCallId, decision) => this._call('Confirm', '(sss)', [jobId, toolCallId, decision]);
|
||||
this._keyName = 'hotkey'; try { Main.wm.addKeybinding(this._keyName, this.settings, Meta.KeyBindingFlags.NONE, Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, () => this.overlay.show(true)); } catch (error) { log(`Jarvis hotkey unavailable: ${error.message}`); }
|
||||
this._settingsChanged = this.settings.connect('changed::accent-color', () => this._applyAccent()); this._applyAccent(); this._connectDaemon(); this.overlay.show(true);
|
||||
try { this._lockChanged = Main.screenShield.connect('locked-changed', () => { if (Main.screenShield.locked) { this.overlay.hide(); this._call('ComputerRevoke'); } }); } catch {}
|
||||
this._mountPopup();
|
||||
this._indicator.connect('button-press-event', (_actor, event) => {
|
||||
const button = event.get_button();
|
||||
if (button === 2) { this._call('Arm'); return Clutter.EVENT_STOP; }
|
||||
return Clutter.EVENT_PROPAGATE;
|
||||
});
|
||||
this._keyName = 'hotkey';
|
||||
try {
|
||||
Main.wm.addKeybinding(this._keyName, this.settings, Meta.KeyBindingFlags.NONE, Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, () => {
|
||||
this._openPopup();
|
||||
this._call('Arm');
|
||||
});
|
||||
} catch (error) { log(`Jarvis hotkey unavailable: ${error.message}`); }
|
||||
this._settingsChanged = this.settings.connect('changed::accent-color', () => this._applyAccent());
|
||||
this._styleChanged = this.settings.connect('changed::overlay-style', () => this._applyLayout());
|
||||
this._applyAccent();
|
||||
this._applyLayout();
|
||||
this._connectDaemon();
|
||||
try { this._lockChanged = Main.screenShield.connect('locked-changed', () => { if (Main.screenShield.locked) { this._closeShell(); this._call('ComputerRevoke'); } }); } catch {}
|
||||
}
|
||||
_bindSurface(view) {
|
||||
view.onTalk = (pressed) => { if (pressed) { this._call('PushToTalk', '(b)', [true]); this._call('Arm'); } else { this._call('PushToTalk', '(b)', [false]); } };
|
||||
view.onStop = () => this._call('Cancel');
|
||||
view.onReset = () => { this._eachView((surface) => { surface.clear(); surface.setNotice('New conversation'); }); this._call('ResetContext'); };
|
||||
view.onAsk = (text) => { this._eachView((surface) => surface.addRow('U', text)); this._call('Ask', '(s)', [text]); };
|
||||
view.onSuggestion = (id, payload) => this._call('Ask', '(s)', [payload?.text || payload?.prompt || id]);
|
||||
view.onConfirm = (jobId, toolCallId, decision) => this._call('Confirm', '(sss)', [jobId, toolCallId, decision]);
|
||||
view.onConfirmShown = () => this._openPopup();
|
||||
view.onExpand = () => this.session.show(true);
|
||||
view.onSettings = () => this._openSettings();
|
||||
view.onMode = (mode) => this._call('SetMode', '(s)', [mode]);
|
||||
}
|
||||
_eachView(fn) { if (this.popup) fn(this.popup); if (this.session?.view) fn(this.session.view); }
|
||||
_mountPopup() {
|
||||
const menu = this._indicator.menu;
|
||||
try {
|
||||
const section = new PopupMenu.PopupMenuSection();
|
||||
section.actor?.add_style_class_name?.('jarvis-menu-item');
|
||||
section.actor.add_child(this.popup.root);
|
||||
menu.addMenuItem(section);
|
||||
} catch {
|
||||
menu.box.add_child(this.popup.root);
|
||||
}
|
||||
menu.actor?.add_style_class_name?.('jarvis-menu');
|
||||
try {
|
||||
menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
||||
const grantItem = new PopupMenu.PopupMenuItem('Grant desktop');
|
||||
grantItem.connect('activate', () => this._call('ComputerGrant', '(b)', [true]));
|
||||
menu.addMenuItem(grantItem);
|
||||
const revokeItem = new PopupMenu.PopupMenuItem('Revoke desktop');
|
||||
revokeItem.connect('activate', () => this._call('ComputerRevoke'));
|
||||
menu.addMenuItem(revokeItem);
|
||||
const settingsItem = new PopupMenu.PopupMenuItem('Settings');
|
||||
settingsItem.connect('activate', () => this._openSettings());
|
||||
menu.addMenuItem(settingsItem);
|
||||
} catch {}
|
||||
this._menuState = menu.connect('open-state-changed', (_menu, open) => { if (!open) this.popup.endTalk(); });
|
||||
}
|
||||
_openPopup() { try { this._indicator.menu.open(); } catch {} }
|
||||
_openSettings() {
|
||||
const uuid = this.uuid;
|
||||
try { this._indicator.menu.close(); } catch {}
|
||||
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 120, () => {
|
||||
this._launchPreferences(uuid);
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
_launchPreferences(uuid) {
|
||||
try {
|
||||
if (Main.extensionManager?.openExtensionPrefs) {
|
||||
Main.extensionManager.openExtensionPrefs(uuid, '', {});
|
||||
return;
|
||||
}
|
||||
} catch (error) { log(`Jarvis preferences manager: ${error.message}`); }
|
||||
try {
|
||||
if (typeof this.openPreferences === 'function') {
|
||||
this.openPreferences();
|
||||
return;
|
||||
}
|
||||
} catch (error) { log(`Jarvis preferences: ${error.message}`); }
|
||||
Gio.DBus.session.call(
|
||||
'org.gnome.Shell.Extensions',
|
||||
'/org/gnome/Shell/Extensions',
|
||||
'org.gnome.Shell.Extensions',
|
||||
'OpenExtensionPrefs',
|
||||
new GLib.Variant('(ssa{sv})', [uuid, '', {}]),
|
||||
null,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
null,
|
||||
(_source, result) => {
|
||||
try { Gio.DBus.session.call_finish(result); }
|
||||
catch (error) {
|
||||
log(`Jarvis preferences dbus: ${error.message}`);
|
||||
try { Gio.Subprocess.new(['gnome-extensions', 'prefs', uuid], Gio.SubprocessFlags.NONE); }
|
||||
catch (err) { this.popup?.setNotice(`Could not open settings: ${shortError(err.message)}`); }
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
_closeShell() {
|
||||
try { this._indicator.menu.close(); } catch {}
|
||||
this.popup.endTalk();
|
||||
this.session.hide();
|
||||
this.osd.hide();
|
||||
this.cu.hide();
|
||||
}
|
||||
_applyLayout() {
|
||||
const expanded = this.settings.get_string('overlay-style') === 'expanded';
|
||||
if (expanded) this.session.show(true);
|
||||
else this.session.hide();
|
||||
}
|
||||
async _connectDaemon() {
|
||||
const proxy = this.proxy;
|
||||
@@ -232,63 +175,96 @@ export default class JarvisExtension extends Extension {
|
||||
await proxy.connect(); if (this.proxy !== proxy) return;
|
||||
this._signals = [
|
||||
['StateChanged', (state) => { this._setState(state); this._refreshVoiceStatus(); }],
|
||||
['ContextReset', () => { this.overlay.clear(); this.overlay.setNotice('New conversation'); }],
|
||||
['WakeHeard', () => {}],
|
||||
['PartialTranscript', (text) => this.overlay.addRow('U', text)],
|
||||
['Token', (text) => this.overlay.token(text)],
|
||||
['Thinking', (text) => this.overlay.updateThinking(text)],
|
||||
['ToolCall', (json) => this.overlay.addToolCall(json)],
|
||||
['ToolResult', (json) => this.overlay.addToolResult(json)],
|
||||
['Reply', (text) => this.overlay.finalizeReply(text)],
|
||||
['SpeakingLevel', (rms) => this.overlay.level(rms)],
|
||||
['ListeningLevel', (rms) => this.overlay.level(rms)],
|
||||
['ChipOffered', (id, label, payload) => this.overlay.addChip(id, label, payload)],
|
||||
['JobProgress', (id, pct, label) => this.overlay.addJob(id, pct, label)],
|
||||
['ComputerStep', (json) => this.overlay.addStep(json)],
|
||||
['ComputerHighlight', (json) => this.overlay.setTarget(json)],
|
||||
['ConfirmationRequired', (tool, args, pattern) => this.overlay.offerConfirm(tool, args, pattern)],
|
||||
['Error', (code, message) => { this.overlay.setNotice(message); this.overlay.finishThinking(); if (coerceText(code) === 'VOICE_UNAVAILABLE') this.overlay.setConnectionStatus('voice-unavailable'); }],
|
||||
['ContextReset', () => { this._eachView((view) => { view.clear(); view.setNotice('New conversation'); }); }],
|
||||
['WakeHeard', () => this.osd.showWake()],
|
||||
['PartialTranscript', (text) => this._eachView((view) => view.addRow('U', text))],
|
||||
['Token', (text) => this._eachView((view) => view.token(text))],
|
||||
['Thinking', (text) => this._eachView((view) => view.updateThinking(text))],
|
||||
['ToolCall', (json) => this._eachView((view) => view.addToolCall(json))],
|
||||
['ToolResult', (json) => this._eachView((view) => view.addToolResult(json))],
|
||||
['Reply', (text) => this._eachView((view) => view.finalizeReply(text))],
|
||||
['SpeakingLevel', () => {}],
|
||||
['ListeningLevel', () => {}],
|
||||
['ChipOffered', (id, label, payload) => this._eachView((view) => view.addChip(id, label, payload))],
|
||||
['JobProgress', (id, pct, label) => this.cu.addJob(id, pct, label)],
|
||||
['ComputerStep', (json) => { this.cu.addStep(json); this._eachView((view) => view.addStep(json)); }],
|
||||
['ComputerHighlight', (json) => this.cu.setTarget(json)],
|
||||
['ConfirmationRequired', (tool, args, pattern) => { this._eachView((view) => view.offerConfirm(tool, args, pattern)); this._openPopup(); }],
|
||||
['Error', (code, message) => { this._eachView((view) => { view.setNotice(message); view.finishThinking(); }); if (coerceText(code) === 'VOICE_UNAVAILABLE') this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }],
|
||||
].map(([name, handler]) => proxy.on(name, handler));
|
||||
proxy.onOwnerChanged = () => { if (this.proxy !== proxy) return; this._syncDaemon(); };
|
||||
await this._syncDaemon();
|
||||
} catch (error) { if (this.proxy !== proxy) return; this.overlay?.setConnectionStatus('offline'); log(`Jarvis daemon unavailable: ${error.message}`); }
|
||||
} catch (error) { if (this.proxy !== proxy) return; this._eachView((view) => view.setConnectionStatus('offline')); log(`Jarvis daemon unavailable: ${error.message}`); }
|
||||
}
|
||||
async _syncDaemon() {
|
||||
if (!this.proxy) return;
|
||||
if (!this.proxy.owned()) { this.overlay?.setConnectionStatus('offline'); return; }
|
||||
if (!this.proxy.owned()) { this._eachView((view) => view.setConnectionStatus('offline')); return; }
|
||||
try {
|
||||
const result = await this.proxy.call('GetState'); if (!this.overlay) return;
|
||||
const result = await this.proxy.call('GetState'); if (!this.popup) return;
|
||||
this._setState(result.deep_unpack()[0]);
|
||||
await this._refreshVoiceStatus();
|
||||
} catch (error) { this.overlay?.setConnectionStatus('offline'); log(`Jarvis daemon unavailable: ${error.message}`); }
|
||||
} catch (error) { this._eachView((view) => view.setConnectionStatus('offline')); log(`Jarvis daemon unavailable: ${error.message}`); }
|
||||
}
|
||||
async _refreshVoiceStatus() {
|
||||
if (!this.proxy?.owned?.() || !this.overlay) return;
|
||||
if (!this.proxy?.owned?.() || !this.popup) return;
|
||||
try {
|
||||
const runtime = await this.proxy.call('GetRuntimeStatus');
|
||||
if (!this.overlay) return;
|
||||
if (!this.popup) return;
|
||||
const status = JSON.parse(runtime.deep_unpack()[0] || '{}');
|
||||
const voice = status.voice;
|
||||
this.overlay.setConnectionStatus(voice ? 'local' : 'voice-unavailable');
|
||||
this._eachView((view) => view.setConnectionStatus(voice ? 'local' : 'voice-unavailable'));
|
||||
if (voice) {
|
||||
const input = voice.asr && voice.capture;
|
||||
this.overlay.status.text = `${voice.tts ? 'SPEECH ON' : 'SPEECH OFF'} · ${input ? (voice.wake ? 'WAKE ON' : 'HOLD TALK') : 'MIC UNAVAILABLE'}`;
|
||||
this.overlay.status.accessible_name = this.overlay.status.text;
|
||||
this.overlay.talk.reactive = Boolean(input); this.overlay.talk.can_focus = Boolean(input);
|
||||
this.overlay.talk.label = input ? 'Hold to talk' : 'Mic unavailable';
|
||||
this._eachView((view) => view.setVoiceStatus({ tts: voice.tts, input, wake: voice.wake }));
|
||||
}
|
||||
} catch { this.overlay?.setConnectionStatus('voice-unavailable'); }
|
||||
} catch { this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }
|
||||
}
|
||||
_call(name, signature, value) {
|
||||
this.proxy.call(name, signature, value).catch((error) => {
|
||||
log(`Jarvis ${name}: ${error.message}`);
|
||||
const fallback = name === 'ResetContext' ? 'Could not reset conversation' : `${name} failed: ${shortError(error.message)}`;
|
||||
this.overlay?.setNotice(fallback);
|
||||
this._eachView((view) => view.setNotice(fallback));
|
||||
});
|
||||
}
|
||||
_setState(state) { const value = safeText(state); this._glyph.text = ({ ARMED: '◯', LISTENING: '◌', THINKING: '◉', SPEAKING: '◎', SLEEPING: '◐' })[value] || '◯'; this._glyph.text += ' Jarvis'; this._glyph.accessible_name = `Jarvis ${value.toLowerCase()}`; this.overlay.setState(value); }
|
||||
_applyAccent() { this.overlay.root.set_style(`--jarvis-accent: ${this.settings.get_string('accent-color')};`); }
|
||||
_applyAccessibility() { try { const desktop = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' }); this.overlay.reducedMotion = desktop.list_keys().includes('enable-animations') && !desktop.get_boolean('enable-animations'); const theme = desktop.list_keys().includes('gtk-theme') ? desktop.get_string('gtk-theme') : ''; if (/high.?contrast/i.test(theme)) this.overlay.root.add_style_class_name('jarvis-high-contrast'); } catch {} this.overlay.root.connect('key-press-event', (_actor, event) => { if (event.get_key_symbol() === Clutter.KEY_Escape) { this._call('Cancel'); this.overlay.hide(); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; }); }
|
||||
_buildMenu() { const menu = this._indicator.menu; menu.removeAll(); for (const [label, action] of [['Open ARC', () => this.overlay.show(true)], ['Talk', () => this._call('Arm')], ['Stop', () => this._call('Cancel')], ['Reset context', () => this._call('ResetContext')], ['Privacy mode', () => this._call('Sleep')], ['Settings', () => this.openPreferences()]]) { const item = new PopupMenu.PopupMenuItem(label); item.connect('activate', action); menu.addMenuItem(item); } menu.open(); }
|
||||
disable() { this._removeShellService?.(); try { Main.screenShield.disconnect(this._lockChanged); } catch {} try { Main.wm.removeKeybinding(this._keyName); } catch {} if (this._settingsChanged) this.settings.disconnect(this._settingsChanged); this._signals?.forEach((id) => this.proxy?.proxy?.disconnect(id)); this.proxy?.close(); this.overlay?.destroy(); this._indicator?.destroy(); if (this._theme && this._stylesheet) { try { this._theme.unload_stylesheet(this._stylesheet); } catch {} } this.overlay = this._indicator = this._glyph = this.proxy = null; }
|
||||
_setState(state) {
|
||||
const value = safeText(state);
|
||||
this._glyph.text = `${GLYPHS[value] || '◯'} Jarvis`;
|
||||
this._glyph.accessible_name = `Jarvis ${value.toLowerCase()}`;
|
||||
this._eachView((view) => view.setState(value));
|
||||
this.osd.setState(value);
|
||||
}
|
||||
_applyAccent() {
|
||||
const style = `--jarvis-accent: ${this.settings.get_string('accent-color')};`;
|
||||
this.popup.root.set_style(style);
|
||||
this.session.root.set_style(style);
|
||||
this.osd.root.set_style(style);
|
||||
this.cu.root.set_style(style);
|
||||
}
|
||||
_applyAccessibility() {
|
||||
try {
|
||||
const desktop = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' });
|
||||
this.popup.reducedMotion = desktop.list_keys().includes('enable-animations') && !desktop.get_boolean('enable-animations');
|
||||
const theme = desktop.list_keys().includes('gtk-theme') ? desktop.get_string('gtk-theme') : '';
|
||||
if (/high.?contrast/i.test(theme)) {
|
||||
this.popup.root.add_style_class_name('jarvis-high-contrast');
|
||||
this.session.root.add_style_class_name('jarvis-high-contrast');
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
disable() {
|
||||
this._removeShellService?.();
|
||||
try { Main.screenShield.disconnect(this._lockChanged); } catch {}
|
||||
try { Main.wm.removeKeybinding(this._keyName); } catch {}
|
||||
if (this._settingsChanged) this.settings.disconnect(this._settingsChanged);
|
||||
if (this._styleChanged) this.settings.disconnect(this._styleChanged);
|
||||
if (this._menuState) try { this._indicator.menu.disconnect(this._menuState); } catch {}
|
||||
this._signals?.forEach((id) => this.proxy?.proxy?.disconnect(id));
|
||||
this.proxy?.close();
|
||||
this.osd?.destroy(); this.cu?.destroy(); this.session?.destroy(); this.popup?.destroy();
|
||||
this._indicator?.destroy();
|
||||
if (this._theme && this._stylesheet) { try { this._theme.unload_stylesheet(this._stylesheet); } catch {} }
|
||||
this.popup = this.session = this.osd = this.cu = this._indicator = this._glyph = this.proxy = null;
|
||||
}
|
||||
}
|
||||
|
||||
export { JarvisProxy, ConversationView, JarvisOsd, ComputerUseChrome, SessionPanel };
|
||||
|
||||
Reference in New Issue
Block a user