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