Files
gnome-jarvis/apps/gnome-extension/[email protected]/ui.js
T
snxraven 599bfe440d
Rolling release / release (push) Failing after 1m46s
Computer Use Updates
2026-09-13 19:30:17 -04:00

752 lines
33 KiB
JavaScript

import * as Main from 'resource:///org/gnome/shell/ui/main.js';
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';
function brandMark(size = 16, directory = '') {
if (typeof Gio === 'undefined' || typeof St.Icon !== 'function' || !directory) return null;
try {
const file = Gio.File.new_for_path(`${directory}/brand/icons/jarvis-mark.svg`);
if (!file.query_exists(null)) return null;
return new St.Icon({
gicon: new Gio.FileIcon({ file }),
icon_size: size,
style_class: 'jarvis-mark',
y_align: Clutter.ActorAlign.CENTER,
});
} catch {
return null;
}
}
export const STATES = new Set(['ARMED', 'LISTENING', 'THINKING', 'SPEAKING', 'SLEEPING']);
export const POPUP_ROWS = 8;
export const SESSION_WIDTH = 420;
export 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;
};
export const safeText = (value) => coerceText(value).replace(/[<>]/g, '');
export const shortError = (value) => safeText(value).split('\n')[0].replace(/\s+/g, ' ').slice(0, 140);
export const previewToolResult = (value) => {
const raw = typeof value === 'string' ? value : coerceText(value);
const text = safeText(raw).replace(/\s+/g, ' ').trim();
if (!text || text === '(no output)') return '';
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
const first = parsed[0] && (parsed[0].title || parsed[0].url || parsed[0].name);
if (first) return parsed.length === 1 ? String(first).slice(0, 80) : `${parsed.length} results · ${String(first).slice(0, 60)}`;
return parsed.length ? `${parsed.length} results` : '';
}
if (parsed && typeof parsed === 'object') {
if (parsed.error) return safeText(parsed.error).slice(0, 160);
if (parsed.ok && parsed.action) return `${prettyToolName(parsed.action)}${parsed.name ? ` · ${parsed.name}` : ''}`.slice(0, 160);
if (parsed.status && parsed.url) return `${parsed.status} ${parsed.url}`.slice(0, 160);
}
} catch {
const status = text.match(/"status"\s*:\s*(\d+)/);
const href = text.match(/"url"\s*:\s*"([^"]+)"/);
if (status) return `${status[1]}${href ? ' ' + href[1] : ''}`.slice(0, 160);
}
return text.slice(0, 160);
};
export const prettyToolName = (value) => safeText(value || 'tool').replace(/_/g, ' ').replace(/\s+/g, ' ').trim() || 'tool';
function wrapLabel(label) {
if (label.clutter_text) {
try { label.clutter_text.single_line_mode = false; } catch {}
label.clutter_text.line_wrap = true;
label.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR;
label.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
}
return label;
}
function actorWidth(actor) {
if (!actor) return 0;
const box = actor.get_allocation_box?.() || actor.allocation;
if (box) {
if (typeof box.get_width === 'function') {
const width = Number(box.get_width()) || 0;
if (width) return width;
}
const x1 = Number(box.x1) || 0;
const x2 = Number(box.x2) || 0;
if (x2 > x1) return x2 - x1;
if (Number(box.width)) return Number(box.width);
}
return Number(actor.width) || 0;
}
export class ConversationView {
constructor({ compact = true, maxRows = compact ? POPUP_ROWS : 40, brandDir = '' } = {}) {
this.compact = compact;
this.maxRows = maxRows;
this.root = new St.BoxLayout({ style_class: compact ? 'jarvis-popup' : 'jarvis-session', vertical: true, reactive: true, can_focus: true, x_expand: true });
this.root.accessible_name = compact ? 'Jarvis voice assistant' : 'Jarvis conversation';
this.header = new St.BoxLayout({ style_class: 'jarvis-popup-header', x_expand: true });
this.mark = brandMark(compact ? 16 : 20, brandDir);
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;
if (this.mark) this.header.add_child(this.mark);
this.header.add_child(this.title);
this.header.add_child(new St.Widget({ x_expand: true }));
this.header.add_child(this.status);
this.settings = new St.Button({ label: 'Settings', style_class: 'jarvis-chip jarvis-chip-quiet jarvis-settings', reactive: true, can_focus: true });
this.settings.accessible_name = 'Open Jarvis settings';
this._bindChip(this.settings, () => this.onSettings?.());
this.header.add_child(this.settings);
this.statusLine = new St.Label({ text: 'armed · tap Listening to speak', style_class: 'jarvis-status-line' });
this.tabs = new St.BoxLayout({ style_class: 'jarvis-tabs', x_expand: true });
this.chatTab = new St.Button({ label: 'Chat', style_class: 'jarvis-tab jarvis-tab-active', reactive: true, can_focus: true });
this.thinkTab = new St.Button({ label: 'Thinking', style_class: 'jarvis-tab', reactive: true, can_focus: true });
this.chatTab.accessible_name = 'Chat tab';
this.thinkTab.accessible_name = 'Thinking tab';
this._bindChip(this.chatTab, () => this.showTab('chat'));
this._bindChip(this.thinkTab, () => this.showTab('thinking'));
this.tabs.add_child(this.chatTab);
this.tabs.add_child(this.thinkTab);
this.notice = new St.Label({ text: '', style_class: 'jarvis-notice', visible: false, x_expand: true });
this.notice.accessible_name = 'Jarvis notice';
this.confirm = new St.BoxLayout({ style_class: 'jarvis-confirm', visible: false, x_expand: true, vertical: compact });
this.confirmLabel = wrapLabel(new St.Label({ text: '', style_class: 'jarvis-confirm-label', x_expand: true }));
this.confirm.add_child(this.confirmLabel);
this.confirmButtons = new St.BoxLayout({ style_class: 'jarvis-confirm-actions' });
for (const [label, decision] of [['Allow', 'allow'], ['Always allow', 'always'], ['Deny', 'deny']]) {
const button = new St.Button({ label, style_class: 'jarvis-chip', reactive: true, can_focus: true });
this._bindChip(button, () => this._answerConfirm(decision));
this.confirmButtons.add_child(button);
}
this.confirm.add_child(this.confirmButtons);
this.scroll = new St.ScrollView({ style_class: compact ? 'jarvis-popup-scroll' : 'jarvis-session-scroll', overlay_scrollbars: false, 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);
const thinkHeight = compact ? 228 : 268;
this.thinkingPane = new St.BoxLayout({
style_class: compact ? 'jarvis-thinking-pane' : 'jarvis-session-thinking-pane',
vertical: true,
x_expand: true,
y_expand: true,
visible: false,
reactive: true,
clip_to_allocation: true,
height: thinkHeight,
});
try { this.thinkingPane.set_height(thinkHeight); } catch {}
try { this.thinkingPane.set_style?.(`height: ${thinkHeight}px;`); } catch {}
try { this.thinkingPane.clip_to_allocation = true; } catch {}
this.thinkingScroll = new St.ScrollView({
style_class: compact ? 'jarvis-thinking-scroll' : 'jarvis-session-thinking',
overlay_scrollbars: false,
x_expand: true,
y_expand: true,
visible: false,
reactive: true,
enable_mouse_scrolling: true,
clip_to_allocation: true,
});
try { this.thinkingScroll.hscrollbar_policy = St.PolicyType.NEVER; this.thinkingScroll.vscrollbar_policy = St.PolicyType.AUTOMATIC; } catch {}
try { this.thinkingScroll.overlay_scrollbars = false; } catch {}
try { this.thinkingScroll.set_height(thinkHeight); } catch {}
try { this.thinkingScroll.clip_to_allocation = true; } catch {}
this.thinkingBox = new St.BoxLayout({ style_class: 'jarvis-thinking-box', vertical: true, x_expand: true, y_expand: false });
this.thinking = wrapLabel(new St.Label({ text: '', style_class: 'jarvis-thinking', x_expand: true, y_expand: false, y_align: Clutter.ActorAlign.START, reactive: true, can_focus: true }));
this.thinking.accessible_name = 'Jarvis thinking';
this.thinkingBox.add_child(this.thinking);
if (typeof this.thinkingScroll.set_child === 'function') this.thinkingScroll.set_child(this.thinkingBox); else this.thinkingScroll.add_child(this.thinkingBox);
this.thinkingPane.add_child(this.thinkingScroll);
try {
if (Clutter.BindConstraint && Clutter.BindCoordinate) {
this.thinkingBox.add_constraint(new Clutter.BindConstraint({
source: this.thinkingPane,
coordinate: Clutter.BindCoordinate.WIDTH,
offset: -8,
}));
}
} catch {}
this.chipScroll = new St.ScrollView({ style_class: 'jarvis-chip-scroll', overlay_scrollbars: true, x_expand: true, visible: false });
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);
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.controls = new St.BoxLayout({ style_class: 'jarvis-controls' });
this.talk = new St.Button({ label: 'Listening', style_class: 'jarvis-chip jarvis-talk jarvis-talk-off', reactive: true, can_focus: true });
this.talk.accessible_name = 'Start listening';
this._bindChip(this.talk, () => this.onListen?.());
this.mute = new St.Button({ label: 'Mute', style_class: 'jarvis-chip jarvis-mute', reactive: true, can_focus: true });
this.mute.accessible_name = 'Mute microphone';
this._bindChip(this.mute, () => this.onMute?.(!this._muted));
this.controls.add_child(this.talk);
this.controls.add_child(this.mute);
this.stop = new St.Button({ label: 'Stop', style_class: 'jarvis-chip jarvis-chip-quiet', reactive: true, can_focus: true });
this._bindChip(this.stop, () => this.onStop?.());
this.reset = new St.Button({ label: 'Reset', style_class: 'jarvis-chip jarvis-chip-quiet', reactive: true, can_focus: true });
this._bindChip(this.reset, () => this.onReset?.());
this.controls.add_child(this.stop);
this.controls.add_child(this.reset);
if (!compact) {
this.close = new St.Button({ label: 'Close', style_class: 'jarvis-chip jarvis-chip-quiet', reactive: true, can_focus: true });
this._bindChip(this.close, () => this.hide());
this.controls.add_child(this.close);
}
this.root.add_child(this.header);
this.root.add_child(this.statusLine);
this.root.add_child(this.tabs);
this.root.add_child(this.notice);
this.root.add_child(this.confirm);
this.root.add_child(this.scroll);
this.root.add_child(this.thinkingPane);
this.root.add_child(this.chipScroll);
this.root.add_child(this.entry);
this.root.add_child(this.controls);
this.streamingReply = false;
this.replyFinalized = false;
this._state = 'ARMED';
this._tab = 'chat';
this._muted = false;
this._activity = '';
this.reducedMotion = false;
this._chatLayoutTimers = [];
this._thinkLayoutTimers = [];
this._bindFollow(this.scroll);
this._bindFollow(this.thinkingScroll);
this._fitThinking();
}
_bindChip(button, action) {
button.connect('clicked', () => action?.());
}
setAssistantName(name) {
const text = safeText(name).slice(0, 32) || 'Jarvis';
this._assistantName = text;
this.title.text = text;
this.title.accessible_name = `${text} status`;
this.root.accessible_name = this.compact ? `${text} voice assistant` : `${text} conversation`;
if (this.settings) this.settings.accessible_name = `Open ${text} settings`;
if (this.notice) this.notice.accessible_name = `${text} notice`;
}
clear() {
this.transcript.destroy_all_children();
this.thinking.text = '';
this.notice.visible = false;
this.confirm.visible = false;
this.chipScroll.visible = false;
this.chips.destroy_all_children();
this.streamingReply = false;
this.replyFinalized = false;
this._activity = '';
this.showTab('chat');
}
setNotice(text) { const body = shortError(text); this.notice.text = body; this.notice.visible = Boolean(body); }
_bindFollow(scroll) {
const adjustment = scroll?.get_vadjustment?.() || scroll?.vadjustment;
scroll._jarvisFollow = true;
adjustment?.connect?.('notify::value', () => {
if (this._pinning || this._destroyed) return;
scroll._jarvisFollow = this._nearBottom(adjustment);
});
for (const signal of ['notify::upper', 'notify::page-size'])
adjustment?.connect?.(signal, () => {
if (scroll._jarvisFollow !== false) this._pinScroll(scroll);
});
scroll.connect('notify::mapped', () => {
if (scroll.mapped) this._followAfterLayout(scroll);
if (scroll === this.thinkingScroll && scroll.mapped) this._fitThinking();
});
if (scroll === this.thinkingScroll) {
scroll.connect('notify::allocation', () => this._fitThinking());
scroll.connect('notify::width', () => this._fitThinking());
}
}
_nearBottom(adjustment) {
const upper = Number(adjustment?.upper) || 0;
const page = Number(adjustment?.page_size) || 0;
const value = Number(adjustment?.value) || 0;
return value >= Math.max(0, upper - page) - 32;
}
_clearLayoutTimers(slot) {
for (const id of this[slot] || []) {
try { GLib.Source.remove(id); } catch {}
}
this[slot] = [];
}
_relayoutPane(scroll) {
try { scroll?.queue_relayout?.(); } catch {}
const child = scroll?.get_child?.() || scroll?.get_first_child?.();
try { child?.queue_relayout?.(); } catch {}
try { child?.get_first_child?.()?.queue_relayout?.(); } catch {}
}
_viewportWidth(scroll) {
const fallback = this.compact ? 292 : 388;
const widths = [
actorWidth(scroll),
actorWidth(this.thinkingPane),
actorWidth(this.root),
];
return widths.find((width) => width >= 160) || fallback;
}
_fitThinking() {
if (this._destroyed) return;
const label = this.thinking;
const text = label?.clutter_text;
if (!label || !text) return;
const width = Math.max(this.compact ? 260 : 360, this._viewportWidth(this.thinkingScroll) - 18);
try { text.single_line_mode = false; } catch {}
try { text.set_single_line_mode?.(false); } catch {}
try { text.line_wrap = true; } catch {}
try { text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; } catch {}
try { text.ellipsize = Pango.EllipsizeMode.NONE; } catch {}
try { text.set_line_wrap?.(true); } catch {}
try { text.set_line_wrap_mode?.(Pango.WrapMode.WORD_CHAR); } catch {}
try { text.set_ellipsize?.(Pango.EllipsizeMode.NONE); } catch {}
for (const actor of [this.thinkingBox, label, text]) {
try { actor.set_width?.(width); } catch {}
try { actor.width = width; } catch {}
}
try {
const layout = text.get_layout?.();
if (layout?.set_width) {
layout.set_width(width * (Pango.SCALE || 1024));
layout.set_wrap?.(Pango.WrapMode.WORD_CHAR);
layout.set_ellipsize?.(Pango.EllipsizeMode.NONE);
}
} catch {}
try { label.queue_relayout?.(); } catch {}
try { this.thinkingBox?.queue_relayout?.(); } catch {}
}
_pinScroll(scroll) {
if (this._destroyed) return;
const adjustment = scroll?.get_vadjustment?.() || scroll?.vadjustment;
if (!adjustment) return;
if (scroll._jarvisFollow === false) return;
const upper = Number(adjustment.upper) || 0;
const page = Number(adjustment.page_size) || 0;
this._pinning = true;
try { adjustment.value = Math.max(0, upper - page); } catch {}
this._pinning = false;
}
_followAfterLayout(scroll) {
if (this._destroyed) return;
const slot = scroll === this.thinkingScroll ? '_thinkLayoutTimers' : '_chatLayoutTimers';
this._relayoutPane(scroll);
if (scroll === this.thinkingScroll) this._fitThinking();
scroll._jarvisFollow = true;
this._pinScroll(scroll);
// Coalesce bursts without postponing the follow on every token. Track the
// source until it runs so teardown never leaves callbacks on dead actors.
if (this[slot].length) return;
this[slot] = [GLib.timeout_add(GLib.PRIORITY_DEFAULT, 0, () => {
this[slot] = [];
if (scroll === this.thinkingScroll) this._fitThinking();
this._pinScroll(scroll);
return GLib.SOURCE_REMOVE;
})];
}
followActive() {
this._followAfterLayout(this._tab === 'thinking' ? this.thinkingScroll : this.scroll);
}
showTab(name) {
this._tab = name === 'thinking' ? 'thinking' : 'chat';
this._applyTab();
}
_applyTab() {
const thinking = this._tab === 'thinking';
this.chatTab.style_class = thinking ? 'jarvis-tab' : 'jarvis-tab jarvis-tab-active';
this.thinkTab.style_class = thinking ? 'jarvis-tab jarvis-tab-active' : 'jarvis-tab';
this.scroll.visible = !thinking;
this.thinkingPane.visible = thinking;
this.thinkingScroll.visible = thinking;
this.chipScroll.visible = !thinking && this.chips.get_n_children() > 0;
this.entry.visible = !thinking;
this._refreshListenButton();
if (thinking) this._fitThinking();
this.followActive();
}
addRow(who, text) {
const body = safeText(text);
const row = wrapLabel(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}`;
this.transcript.add_child(row);
while (this.transcript.get_n_children() > this.maxRows) {
const first = this.transcript.get_first_child();
if (!first) break;
if (typeof this.transcript.remove_child === 'function') this.transcript.remove_child(first);
else first.destroy();
}
this.showTab('chat');
return row;
}
token(text) {
const chunk = safeText(text);
if (!chunk) return;
if (!this.streamingReply && !chunk.trim()) 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.showTab('chat');
}
updateThinking(text) {
const chunk = safeText(text);
if (!chunk) return;
// Map the pane before writing so the wrap width is the viewport, not 0.
this.showTab('thinking');
const next = `${this.thinking.text || ''}${chunk}`;
this.thinking.text = next;
try { this.thinking.set_text?.(next); } catch {}
try { if (this.thinking.clutter_text) this.thinking.clutter_text.text = next; } catch {}
this.thinkingScroll._jarvisFollow = true;
this._fitThinking();
this._followAfterLayout(this.thinkingScroll);
}
toggleThinking() { this.showTab(this._tab === 'thinking' ? 'chat' : 'thinking'); }
finishThinking() { this.showTab('chat'); }
addToolCall(json) {
this.streamingReply = false;
this.replyFinalized = false;
let name = 'tool';
try { name = JSON.parse(json).name || 'tool'; } catch { name = json; }
const pretty = prettyToolName(name);
this._activity = `Using ${pretty}`;
this._refreshStatusLine();
this._setToolActivity(pretty);
}
addToolResult(json) {
this.streamingReply = false;
let name = 'tool';
let preview = '';
let failed = false;
try {
const result = JSON.parse(json);
name = result.name || 'tool';
const payload = result.result;
if (payload && typeof payload === 'object' && payload.error) {
failed = true;
preview = previewToolResult(payload.error);
} else {
preview = previewToolResult(payload);
if (/error|timed out|HTTP \d+/i.test(preview)) failed = true;
}
} catch {
preview = previewToolResult(json);
}
const pretty = prettyToolName(name);
this._setToolActivity(failed && preview ? `${pretty} · ${preview}` : preview ? `${pretty} · ${preview}` : `${pretty} · done`);
if (this._state === 'THINKING') {
this._activity = failed ? `${pretty} failed` : `${pretty} · done`;
this._refreshStatusLine();
}
}
_setToolActivity(text) {
const body = safeText(text);
let row = this.transcript.get_last_child?.();
if (!row || !String(row.style_class || '').includes('jarvis-row-tool')) {
row = wrapLabel(new St.Label({ text: body, style_class: 'jarvis-row jarvis-row-tool', can_focus: true }));
this.transcript.add_child(row);
while (this.transcript.get_n_children() > this.maxRows) {
const first = this.transcript.get_first_child();
if (!first) break;
if (typeof this.transcript.remove_child === 'function') this.transcript.remove_child(first);
else first.destroy();
}
} else {
row.text = body;
}
row.accessible_name = `Activity: ${body}`;
this.showTab('chat');
return row;
}
_addToolRow(text) { return this._setToolActivity(text); }
offerConfirm(tool, argsJson, pattern) {
let jobId = ''; let toolCallId = ''; let detail = safeText(pattern);
try {
const parsed = JSON.parse(argsJson);
jobId = parsed.jobId || '';
toolCallId = parsed.toolCallId || '';
const args = parsed.args || {};
const command = args.command || args.path || args.file || args.url || '';
if (command) detail = String(command).replace(/\s+/g, ' ').slice(0, 80);
} catch {}
this._confirmJob = jobId; this._confirmCall = toolCallId;
this.confirmLabel.text = `Allow ${safeText(tool)}? ${detail}`.trim();
this.confirm.visible = true;
this.onConfirmShown?.();
}
_answerConfirm(decision) { this.confirm.visible = false; this.onConfirm?.(this._confirmJob || '', this._confirmCall || '', decision); }
finalizeReply(text) {
this.finishThinking();
const spoken = safeText(text);
if (this.replyFinalized) {
this.followActive();
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')) {
if (spoken) { row.text = `J ${spoken}`; row.accessible_name = `Jarvis: ${spoken}`; }
this.streamingReply = false;
this.replyFinalized = true;
this.followActive();
return;
}
if (spoken) this.addRow('J', spoken);
this.replyFinalized = true;
this.followActive();
}
setConnectionStatus(kind) {
this._connection = kind;
const labels = { local: 'Local', offline: 'Offline', 'voice-unavailable': 'Voice off' };
this.status.text = labels[kind] || labels.local;
this.status.accessible_name = this.status.text;
this.status.style_class = 'jarvis-local';
}
setVoiceStatus({ tts, input, wake, muted } = {}) {
if (muted != null) this._muted = Boolean(muted);
this._voice = { tts: Boolean(tts), input: Boolean(input), wake: Boolean(wake), muted: this._muted };
this.status.text = this._muted ? 'Muted' : input ? 'Local' : 'Mic off';
this.status.accessible_name = this.status.text;
this.status.style_class = 'jarvis-local';
this._refreshMuteButton();
this._refreshListenButton();
this._refreshStatusLine();
}
setMuted(muted) {
this._muted = Boolean(muted);
if (this._voice) this._voice.muted = this._muted;
this._refreshMuteButton();
this._refreshListenButton();
this._refreshStatusLine();
}
_refreshMuteButton() {
if (!this.mute) return;
this.mute.label = this._muted ? 'Muted' : 'Mute';
this.mute.style_class = this._muted ? 'jarvis-chip jarvis-mute jarvis-mute-active' : 'jarvis-chip jarvis-mute';
this.mute.accessible_name = this._muted ? 'Unmute microphone' : 'Mute microphone';
}
_refreshListenButton() {
if (!this.talk) return;
const listening = !this._muted && this._state === 'LISTENING';
const available = !this._muted && (this._voice?.input !== false);
this.talk.label = 'Listening';
this.talk.reactive = available;
this.talk.can_focus = available;
this.talk.style_class = listening ? 'jarvis-chip jarvis-talk' : 'jarvis-chip jarvis-talk jarvis-talk-off';
this.talk.accessible_name = this._muted ? 'Listening disabled while muted' : listening ? 'Stop listening' : 'Start listening';
}
setState(state) {
const value = STATES.has(state) ? state : 'ARMED';
const changed = this._state !== value;
this._state = value;
// Polling repeats the coarse daemon state while thinking and answer tokens
// alternate. Only a transition may select a pane; content selects it next.
if (changed) {
if (value === 'LISTENING') {
this._activity = '';
this.showTab('chat');
} else if (value === 'THINKING') {
this.showTab('thinking');
} else if (value === 'SPEAKING' || value === 'ARMED') {
this.showTab('chat');
}
}
this._refreshStatusLine();
const name = this._assistantName || 'Jarvis';
this.title.text = this._muted ? `${name} · muted` : value === 'SLEEPING' ? `${name} · privacy` : name;
this._refreshListenButton();
}
_refreshStatusLine() {
const value = this._state;
const voice = this._voice || {};
const bits = [];
if (this._muted) bits.push('muted');
else {
if (voice.tts) bits.push('speech on');
else if (voice.tts === false) bits.push('speech off');
if (voice.input && voice.wake) bits.push('wake on');
else if (voice.input) bits.push('listening ready');
else if (voice.input === false) bits.push('mic off');
}
const extra = bits.length ? ` · ${bits.join(' · ')}` : '';
if (this._muted) this.statusLine.text = `muted · microphone off`;
else if (value === 'LISTENING') this.statusLine.text = `Listening${extra}`;
else if (value === 'SPEAKING') this.statusLine.text = `Speaking${extra}`;
else if (value === 'SLEEPING') this.statusLine.text = `privacy · microphone off${extra}`;
else if (value === 'THINKING') this.statusLine.text = `${this._activity || 'Thinking'}${extra}`;
else this.statusLine.text = `armed · tap Listening to speak${extra}`;
}
addChip(id, label, payload) {
const chip = new St.Button({ label: safeText(label), style_class: 'jarvis-chip jarvis-chip-suggested', reactive: true, can_focus: true });
chip.accessible_name = `Suggested action: ${safeText(label)}`;
chip.connect('clicked', () => this.onSuggestion?.(id, payload));
this.chips.add_child(chip);
this.chipScroll.visible = this._tab !== 'thinking';
}
addStep(json) {
try {
const step = JSON.parse(json);
const action = String(step.action || '');
if (action === 'grant' || action === 'revoke' || action === 'backend') return;
this.addRow('J', `${step.n ? `${step.n}. ` : ''}${action || 'computer step'}`);
} catch { this.addRow('J', json); }
}
endTalk() {}
destroy() {
this._destroyed = true;
this._clearLayoutTimers('_chatLayoutTimers');
this._clearLayoutTimers('_thinkLayoutTimers');
this.root.destroy();
}
}
const PANEL_STATES = {
LISTENING: 'Listening',
SPEAKING: 'Speaking',
THINKING: 'Thinking',
SLEEPING: 'Privacy',
};
export class JarvisOsd {
constructor() {
this.root = new St.BoxLayout({ style_class: 'jarvis-panel-state', visible: false, y_align: Clutter.ActorAlign.CENTER });
this.label = new St.Label({ text: '', style_class: 'jarvis-panel-state-label', y_align: Clutter.ActorAlign.CENTER });
if (this.label.clutter_text) this.label.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
this.root.add_child(this.label);
this.root.accessible_name = 'Jarvis status';
this._name = null;
this._state = 'ARMED';
this._wake = false;
this._timeout = 0;
}
attach(parent, nameLabel) {
this._name = nameLabel || null;
parent?.add_child?.(this.root);
this.hide();
}
setState(state) {
this._state = STATES.has(state) ? state : 'ARMED';
this._wake = false;
this._clearTimer();
this._render();
}
showWake() {
this._wake = true;
this._clearTimer();
this._render();
this._timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1200, () => {
this._timeout = 0;
this._wake = false;
this._render();
return GLib.SOURCE_REMOVE;
});
}
_render() {
const text = this._wake ? 'Wake' : (PANEL_STATES[this._state] || '');
this.label.text = text;
this.root.visible = Boolean(text);
if (this._name) this._name.visible = !this.root.visible;
this.root.accessible_name = text ? `Jarvis ${text}` : 'Jarvis status';
for (const name of ['armed', 'listening', 'speaking', 'thinking', 'sleeping']) {
this.root.remove_style_class_name(`jarvis-state-${name}`);
}
if (this._state) this.root.add_style_class_name(`jarvis-state-${this._state.toLowerCase()}`);
}
_clearTimer() { if (this._timeout) { GLib.Source.remove(this._timeout); this._timeout = 0; } }
hide() {
this._wake = false;
this._clearTimer();
this.root.visible = false;
if (this._name) this._name.visible = true;
}
destroy() { this._clearTimer(); this.root.destroy(); }
}
export class ComputerUseChrome {
constructor() {
this.root = new St.BoxLayout({ style_class: 'jarvis-cu', vertical: true, visible: false, reactive: false });
this.root.accessible_name = 'Jarvis computer use highlight';
this.job = new St.Label({ text: '', style_class: 'jarvis-job', visible: false });
this.target = new St.Label({ text: '', style_class: 'jarvis-target', visible: false });
this.cursor = new St.Label({ text: '', style_class: 'jarvis-agent-cursor', visible: false });
this.step = new St.Label({ text: '', style_class: 'jarvis-cu-step', visible: false });
this.root.add_child(this.job);
this.root.add_child(this.target);
this.root.add_child(this.cursor);
this.root.add_child(this.step);
}
attach() { Main.layoutManager.addChrome(this.root, { affectsStruts: false, trackFullscreen: false }); this.hide(); }
_place() {
const monitor = Main.layoutManager.primaryMonitor;
if (!monitor) return;
const width = Math.min(360, monitor.width - 48);
this.root.set_width(width);
this.root.set_position(monitor.x + 24, monitor.y + 48);
}
_reveal() { this._place(); this.root.visible = true; }
addJob(id, pct, label) { this.job.text = `${safeText(label)} · ${Math.round(Number(pct) * 100)}%`; this.job.visible = true; this._reveal(); }
setTarget(json) { this.target.text = `⌾ ${safeText(json)}`; this.cursor.text = '◎'; this.cursor.visible = true; this.target.visible = true; this._reveal(); }
addStep(json) {
let step = {};
try { step = JSON.parse(json); } catch { return; }
const action = String(step.action || '');
if (action === 'revoke') { this.hide(); return; }
if (action === 'grant' || action === 'backend') return;
this.step.text = `${step.n ? `${step.n}. ` : ''}${action || 'computer step'}`;
this.step.visible = true;
this._reveal();
}
hide() { this.root.visible = false; this.job.visible = false; this.target.visible = false; this.cursor.visible = false; this.step.visible = false; }
destroy() { this.root.destroy(); }
}
export class SessionPanel {
constructor({ brandDir = '' } = {}) {
this.view = new ConversationView({ compact: false, brandDir });
this.root = this.view.root;
this.minimized = true;
}
attach() { Main.layoutManager.addChrome(this.root, { affectsStruts: false, trackFullscreen: false }); this.hide(); }
show(force = false) {
if (this.minimized && !force) return;
this.minimized = false;
const monitor = Main.layoutManager.primaryMonitor;
if (monitor) {
const width = Math.min(SESSION_WIDTH, monitor.width - 48);
this.root.set_width(width);
this.root.set_position(monitor.x + Math.max(24, monitor.width - width - 24), monitor.y + 40);
const chatHeight = Math.max(120, Math.min(280, monitor.height - 360));
this.view.scroll.set_height(chatHeight);
const thinkHeight = chatHeight + 48;
this.view.thinkingPane?.set_height?.(thinkHeight);
this.view.thinkingPane?.set_style?.(`height: ${thinkHeight}px;`);
this.view.thinkingScroll.set_height(thinkHeight);
}
this.root.visible = true;
this.view.followActive();
}
hide() { this.view.endTalk(); this.root.visible = false; this.minimized = true; }
toggle() { this.root.visible ? this.hide() : this.show(true); }
destroy() { this.view.destroy(); }
}