import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import GLib from 'gi://GLib'; import St from 'gi://St'; import Clutter from 'gi://Clutter'; import Pango from 'gi://Pango'; 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 text = safeText(value).replace(/\s+/g, ' ').trim(); if (!text || text === '(no output)') return ''; return text.slice(0, 160); }; function wrapLabel(label) { if (label.clutter_text) { 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; } export class ConversationView { constructor({ compact = true, maxRows = compact ? POPUP_ROWS : 40 } = {}) { 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.title = new St.Label({ text: 'Jarvis', style_class: 'jarvis-title', x_align: Clutter.ActorAlign.START }); this.title.accessible_name = 'Jarvis status'; 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.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 · 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 = wrapLabel(new St.Label({ text: '', style_class: 'jarvis-thinking', visible: false, x_expand: true, can_focus: true })); this.thinking.accessible_name = 'Jarvis thinking'; 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: 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.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: 'Hold to talk', style_class: 'jarvis-chip jarvis-talk', reactive: true, can_focus: true }); this.talk.accessible_name = 'Hold to talk'; this.talk.connect('notify::pressed', () => this.onTalk?.(Boolean(this.talk.pressed))); this.talk.connect('button-press-event', () => { this.onTalk?.(true); return Clutter.EVENT_PROPAGATE; }); this.talk.connect('button-release-event', () => { this.onTalk?.(false); return Clutter.EVENT_PROPAGATE; }); this.talk.connect('leave-event', () => { this.onTalk?.(false); return Clutter.EVENT_PROPAGATE; }); this.talk.connect('key-focus-out', () => this.onTalk?.(false)); this.controls.add_child(this.talk); 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.expand = new St.Button({ label: 'Open', style_class: 'jarvis-chip jarvis-chip-quiet', reactive: true, can_focus: true }); this.expand.accessible_name = 'Open conversation'; this._bindChip(this.expand, () => this.onExpand?.()); this.controls.add_child(this.expand); } else { 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.thinkingToggle); this.root.add_child(this.thinking); this.root.add_child(this.notice); this.root.add_child(this.confirm); this.root.add_child(this.scroll); 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.reducedMotion = false; } _bindChip(button, action) { button.connect('clicked', () => action?.()); } 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.chipScroll.visible = false; this.chips.destroy_all_children(); 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 = 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._followConversation(); 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._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); const name = safeText(result.name || 'tool'); const preview = previewToolResult(result.result); this._addToolRow(preview ? `${name}: ${preview}` : `${name} 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 = ''; 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) 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; } setVoiceStatus({ tts, input, wake } = {}) { this.status.text = `${tts ? 'SPEECH ON' : 'SPEECH OFF'} · ${input ? (wake ? 'WAKE ON' : 'HOLD TALK') : 'MIC UNAVAILABLE'}`; this.status.accessible_name = this.status.text; this.talk.reactive = Boolean(input); this.talk.can_focus = Boolean(input); this.talk.label = input ? 'Hold to talk' : 'Mic unavailable'; } setState(state) { const value = STATES.has(state) ? state : 'ARMED'; this._state = value; this.statusLine.text = `${value.toLowerCase()} · Hold Talk to speak`; this.title.text = value === 'SLEEPING' ? 'Jarvis · privacy' : 'Jarvis'; this.status.style_class = `jarvis-local jarvis-state-${value.toLowerCase()}`; } 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 = true; } addStep(json) { try { const step = JSON.parse(json); this.addRow('J', `${step.n ? `${step.n}. ` : ''}${step.action || 'computer step'}`); } catch { this.addRow('J', json); } } endTalk() { this.onTalk?.(false); } destroy() { 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 }); this.root.add_child(this.label); this.root.accessible_name = 'Jarvis status'; this._state = 'ARMED'; this._wake = false; this._timeout = 0; } attach(parent) { 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); this.root.accessible_name = text ? `Jarvis ${text}` : 'Jarvis status'; } _clearTimer() { if (this._timeout) { GLib.Source.remove(this._timeout); this._timeout = 0; } } hide() { this._wake = false; this._clearTimer(); this.root.visible = false; } 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) { try { const step = JSON.parse(json); this.step.text = `${step.n ? `${step.n}. ` : ''}${step.action || 'computer step'}`; } catch { this.step.text = safeText(json); } 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() { this.view = new ConversationView({ compact: false }); 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); this.view.scroll.set_height(Math.max(120, Math.min(280, monitor.height - 360))); } this.root.visible = true; } hide() { this.view.endTalk(); this.root.visible = false; this.minimized = true; } toggle() { this.root.visible ? this.hide() : this.show(true); } destroy() { this.view.destroy(); } }