feat: Samsung lock screen face widget, voice assistant services, PiP mode and gitignore installers

Add Samsung proprietary Face Widget (lock screen/AOD) with terminal status display.
Add voice interaction services (AgentVoiceInteractionService, RecognitionService) for
digital assistant registration. Add PiP mode with voice/expand actions. Add session-state
proxy, voice transcript routes, window controls component. Ignore installers/ directory.
This commit is contained in:
2026-02-23 20:52:11 -06:00
parent e1aa8b1bdb
commit 65303df96a
35 changed files with 2640 additions and 484 deletions

15
.gitignore vendored
View File

@@ -9,6 +9,9 @@ nul
# Voice recordings (training data) # Voice recordings (training data)
server/recordings/*.webm server/recordings/*.webm
# Installers / APKs
installers/
# Tauri build artifacts # Tauri build artifacts
src-tauri/target/ src-tauri/target/
src-tauri/installers/ src-tauri/installers/
@@ -31,12 +34,24 @@ src-tauri/gen/android/app/src/main/res/*
!src-tauri/gen/android/app/src/main/res/layout/ !src-tauri/gen/android/app/src/main/res/layout/
src-tauri/gen/android/app/src/main/res/layout/* src-tauri/gen/android/app/src/main/res/layout/*
!src-tauri/gen/android/app/src/main/res/layout/widget_transcript.xml !src-tauri/gen/android/app/src/main/res/layout/widget_transcript.xml
!src-tauri/gen/android/app/src/main/res/layout/widget_terminal_item.xml
!src-tauri/gen/android/app/src/main/res/layout/face_widget_lockscreen.xml
!src-tauri/gen/android/app/src/main/res/layout/face_widget_aod.xml
!src-tauri/gen/android/app/src/main/res/xml/ !src-tauri/gen/android/app/src/main/res/xml/
src-tauri/gen/android/app/src/main/res/xml/* src-tauri/gen/android/app/src/main/res/xml/*
!src-tauri/gen/android/app/src/main/res/xml/transcript_widget_info.xml !src-tauri/gen/android/app/src/main/res/xml/transcript_widget_info.xml
!src-tauri/gen/android/app/src/main/res/xml/voice_interaction_service.xml
!src-tauri/gen/android/app/src/main/res/xml/recognition_service.xml
!src-tauri/gen/android/app/src/main/res/values/ !src-tauri/gen/android/app/src/main/res/values/
src-tauri/gen/android/app/src/main/res/values/* src-tauri/gen/android/app/src/main/res/values/*
!src-tauri/gen/android/app/src/main/res/values/strings.xml !src-tauri/gen/android/app/src/main/res/values/strings.xml
!src-tauri/gen/android/app/src/main/res/raw/
src-tauri/gen/android/app/src/main/res/raw/*
!src-tauri/gen/android/app/src/main/res/raw/facewidgets.json
!src-tauri/gen/android/app/src/main/res/drawable/
src-tauri/gen/android/app/src/main/res/drawable/*
!src-tauri/gen/android/app/src/main/res/drawable/face_widget_bg_dark.xml
!src-tauri/gen/android/app/src/main/res/drawable/face_widget_bg_aod.xml
src-tauri/gen/android/keystore.jks src-tauri/gen/android/keystore.jks
# Old frontend Tauri location # Old frontend Tauri location

View File

@@ -11,6 +11,7 @@ import TerminalFabStack from './components/transcript-debug/TerminalFabStack.vue
import PwaInstallBanner from './components/PwaInstallBanner.vue' import PwaInstallBanner from './components/PwaInstallBanner.vue'
import HooksApprovalModal from './components/HooksApprovalModal.vue' import HooksApprovalModal from './components/HooksApprovalModal.vue'
import ServerConfigDialog from './components/ServerConfigDialog.vue' import ServerConfigDialog from './components/ServerConfigDialog.vue'
import WindowControls from './components/WindowControls.vue'
import { useGlobalApproval } from './composables/useGlobalApproval' import { useGlobalApproval } from './composables/useGlobalApproval'
import { initWebMCP, getWebMCP } from './services/webmcp' import { initWebMCP, getWebMCP } from './services/webmcp'
import { initTorch, destroyTorch } from './services/torch' import { initTorch, destroyTorch } from './services/torch'
@@ -20,7 +21,7 @@ import { setResponseControls } from './services/tools/handlers/responseHandlers'
import { useCanvasStore } from './stores/canvas' import { useCanvasStore } from './stores/canvas'
import { useProjectCanvasStore } from './stores/projectCanvas' import { useProjectCanvasStore } from './stores/projectCanvas'
import { useSessionState } from './stores/session-state' import { useSessionState } from './stores/session-state'
import { isTauri } from './lib/tauri' import { isTauri, isMobileTauri, getTauriNotification } from './lib/tauri'
import { useServerConfig } from './stores/server-config' import { useServerConfig } from './stores/server-config'
const route = useRoute() const route = useRoute()
@@ -35,7 +36,7 @@ const showVoice = ref(false)
const showTranscriptDebug = ref(false) const showTranscriptDebug = ref(false)
const showDebugConsole = ref(false) const showDebugConsole = ref(false)
const toolbarVisible = ref(true) const toolbarVisible = ref(true)
const forceWco = ref(false) const forceWco = ref(isTauri && !isMobileTauri())
const debugLogs = ref<Array<{ type: string; message: string; time: string }>>([]) const debugLogs = ref<Array<{ type: string; message: string; time: string }>>([])
// Intercept console.log for debug panel // Intercept console.log for debug panel
@@ -222,6 +223,31 @@ function syncThemeColor() {
} }
onMounted(async () => { onMounted(async () => {
// Bridge for Android widget navigation (called from MainActivity via evaluateJavascript)
;(window as any).__WIDGET_NAVIGATE__ = (route: string) => {
router.push(route)
return true
}
// Bridge for Android voice assistant — opens FloatingTranscriptDebug on the target terminal
;(window as any).__VOICE_OPEN_TERMINAL__ = (ephemeralSessionId: string) => {
const entry = sessionState.terminalRegistry.find(
t => t.ephemeralSessionId === ephemeralSessionId
)
if (entry && transcriptDebugRef.value) {
transcriptDebugRef.value.switchToTerminal(entry.transcriptSessionId)
showTranscriptDebug.value = true
return true
}
// Fallback: open on first terminal
if (sessionState.terminalRegistry.length && transcriptDebugRef.value) {
transcriptDebugRef.value.switchToTerminal(sessionState.terminalRegistry[0].transcriptSessionId)
showTranscriptDebug.value = true
return true
}
return false
}
// Sync Windows titlebar color with CSS variable // Sync Windows titlebar color with CSS variable
syncThemeColor() syncThemeColor()
@@ -287,6 +313,34 @@ onMounted(async () => {
await torchReady await torchReady
}) })
async function sendTestNotification() {
const title = 'Agent UI'
const body = 'Test notification from Agent UI — all platforms!'
if (isTauri) {
try {
const { isPermissionGranted, requestPermission, sendNotification } = await getTauriNotification()
let granted = await isPermissionGranted()
if (!granted) {
const perm = await requestPermission()
granted = perm === 'granted'
}
if (granted) {
sendNotification({ title, body })
}
} catch (e) {
console.warn('[Notification] Tauri plugin failed:', e)
}
} else if ('Notification' in window) {
if (Notification.permission === 'granted') {
new Notification(title, { body })
} else if (Notification.permission !== 'denied') {
const perm = await Notification.requestPermission()
if (perm === 'granted') new Notification(title, { body })
}
}
}
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('mousemove', trackMouse) document.removeEventListener('mousemove', trackMouse)
document.removeEventListener('keydown', handleGlobalKeydown) document.removeEventListener('keydown', handleGlobalKeydown)
@@ -367,6 +421,12 @@ if (serverConfig) {
</svg> </svg>
<span v-if="totalPending > 0" class="approval-count">{{ totalPending }}</span> <span v-if="totalPending > 0" class="approval-count">{{ totalPending }}</span>
</button> </button>
<button class="refresh-btn" @click="sendTestNotification" title="Test notification">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
</button>
<button class="refresh-btn" @click="hardRefresh" title="Hard refresh (Ctrl+F5)"> <button class="refresh-btn" @click="hardRefresh" title="Hard refresh (Ctrl+F5)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/> <path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/>
@@ -375,6 +435,7 @@ if (serverConfig) {
</button> </button>
<span class="wco-dot" :class="{ on: forceWco }" @click="forceWco = !forceWco"></span> <span class="wco-dot" :class="{ on: forceWco }" @click="forceWco = !forceWco"></span>
<TorchButton /> <TorchButton />
<WindowControls />
</div> </div>
</header> </header>
<main class="app-main"> <main class="app-main">
@@ -503,9 +564,9 @@ if (serverConfig) {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
padding-top: calc(0.5rem + env(safe-area-inset-top, 0px)); padding-top: calc(0.5rem + var(--sat, env(safe-area-inset-top, 0px)));
padding-left: calc(1rem + env(safe-area-inset-left, 0px)); padding-left: calc(1rem + var(--sal, env(safe-area-inset-left, 0px)));
padding-right: calc(1rem + env(safe-area-inset-right, 0px)); padding-right: calc(1rem + var(--sar, env(safe-area-inset-right, 0px)));
background: var(--bg-primary); background: var(--bg-primary);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
flex-shrink: 0; flex-shrink: 0;
@@ -537,6 +598,7 @@ if (serverConfig) {
min-height: 32px; min-height: 32px;
max-height: 32px; max-height: 32px;
padding: 0 0.5rem; padding: 0 0.5rem;
padding-right: 0;
border-bottom: none; border-bottom: none;
overflow: visible; overflow: visible;
} }

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { isTauri, isMobileTauri, getTauriWindow } from '../lib/tauri'
const isDesktopTauri = isTauri && !isMobileTauri()
const isMaximized = ref(false)
let unlisten: (() => void) | null = null
async function initWindowState() {
if (!isDesktopTauri) return
try {
const win = await getTauriWindow()
isMaximized.value = await win.isMaximized()
unlisten = await win.onResized(async () => {
isMaximized.value = await win.isMaximized()
})
} catch (e) {
console.warn('[WindowControls] init failed:', e)
}
}
async function minimizeWindow() {
try {
const win = await getTauriWindow()
await win.minimize()
} catch (e) {
console.warn('[WindowControls] minimize failed:', e)
}
}
async function toggleMaximize() {
try {
const win = await getTauriWindow()
await win.toggleMaximize()
} catch (e) {
console.warn('[WindowControls] toggleMaximize failed:', e)
}
}
async function closeWindow() {
try {
const win = await getTauriWindow()
await win.close()
} catch (e) {
console.warn('[WindowControls] close failed:', e)
}
}
onMounted(() => {
initWindowState()
})
onUnmounted(() => {
if (unlisten) {
unlisten()
unlisten = null
}
})
</script>
<template>
<div v-if="isDesktopTauri" class="window-controls">
<button class="wc-btn wc-minimize" @click="minimizeWindow" title="Minimize">
<svg width="10" height="1" viewBox="0 0 10 1">
<rect width="10" height="1" fill="currentColor" />
</svg>
</button>
<button class="wc-btn wc-maximize" @click="toggleMaximize" :title="isMaximized ? 'Restore' : 'Maximize'">
<svg v-if="!isMaximized" width="10" height="10" viewBox="0 0 10 10">
<rect x="0.5" y="0.5" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1" />
</svg>
<svg v-else width="10" height="10" viewBox="0 0 10 10">
<rect x="2.5" y="0.5" width="7" height="7" fill="none" stroke="currentColor" stroke-width="1" />
<rect x="0.5" y="2.5" width="7" height="7" fill="var(--bg-primary, #0f0f14)" stroke="currentColor" stroke-width="1" />
</svg>
</button>
<button class="wc-btn wc-close" @click="closeWindow" title="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.2" />
<line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.2" />
</svg>
</button>
</div>
</template>
<style scoped>
.window-controls {
display: flex;
align-items: stretch;
height: 100%;
margin-left: 0.5rem;
-webkit-app-region: no-drag;
app-region: no-drag;
}
.wc-btn {
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 100%;
min-height: 32px;
padding: 0;
background: transparent;
border: none;
color: var(--text-secondary, #a1a1aa);
cursor: pointer;
transition: background 0.1s ease, color 0.1s ease;
outline: none;
}
.wc-btn:hover {
background: var(--bg-hover, #1e1e28);
color: var(--text-primary, #e4e4e7);
}
.wc-btn:active {
background: var(--border-color, #2a2a3a);
}
.wc-close:hover {
background: #e81123;
color: #ffffff;
}
.wc-close:active {
background: #c50f1f;
color: #ffffff;
}
</style>

View File

@@ -83,3 +83,8 @@ export async function getTauriClipboard() {
export async function getTauriDialog() { export async function getTauriDialog() {
return import('@tauri-apps/plugin-dialog') return import('@tauri-apps/plugin-dialog')
} }
export async function getTauriWindow() {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
return getCurrentWindow()
}

View File

@@ -1,30 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTranscriptDebug } from '@/composables/transcript-debug' import { useTranscriptDebug } from '@/composables/transcript-debug'
import { useVoiceInput } from '@/composables/useVoiceInput' import { useVoiceInput } from '@/composables/useVoiceInput'
import { SessionSelector, RawJsonViewer, ChatContainer } from '@/components/transcript-debug' import { useSessionState } from '@/stores/session-state'
import { ChatContainer, AquaticBackground, AgentBadge, NewSessionModal } from '@/components/transcript-debug'
import type { AgentName } from '@/types/transcript-debug' import type { AgentName } from '@/types/transcript-debug'
const route = useRoute()
const router = useRouter()
const sessionState = useSessionState()
const { const {
selectedAgent, selectedAgent,
sessions, sessions,
selectedSessionId, selectedSessionId,
rawContent,
conversation, conversation,
loading, loading,
transitioning, transitioning,
transitionError,
error, error,
lineCount,
isRealtime, isRealtime,
sending,
processing, processing,
ephemeral, ephemeral,
terminalReady, terminalReady,
hookMeta, hookMeta,
openTerminals,
activeTerminalSessionId,
init, init,
switchAgent, switchAgent,
selectSession, selectSession,
createNewSession, createNewSession,
switchToTerminal,
closeTerminal,
disconnectRealtime, disconnectRealtime,
sendPrompt sendPrompt
} = useTranscriptDebug() } = useTranscriptDebug()
@@ -48,21 +56,117 @@ const agents: { id: AgentName; label: string }[] = [
{ id: 'claude', label: 'Claude' } { id: 'claude', label: 'Claude' }
] ]
const chatRef = ref<InstanceType<typeof ChatContainer> | null>(null)
const showSelector = ref(false)
const showNewSessionModal = ref(false)
// Readability overlay
const savedOverlay = localStorage.getItem('transcript-overlay-opacity')
const overlayOpacity = ref(savedOverlay !== null ? parseFloat(savedOverlay) : 0.55)
function setOverlayOpacity(val: number) {
overlayOpacity.value = val
localStorage.setItem('transcript-overlay-opacity', String(val))
}
// Input max lines
const savedMaxLines = localStorage.getItem('transcript-input-max-lines')
const inputMaxLines = ref(savedMaxLines !== null ? parseInt(savedMaxLines) : 6)
function setInputMaxLines(val: number) {
inputMaxLines.value = val
localStorage.setItem('transcript-input-max-lines', String(val))
}
// Scroll nav mode
type ScrollNavMode = 'scrollbar' | 'buttons' | 'none'
const savedScrollNav = localStorage.getItem('transcript-scroll-nav') as ScrollNavMode | null
const scrollNavMode = ref<ScrollNavMode>(
savedScrollNav && ['scrollbar', 'buttons', 'none'].includes(savedScrollNav) ? savedScrollNav : 'buttons'
)
function setScrollNavMode(val: ScrollNavMode) {
scrollNavMode.value = val
localStorage.setItem('transcript-scroll-nav', val)
}
// Scroll jump percent
const savedScrollJump = localStorage.getItem('transcript-scroll-jump')
const scrollJumpPercent = ref(savedScrollJump !== null ? parseInt(savedScrollJump) : 50)
function setScrollJumpPercent(val: number) {
scrollJumpPercent.value = val
localStorage.setItem('transcript-scroll-jump', String(val))
}
// Active terminal index label
const terminalLabel = computed(() => {
if (!activeTerminalSessionId.value) return null
const idx = sessionState.terminalRegistry.findIndex(
e => e.transcriptSessionId === activeTerminalSessionId.value
)
return idx >= 0 ? `T${idx + 1}` : null
})
function handleSend(message: string) { function handleSend(message: string) {
voice.clearTranscript() voice.clearTranscript()
sendPrompt(message) sendPrompt(message)
} }
function handleCreateSession() { function handleAgentSwitch(agent: AgentName) {
createNewSession() switchAgent(agent)
} }
onMounted(async () => { function handleSessionSelect(sessionId: string) {
init() selectSession(sessionId)
await voice.init() showSelector.value = false
}
function handleCreateSession() {
showNewSessionModal.value = true
}
async function handleModalCreateNew(agent: AgentName, initialPrompt: string) {
showNewSessionModal.value = false
if (agent !== selectedAgent.value) {
switchAgent(agent)
}
await createNewSession()
if (initialPrompt.trim()) {
sendPrompt(initialPrompt.trim())
}
}
function handleTerminalSwitch(sessionId: string) {
const idx = sessionState.terminalRegistry.findIndex(
e => e.transcriptSessionId === sessionId
)
if (idx >= 0) {
router.push({ name: 'transcript-debug-terminal', params: { terminalIndex: String(idx + 1) } })
}
}
// Resolve terminalIndex param → sessionId and switch
function syncTerminalFromRoute() {
const param = route.params.terminalIndex
if (!param) return
const idx = parseInt(param as string) - 1
const entry = sessionState.terminalRegistry[idx]
if (entry && entry.transcriptSessionId !== activeTerminalSessionId.value) {
switchToTerminal(entry.transcriptSessionId)
}
}
watch(() => route.params.terminalIndex, () => syncTerminalFromRoute())
// Also react when registry populates (it may arrive after mount)
watch(() => sessionState.terminalRegistry.length, () => {
if (route.params.terminalIndex) syncTerminalFromRoute()
}) })
onUnmounted(() => { onMounted(async () => {
await init()
await voice.init()
syncTerminalFromRoute()
})
onBeforeUnmount(() => {
disconnectRealtime() disconnectRealtime()
voice.cleanup() voice.cleanup()
}) })
@@ -70,93 +174,166 @@ onUnmounted(() => {
<template> <template>
<div class="transcript-debug-page"> <div class="transcript-debug-page">
<!-- Header --> <!-- Terminal selector strip -->
<header class="page-header"> <div class="terminal-strip">
<div class="header-left"> <div class="strip-left">
<h2>Transcript Debug</h2> <button
<span :class="['realtime-dot', { connected: isRealtime }]" :title="isRealtime ? 'Realtime: connected' : 'Realtime: disconnected'"> v-for="(entry, idx) in sessionState.terminalRegistry"
<svg width="8" height="8" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4" fill="currentColor"/></svg> :key="entry.transcriptSessionId"
</span> :class="[
'strip-terminal-btn',
{
active: String(idx + 1) === route.params.terminalIndex || (!route.params.terminalIndex && entry.transcriptSessionId === activeTerminalSessionId),
dead: !entry.alive
}
]"
@click="handleTerminalSwitch(entry.transcriptSessionId)"
:title="entry.label"
>
<span :class="['strip-dot', { alive: entry.alive, dead: !entry.alive }]"></span>
T{{ idx + 1 }}
</button>
</div> </div>
<div class="header-selectors"> <div class="strip-right">
<!-- Agent selector --> <AgentBadge
<div class="agent-selector"> v-if="selectedAgent"
<button :agent="selectedAgent"
v-for="a in agents" :connected="!!activeTerminalSessionId"
:key="a.id" :terminals="openTerminals"
:class="['agent-btn', { active: selectedAgent === a.id }]" :active-session-id="activeTerminalSessionId"
@click="switchAgent(a.id)" :model="conversation?.model"
> :version="conversation?.version"
{{ a.label }} @switch-terminal="handleTerminalSwitch"
</button> @close-terminal="closeTerminal"
</div>
<!-- Session selector -->
<SessionSelector
:sessions="sessions"
:selected-id="selectedSessionId"
:loading="loading"
@select="selectSession"
/> />
<span :class="['realtime-dot', { connected: isRealtime }]">
<svg width="6" height="6" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4" fill="currentColor"/></svg>
</span>
<button
@click.stop="chatRef?.collapseAllExceptLast()"
:class="['strip-btn', { active: chatRef?.allCollapsed }]"
title="Collapse all except last"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline v-if="chatRef?.allCollapsed" points="6 9 12 15 18 9"/>
<template v-else><polyline points="18 15 12 9 6 15"/></template>
</svg>
</button>
<button
@click.stop="chatRef?.toggleSelectMode()"
:class="['strip-btn', { active: chatRef?.selectMode }]"
title="Select messages"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline v-if="chatRef?.selectMode" points="20 6 9 17 4 12"/>
<template v-else>
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</template>
</svg>
</button>
<button
@click.stop="showSelector = !showSelector"
:class="['strip-btn', { active: showSelector }]"
title="Agent/Session selector"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
</div> </div>
</header> </div>
<!-- Error --> <!-- Error -->
<div v-if="error" class="error-bar">{{ error }}</div> <div v-if="error" class="error-bar">{{ error }}</div>
<!-- Content --> <!-- Content -->
<div class="content-area"> <div :class="['content-area', { 'selector-open': showSelector }]">
<div v-if="!selectedSessionId" :class="['empty-state', { fading: transitioning }]"> <AquaticBackground />
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" /> <Transition name="terminal-loading">
<line x1="16" y1="13" x2="8" y2="13" /> <div v-if="transitioning" class="loading-overlay">
<line x1="16" y1="17" x2="8" y2="17" /> <div class="loading-spinner" />
<polyline points="10 9 9 9 8 9" /> </div>
</Transition>
<Transition name="terminal-loading">
<div v-if="transitionError" class="error-overlay" @click="transitionError = null">
<div class="error-content">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f87171" stroke-width="2">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span class="error-msg">{{ transitionError }}</span>
<span class="error-hint">Click to dismiss</span>
</div>
</div>
</Transition>
<ChatContainer
ref="chatRef"
v-if="conversation"
:conversation="conversation"
:processing="processing"
:terminal-ready="terminalReady"
:terminal="ephemeral"
:show-selector="showSelector"
:agents="agents"
:selected-agent="selectedAgent"
:sessions="sessions"
:selected-session-id="selectedSessionId"
:sessions-loading="loading"
:voice-mode="voiceMode"
:whisper-status="whisperStatus"
:audio-devices="audioDevices"
:selected-device-id="selectedDeviceId"
:is-recording="voiceRecording"
:voice-transcript="voiceTranscript + voiceInterim"
:last-audio-url="lastAudioUrl"
:is-playing-audio="isPlayingAudio"
:overlay-opacity="overlayOpacity"
:input-max-lines="inputMaxLines"
:scroll-jump-percent="scrollJumpPercent"
:scroll-nav-mode="scrollNavMode"
:hook-permission-mode="hookMeta.permissionMode"
@send="handleSend"
@switch-agent="handleAgentSwitch"
@select-session="handleSessionSelect"
@create-session="handleCreateSession"
@close-session="closeTerminal"
@start-recording="voice.startRecording()"
@stop-recording="voice.stopRecording()"
@set-voice-mode="voice.setMode($event)"
@select-microphone="voice.selectMicrophone($event)"
@play-last-audio="voice.playLastAudio()"
@update:overlay-opacity="setOverlayOpacity"
@update:input-max-lines="setInputMaxLines"
@update:scroll-jump-percent="setScrollJumpPercent"
@update:scroll-nav-mode="setScrollNavMode"
/>
<div v-else-if="!transitioning" class="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg> </svg>
<p>Select a transcript session to begin</p> <span>No active terminal</span>
<span>{{ sessions.length }} sessions available</span> <small v-if="!sessionState.terminalRegistry.length">No terminals registered</small>
</div> <small v-else>Select a terminal above to begin</small>
<div v-else class="split-panels">
<!-- Left: Raw JSONL -->
<div :class="['panel-left', { fading: transitioning }]">
<RawJsonViewer :content="rawContent" />
</div>
<!-- Resize handle -->
<div class="resize-handle"></div>
<!-- Right: Chat -->
<div :class="['panel-right', { fading: transitioning }]">
<ChatContainer
v-if="conversation"
:conversation="conversation"
:processing="processing"
:terminal-ready="terminalReady"
:terminal="ephemeral"
:selected-agent="selectedAgent"
:voice-mode="voiceMode"
:whisper-status="whisperStatus"
:audio-devices="audioDevices"
:selected-device-id="selectedDeviceId"
:is-recording="voiceRecording"
:voice-transcript="voiceTranscript + voiceInterim"
:last-audio-url="lastAudioUrl"
:is-playing-audio="isPlayingAudio"
:hook-permission-mode="hookMeta.permissionMode"
@send="handleSend"
@create-session="handleCreateSession"
@start-recording="voice.startRecording()"
@stop-recording="voice.stopRecording()"
@set-voice-mode="voice.setMode($event)"
@select-microphone="voice.selectMicrophone($event)"
@play-last-audio="voice.playLastAudio()"
/>
</div>
</div> </div>
</div> </div>
<!-- New session modal -->
<NewSessionModal
v-if="showNewSessionModal"
:agents="agents"
:default-agent="selectedAgent"
@create="handleModalCreateNew"
@close="showNewSessionModal = false"
/>
</div> </div>
</template> </template>
@@ -170,29 +347,70 @@ onUnmounted(() => {
color: var(--text-primary); color: var(--text-primary);
} }
.page-header { /* ── Terminal strip ── */
.terminal-strip {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.5rem 1rem; padding: 3px 0.75rem;
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
flex-shrink: 0; flex-shrink: 0;
gap: 1rem; gap: 0.5rem;
min-height: 32px;
} }
.header-left { .strip-left {
display: flex;
align-items: center;
gap: 3px;
}
.strip-terminal-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-primary);
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
font-family: 'Courier New', monospace;
cursor: pointer;
transition: all 0.15s;
}
.strip-terminal-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--accent, #0ea5e9);
}
.strip-terminal-btn.active {
background: var(--accent, #0ea5e9);
color: white;
border-color: var(--accent, #0ea5e9);
}
.strip-terminal-btn.dead:not(.active) {
opacity: 0.5;
}
.strip-dot {
width: 5px;
height: 5px;
border-radius: 50%;
}
.strip-dot.alive { background: #22c55e; }
.strip-dot.dead { background: #ef4444; }
.strip-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex-shrink: 0;
}
.header-left h2 {
font-size: 15px;
font-weight: 600;
margin: 0;
white-space: nowrap;
} }
.realtime-dot { .realtime-dot {
@@ -200,7 +418,6 @@ onUnmounted(() => {
opacity: 0.4; opacity: 0.4;
transition: all 0.3s; transition: all 0.3s;
} }
.realtime-dot.connected { .realtime-dot.connected {
color: #22c55e; color: #22c55e;
opacity: 1; opacity: 1;
@@ -212,50 +429,30 @@ onUnmounted(() => {
50% { filter: drop-shadow(0 0 6px currentColor); } 50% { filter: drop-shadow(0 0 6px currentColor); }
} }
.header-selectors { .strip-btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; justify-content: center;
flex: 1; width: 26px;
min-width: 0; height: 26px;
}
/* Agent selector */
.agent-selector {
display: flex;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
}
.agent-btn {
padding: 0.35rem 0.75rem;
background: transparent;
border: none; border: none;
border-radius: 4px;
background: transparent;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
white-space: nowrap;
} }
.strip-btn:hover {
.agent-btn:not(:last-child) {
border-right: 1px solid var(--border-color);
}
.agent-btn:hover {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); color: var(--text-primary);
} }
.strip-btn.active {
.agent-btn.active { background: var(--accent, #0ea5e9);
background: var(--accent);
color: white; color: white;
} }
/* ── Error bar ── */
.error-bar { .error-bar {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
@@ -265,18 +462,83 @@ onUnmounted(() => {
flex-shrink: 0; flex-shrink: 0;
} }
/* ── Content area (mirrors FloatingTranscriptDebug .content) ── */
.content-area { .content-area {
display: flex;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
} }
.fading { .readability-overlay {
opacity: 0 !important; position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
} }
/* ── Loading / Error overlays ── */
.loading-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(4px);
}
.loading-spinner {
width: 28px;
height: 28px;
border: 2.5px solid rgba(255, 255, 255, 0.15);
border-top-color: rgba(255, 255, 255, 0.7);
border-radius: 50%;
animation: tl-spin 0.7s linear infinite;
}
@keyframes tl-spin { to { transform: rotate(360deg); } }
.terminal-loading-enter-active { transition: opacity 0.15s ease; }
.terminal-loading-leave-active { transition: opacity 0.25s ease; }
.terminal-loading-enter-from,
.terminal-loading-leave-to { opacity: 0; }
.error-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(6px);
cursor: pointer;
}
.error-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
max-width: 80%;
padding: 1.2rem 1.5rem;
background: rgba(30, 30, 30, 0.85);
border: 1px solid rgba(248, 113, 113, 0.3);
border-radius: 10px;
}
.error-msg { font-size: 12px; color: #fca5a5; text-align: center; line-height: 1.4; word-break: break-word; }
.error-hint { font-size: 10px; color: rgba(255, 255, 255, 0.35); }
/* ── Empty state ── */
.empty-state { .empty-state {
transition: opacity 0.15s ease; position: relative;
z-index: 2;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -285,103 +547,218 @@ onUnmounted(() => {
gap: 0.75rem; gap: 0.75rem;
color: var(--text-muted); color: var(--text-muted);
} }
.empty-state svg { opacity: 0.4; }
.empty-state span { font-size: 15px; color: var(--text-secondary); }
.empty-state small { font-size: 13px; }
.empty-state svg { /* ══════════════════════════════════════════════════════════════════════════════
opacity: 0.4; ChatContainer glass-transparent overrides (mirrored from FloatingTranscriptDebug)
══════════════════════════════════════════════════════════════════════════════ */
.content-area :deep(.chat-container) {
background: transparent !important;
border: none !important;
border-radius: 0 !important;
position: relative;
z-index: 1;
flex: 1 !important;
min-height: 0 !important;
} }
.empty-state p { /* Chat header: absolute overlay, floats over messages */
font-size: 15px; .content-area :deep(.chat-header) {
color: var(--text-secondary); position: absolute !important;
margin: 0; top: 0 !important;
left: 0 !important;
right: 0 !important;
z-index: 3 !important;
background: rgba(0, 6, 18, 0.5) !important;
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.06) !important;
padding: 0.3rem 0.6rem !important;
transition: opacity 0.35s ease, transform 0.35s ease !important;
} }
.empty-state span { /* Hidden by default, shown only when selector-open */
font-size: 13px; .content-area:not(.selector-open) :deep(.chat-header) {
opacity: 0 !important;
transform: translateY(-150%) !important;
pointer-events: none !important;
} }
.split-panels { /* Messages: fill entire container, pad for overlaid header / input */
display: flex; .content-area :deep(.messages-scroll) {
flex: 1; background: transparent !important;
overflow: hidden; padding-top: 3.5rem !important;
padding-bottom: 5rem !important;
flex: 1 !important;
} }
.panel-left { /* Bottom overlay: absolute container for lifecycle + input + status */
width: 35%; .content-area :deep(.bottom-overlay) {
min-width: 250px; position: absolute !important;
display: flex; bottom: 0 !important;
flex-direction: column; left: 0 !important;
padding: 0.5rem; right: 0 !important;
padding-right: 0; z-index: 3 !important;
transition: opacity 0.15s ease; display: flex !important;
flex-direction: column !important;
background: rgba(0, 6, 18, 0.5) !important;
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
border-top: 1px solid rgba(255, 255, 255, 0.06) !important;
} }
.resize-handle { .content-area :deep(.status-bar) {
width: 4px; background: transparent !important;
cursor: col-resize; border-top: 1px solid rgba(255, 255, 255, 0.04) !important;
background: transparent; border-bottom: none !important;
flex-shrink: 0; padding: 0.15rem 0.5rem !important;
margin: 0.5rem 2px;
border-radius: 2px;
transition: background 0.15s;
} }
.resize-handle:hover { .content-area :deep(.status-id) {
background: var(--accent); color: rgba(255,255,255,0.35) !important;
} }
.panel-right { .content-area :deep(.status-bar .copy-id-btn) {
flex: 1; color: rgba(255,255,255,0.25) !important;
min-width: 300px; }
display: flex; .content-area :deep(.status-bar .copy-id-btn:hover) {
flex-direction: column; color: rgba(255,255,255,0.6) !important;
padding: 0.5rem;
padding-left: 0;
transition: opacity 0.15s ease;
} }
@media (max-width: 768px) { .content-area :deep(.status-bar .meta-badge) {
.page-header { border-radius: 0 !important;
flex-direction: column; font-family: 'Courier New', monospace !important;
align-items: flex-start; }
gap: 0.5rem;
padding: 0.5rem 0.75rem;
}
.header-left h2 { .content-area :deep(.status-bar .meta-badge.model) {
font-size: 14px; background: rgba(99, 102, 241, 0.1) !important;
} color: #a5b4fc !important;
}
.header-selectors { .content-area :deep(.status-bar .meta-badge.version) {
flex-direction: column; background: rgba(255,255,255,0.04) !important;
align-items: stretch; color: rgba(255,255,255,0.3) !important;
width: 100%; }
gap: 0.5rem;
}
.split-panels { .content-area :deep(.status-bar .meta-count),
flex-direction: column; .content-area :deep(.status-bar .meta-duration) {
} color: rgba(255,255,255,0.2) !important;
}
.panel-left { /* UserInput */
width: 100%; .content-area :deep(.user-input) {
height: 40%; background: transparent !important;
min-width: 0; border-top: 1px solid rgba(255, 255, 255, 0.04) !important;
padding: 0.5rem; padding: 0.3rem 0.5rem !important;
} }
.resize-handle { /* Lifecycle ribbon */
width: 100%; .content-area :deep(.lifecycle-ribbon) {
height: 4px; background: transparent !important;
margin: 2px 0.5rem; pointer-events: none !important;
cursor: row-resize; }
}
.panel-right { /* Input container */
flex: 1; .content-area :deep(.input-container) {
min-width: 0; background: rgba(0, 6, 18, 0.8) !important;
min-height: 0; border-color: rgba(14, 165, 233, 0.1) !important;
padding: 0.5rem; border-radius: 0 !important;
} padding: 0.3rem 0.4rem !important;
}
.content-area :deep(.input-container:focus-within) {
border-color: rgba(14, 165, 233, 0.25) !important;
background: rgba(0, 6, 18, 0.85) !important;
}
.content-area :deep(.input-field) {
color: rgba(255,255,255,0.85);
font-family: 'Courier New', monospace;
font-size: 12px;
}
/* Send button: pixel art daytime ocean */
.content-area :deep(.send-btn) {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28' shape-rendering='crispEdges'%3E%3Crect width='28' height='6' fill='%2387ceeb'/%3E%3Crect y='6' width='28' height='4' fill='%2356b3d9'/%3E%3Crect y='10' width='28' height='4' fill='%232d9abf'/%3E%3Crect y='14' width='28' height='4' fill='%231a7fa5'/%3E%3Crect y='18' width='28' height='4' fill='%23106888'/%3E%3Crect y='22' width='28' height='6' fill='%23c2b280'/%3E%3Crect x='24' y='4' width='3' height='3' fill='%23fffde0' opacity='0.8'/%3E%3Crect x='25' y='3' width='2' height='1' fill='%23fffde0' opacity='0.5'/%3E%3Crect x='2' y='5' width='4' height='2' fill='white' opacity='0.35'/%3E%3Crect x='10' y='4' width='6' height='2' fill='white' opacity='0.25'/%3E%3Crect x='20' y='6' width='3' height='1' fill='white' opacity='0.2'/%3E%3Crect x='5' y='12' width='3' height='2' fill='%23f97316' opacity='0.7'/%3E%3Crect x='4' y='13' width='1' height='1' fill='%23fdba74' opacity='0.5'/%3E%3Crect x='18' y='16' width='2' height='1' fill='%232563eb' opacity='0.5'/%3E%3Crect x='20' y='16' width='1' height='1' fill='%2393c5fd' opacity='0.4'/%3E%3Crect x='8' y='18' width='1' height='1' fill='white' opacity='0.2'/%3E%3Crect x='22' y='12' width='1' height='1' fill='white' opacity='0.2'/%3E%3Crect x='14' y='20' width='1' height='1' fill='white' opacity='0.15'/%3E%3Crect x='3' y='23' width='4' height='3' fill='%23059669' opacity='0.5'/%3E%3Crect x='4' y='22' width='2' height='1' fill='%2310b981' opacity='0.4'/%3E%3Crect x='20' y='24' width='3' height='2' fill='%23059669' opacity='0.4'/%3E%3Crect x='12' y='25' width='3' height='2' fill='%23ec4899' opacity='0.45'/%3E%3Crect x='13' y='24' width='2' height='1' fill='%23f472b6' opacity='0.35'/%3E%3C/svg%3E") !important;
border: none !important;
border-radius: 0 !important;
width: 28px !important;
height: 28px !important;
color: white !important;
image-rendering: pixelated;
box-shadow: none !important;
transition: color 0.15s ease !important;
}
.content-area :deep(.send-btn:hover:not(:disabled)) {
color: #fffde0 !important;
filter: none !important;
box-shadow: 0 0 12px rgba(135, 206, 235, 0.5), 0 0 4px rgba(255, 253, 224, 0.3) !important;
}
.content-area :deep(.send-btn:disabled) {
opacity: 0.25 !important;
filter: saturate(0.3) !important;
}
/* Meta badges */
.content-area :deep(.meta-badge) {
background: rgba(255,255,255,0.04);
color: rgba(255,255,255,0.4);
border-radius: 0;
font-family: 'Courier New', monospace;
}
.content-area :deep(.meta-badge.model) {
background: rgba(99, 102, 241, 0.1);
color: #a5b4fc;
}
.content-area :deep(.meta-cwd),
.content-area :deep(.meta-duration),
.content-area :deep(.meta-count) {
color: rgba(255,255,255,0.25);
}
.content-area :deep(.copy-id-btn) {
color: rgba(255,255,255,0.25);
}
.content-area :deep(.copy-id-btn:hover) {
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.6);
}
/* Select mode */
.content-area :deep(.select-mode-btn) {
border-color: rgba(255,255,255,0.08);
color: rgba(255,255,255,0.35);
border-radius: 0;
}
.content-area :deep(.select-mode-btn:hover) {
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.7);
}
.content-area :deep(.select-mode-btn.active) {
background: rgba(99, 102, 241, 0.25);
border-color: rgba(99, 102, 241, 0.35);
color: #c7d2fe;
}
/* Selection bar */
.content-area :deep(.selection-bar) {
background: rgba(8, 8, 12, 0.92);
border-color: rgba(255,255,255,0.06);
border-radius: 0;
}
.content-area :deep(.selection-count) {
color: rgba(255,255,255,0.4);
font-family: 'Courier New', monospace;
}
.content-area :deep(.selection-btn.toggle-all) {
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.5);
border-radius: 0;
}
.content-area :deep(.message-wrapper.selected) {
background: rgba(99, 102, 241, 0.06);
} }
</style> </style>

View File

@@ -58,6 +58,11 @@ const router = createRouter({
path: '/transcript-debug', path: '/transcript-debug',
name: 'transcript-debug', name: 'transcript-debug',
component: () => import('../pages/TranscriptDebugPage.vue') component: () => import('../pages/TranscriptDebugPage.vue')
},
{
path: '/transcript-debug/:terminalIndex',
name: 'transcript-debug-terminal',
component: () => import('../pages/TranscriptDebugPage.vue')
} }
] ]
}) })

View File

@@ -12,7 +12,10 @@
"tauri:dev": "npx --prefix frontend tauri dev", "tauri:dev": "npx --prefix frontend tauri dev",
"tauri:build": "npx --prefix frontend tauri build", "tauri:build": "npx --prefix frontend tauri build",
"tauri:android:init": "npx --prefix frontend tauri android init", "tauri:android:init": "npx --prefix frontend tauri android init",
"tauri:android:build": "npx --prefix frontend tauri android build" "tauri:android:build": "npx --prefix frontend tauri android build",
"build:android:tauri": "cd src-tauri/gen/android && ./gradlew assembleRelease -x rustBuildArm64Release -x rustBuildArmRelease -x rustBuildX86Release -x rustBuildX86_64Release",
"build:tauri:android": "cd src-tauri/gen/android && ./gradlew assembleRelease -x rustBuildArm64Release -x rustBuildArmRelease -x rustBuildX86Release -x rustBuildX86_64Release",
"start:tauri": "frontend/node_modules/.bin/tauri dev"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.2.1" "concurrently": "^9.2.1"

View File

@@ -28,6 +28,8 @@ import {
handleHooksApprovalRespond, handleHooksApprovalRespondPlan, handleHooksApprovalRespond, handleHooksApprovalRespondPlan,
handleHooksApprovalIgnore, handleHooksApprovalList handleHooksApprovalIgnore, handleHooksApprovalList
} from './hooks-approval' } from './hooks-approval'
import { handleSessionStateProxy } from './session-state-proxy'
import { handleVoiceTranscript } from './voice-transcript'
export async function handleRequest(req: Request): Promise<Response> { export async function handleRequest(req: Request): Promise<Response> {
const url = new URL(req.url) const url = new URL(req.url)
@@ -343,6 +345,17 @@ export async function handleRequest(req: Request): Promise<Response> {
return handleTranscriptDebugRaw(transcriptDebugRawMatch[1], url) return handleTranscriptDebugRaw(transcriptDebugRawMatch[1], url)
} }
// Voice Transcript (Android voice assistant sends transcribed text here)
if (path === '/api/voice-transcript') {
const res = await handleVoiceTranscript(req)
if (res) return res
}
// Session State (proxy to terminal server for external clients like Android widget)
if (path === '/api/session-state' && req.method === 'GET') {
return handleSessionStateProxy(url)
}
// Hooks Approval (long-poll for permission/plan decisions) // Hooks Approval (long-poll for permission/plan decisions)
if (path === '/api/hooks-approval') { if (path === '/api/hooks-approval') {
if (req.method === 'GET') { if (req.method === 'GET') {

View File

@@ -0,0 +1,26 @@
import { jsonResponse, errorResponse } from '../utils/cors'
import { PORT_TERMINAL } from '../config'
/**
* Proxy GET /api/session-state → terminal server.
* Returns session-state + terminal-registry combined,
* so external clients (Android widget) get everything in one call.
*/
export async function handleSessionStateProxy(url: URL): Promise<Response> {
try {
const [stateResp, registryResp] = await Promise.all([
fetch(`http://localhost:${PORT_TERMINAL}/session-state`),
fetch(`http://localhost:${PORT_TERMINAL}/terminal-registry`)
])
const stateData = stateResp.ok ? await stateResp.json() : { agents: {} }
const registryData = registryResp.ok ? await registryResp.json() : { registry: [] }
return jsonResponse({
agents: stateData.agents ?? {},
registry: registryData.registry ?? []
})
} catch (e: any) {
return errorResponse(`Failed to reach terminal server: ${e.message}`, 502)
}
}

View File

@@ -0,0 +1,105 @@
import { jsonResponse } from '../utils/cors'
import { PORT_TERMINAL } from '../config'
export async function handleVoiceTranscript(req: Request): Promise<Response | null> {
if (req.method !== 'POST') return null
try {
const body = await req.json() as { text?: string; timestamp?: string; source?: string }
const { text, timestamp, source } = body
if (!text) {
return jsonResponse({ error: 'Missing "text" field' }, 400)
}
const ts = timestamp || new Date().toISOString()
const src = source || 'android-voice'
console.log(`\n🎙 [VOICE TRANSCRIPT] ────────────────────────`)
console.log(` Source: ${src}`)
console.log(` Time: ${ts}`)
console.log(` Text: "${text}"`)
// Find first alive terminal and send the text as input
const result = await sendToFirstTerminal(text)
console.log(` Terminal: ${result.terminal || 'none found'}`)
console.log(` Status: ${result.sent ? 'sent ✓' : result.error || 'no terminal'}`)
console.log(` ──────────────────────────────────────────────\n`)
return jsonResponse({
ok: true,
received: text,
timestamp: ts,
source: src,
sentToTerminal: result.sent,
terminal: result.terminal || null,
ephemeralSessionId: result.ephemeralSessionId || null
})
} catch (e: any) {
console.error('[voice-transcript] Parse error:', e.message)
return jsonResponse({ error: 'Invalid JSON body' }, 400)
}
}
async function sendToFirstTerminal(text: string): Promise<{ sent: boolean; terminal?: string; ephemeralSessionId?: string; error?: string }> {
try {
// Fetch terminal registry to find alive terminals
const res = await fetch(`http://localhost:${PORT_TERMINAL}/terminal-registry`)
if (!res.ok) {
return { sent: false, error: `registry fetch failed: ${res.status}` }
}
const registry = await res.json() as Array<{
ephemeralSessionId: string
agent: string
label: string
alive: boolean
}>
// Find first alive terminal
const target = registry.find(t => t.alive)
if (!target) {
return { sent: false, error: 'no alive terminals' }
}
// Connect via WebSocket and send the text as input
const wsUrl = `ws://localhost:${PORT_TERMINAL}/ws/terminal?session=${target.ephemeralSessionId}`
return new Promise((resolve) => {
const ws = new WebSocket(wsUrl)
const timeout = setTimeout(() => {
try { ws.close() } catch {}
resolve({ sent: false, terminal: target.ephemeralSessionId, error: 'ws timeout' })
}, 5000)
ws.onopen = () => {
// Send the transcribed text
ws.send(JSON.stringify({ type: 'input', data: text }))
// Send Enter after a short delay
setTimeout(() => {
ws.send(JSON.stringify({ type: 'input', data: '\r' }))
// Close after sending
setTimeout(() => {
clearTimeout(timeout)
ws.close()
resolve({
sent: true,
terminal: `${target.ephemeralSessionId} (${target.agent})`,
ephemeralSessionId: target.ephemeralSessionId
})
}, 150)
}, 80)
}
ws.onerror = (err) => {
clearTimeout(timeout)
resolve({ sent: false, terminal: target.ephemeralSessionId, error: 'ws connection error' })
}
})
} catch (e: any) {
return { sent: false, error: e.message }
}
}

View File

@@ -15,6 +15,12 @@
"store:default", "store:default",
"notification:default", "notification:default",
"clipboard-manager:default", "clipboard-manager:default",
"dialog:default" "dialog:default",
"core:window:default",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-toggle-maximize",
"core:window:allow-close"
] ]
} }

View File

@@ -45,8 +45,9 @@ android {
} }
} }
getByName("release") { getByName("release") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true isMinifyEnabled = false
proguardFiles( proguardFiles(
*fileTree(".") { include("**/*.pro") } *fileTree(".") { include("**/*.pro") }
.plus(getDefaultProguardFile("proguard-android-optimize.txt")) .plus(getDefaultProguardFile("proguard-android-optimize.txt"))
@@ -80,7 +81,8 @@ dependencies {
apply(from = "tauri.build.gradle.kts") apply(from = "tauri.build.gradle.kts")
// Copy APK to src-tauri/installers after build // Copy APK after build: local installers/ + network backup
// Only the universal signed variant gets copied as agent-ui.apk
android.applicationVariants.all { android.applicationVariants.all {
val variant = this val variant = this
variant.outputs.all { variant.outputs.all {
@@ -88,9 +90,36 @@ android.applicationVariants.all {
variant.assembleProvider.get().doLast { variant.assembleProvider.get().doLast {
val src = output.outputFile val src = output.outputFile
if (src.exists()) { if (src.exists()) {
val dest = file("../../../../installers/AgentUI-${variant.versionName}-${variant.name}.apk") val localDir = file("../../../../installers")
src.copyTo(dest, overwrite = true) localDir.mkdirs()
println(">> Copied APK to ${dest.absolutePath}")
// Always save versioned copy per variant
val localVersioned = File(localDir, "AgentUI-${variant.versionName}-${variant.name}.apk")
src.copyTo(localVersioned, overwrite = true)
println(">> Copied APK to ${localVersioned.absolutePath}")
// agent-ui.apk = only the universal signed build
val isUniversal = variant.name.contains("universal", ignoreCase = true)
val isSigned = variant.signingConfig != null
if (isUniversal && isSigned) {
val localFixed = File(localDir, "agent-ui.apk")
src.copyTo(localFixed, overwrite = true)
println(">> Copied universal signed APK to ${localFixed.absolutePath}")
// Network backup
val networkDir = File("\\\\Memoria-1\\ActiveBackupforBusiness\\Nucleo v3\\agent-ui-apk")
try {
if (networkDir.exists() || networkDir.mkdirs()) {
val networkFile = File(networkDir, "agent-ui.apk")
src.copyTo(networkFile, overwrite = true)
println(">> Copied APK to network: ${networkFile.absolutePath}")
} else {
println(">> WARNING: Network path not available: $networkDir")
}
} catch (e: Exception) {
println(">> WARNING: Failed to copy to network: ${e.message}")
}
}
} }
} }
} }

View File

@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="com.samsung.systemui.permission.FACE_WIDGET" />
<!-- AndroidTV support --> <!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" /> <uses-feature android:name="android.software.leanback" android:required="false" />
@@ -16,7 +17,8 @@
android:launchMode="singleTask" android:launchMode="singleTask"
android:label="@string/main_activity_title" android:label="@string/main_activity_title"
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true"
android:supportsPictureInPicture="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -37,7 +39,7 @@
android:resource="@xml/transcript_widget_info" /> android:resource="@xml/transcript_widget_info" />
</receiver> </receiver>
<!-- Voice Command / Share Activity --> <!-- Voice Command / Share / Assist Activity -->
<activity <activity
android:name=".VoiceCommandActivity" android:name=".VoiceCommandActivity"
android:label="Agent UI Voice" android:label="Agent UI Voice"
@@ -48,8 +50,70 @@
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" /> <data android:mimeType="text/plain" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.ASSIST" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VOICE_COMMAND" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity> </activity>
<!-- Digital Assistant Service (enables Samsung side-key, long-press home, etc.) -->
<service
android:name=".AgentVoiceInteractionService"
android:label="@string/app_name"
android:permission="android.permission.BIND_VOICE_INTERACTION"
android:exported="true">
<meta-data
android:name="android.voice_interaction"
android:resource="@xml/voice_interaction_service" />
<intent-filter>
<action android:name="android.service.voice.VoiceInteractionService" />
</intent-filter>
</service>
<service
android:name=".AgentVoiceInteractionSessionService"
android:permission="android.permission.BIND_VOICE_INTERACTION"
android:exported="false" />
<!-- RecognitionService: required by Android to fully register as digital assistant -->
<service
android:name=".AgentRecognitionService"
android:label="@string/app_name"
android:permission="android.permission.BIND_RECOGNITION_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.speech.RecognitionService" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.speech"
android:resource="@xml/recognition_service" />
</service>
<!-- Widget ListView adapter service -->
<service
android:name=".TerminalListWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS"
android:exported="false" />
<!-- Samsung Lock Screen Face Widget -->
<receiver
android:name=".LockScreenWidgetReceiver"
android:permission="com.samsung.systemui.permission.FACE_WIDGET"
android:exported="true">
<intent-filter>
<action android:name="com.samsung.android.intent.action.REQUEST_SERVICEBOX_REMOTEVIEWS" />
</intent-filter>
</receiver>
<meta-data
android:name="com.samsung.systemui.facewidget.executable"
android:resource="@raw/facewidgets" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"

View File

@@ -0,0 +1,41 @@
package com.agentui.desktop
import android.content.Intent
import android.os.Bundle
import android.speech.RecognitionService
import android.speech.SpeechRecognizer
import android.util.Log
/**
* Stub RecognitionService required by Android to register the app
* as a complete voice interaction service / digital assistant.
*
* Without this, the system won't fully recognize the app as an assistant
* and settings will show "None" even when selected.
*
* We delegate actual speech recognition to the system's default
* RecognizerIntent in VoiceCommandActivity.
*/
class AgentRecognitionService : RecognitionService() {
companion object {
private const val TAG = "AgentUI.Recognition"
}
override fun onStartListening(intent: Intent?, callback: Callback?) {
Log.d(TAG, "onStartListening called")
// We don't do real recognition here - VoiceCommandActivity uses
// the system RecognizerIntent directly. This stub satisfies the
// Android requirement that a VoiceInteractionService must have
// an associated RecognitionService.
callback?.error(SpeechRecognizer.ERROR_CLIENT)
}
override fun onCancel(callback: Callback?) {
Log.d(TAG, "onCancel called")
}
override fun onStopListening(callback: Callback?) {
Log.d(TAG, "onStopListening called")
}
}

View File

@@ -0,0 +1,21 @@
package com.agentui.desktop
import android.service.voice.VoiceInteractionService
import android.util.Log
class AgentVoiceInteractionService : VoiceInteractionService() {
override fun onReady() {
super.onReady()
Log.d(TAG, "VoiceInteractionService ready - registered as digital assistant")
}
override fun onShutdown() {
Log.d(TAG, "VoiceInteractionService shutting down")
super.onShutdown()
}
companion object {
private const val TAG = "AgentUI.VIS"
}
}

View File

@@ -0,0 +1,75 @@
package com.agentui.desktop
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.service.voice.VoiceInteractionSession
import android.service.voice.VoiceInteractionSessionService
import android.util.Log
class AgentVoiceInteractionSessionService : VoiceInteractionSessionService() {
override fun onNewSession(args: Bundle?): VoiceInteractionSession {
Log.d(TAG, "onNewSession called")
return AgentSession(this)
}
private class AgentSession(
private val ctx: android.content.Context
) : VoiceInteractionSession(ctx) {
override fun onPrepareShow(args: Bundle?, showFlags: Int) {
super.onPrepareShow(args, showFlags)
// Disable the default VoiceInteractionSession window/UI -
// we launch our own activity instead
setUiEnabled(false)
Log.d(TAG, "onPrepareShow: UI disabled, will launch activity")
}
override fun onShow(args: Bundle?, showFlags: Int) {
super.onShow(args, showFlags)
Log.d(TAG, "onShow called, flags=$showFlags")
val intent = Intent(ctx, VoiceCommandActivity::class.java).apply {
action = Intent.ACTION_VOICE_COMMAND
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// API 30+: startAssistantActivity is the proper method
// for launching activities from a VoiceInteractionSession
startAssistantActivity(intent)
Log.d(TAG, "startAssistantActivity dispatched (API 30+)")
} else {
// API 23-29: use startVoiceActivity
startVoiceActivity(intent)
Log.d(TAG, "startVoiceActivity dispatched (API <30)")
}
} catch (e: Exception) {
Log.e(TAG, "Assistant/Voice activity launch failed, trying startActivity", e)
try {
ctx.startActivity(intent)
Log.d(TAG, "Fallback startActivity dispatched")
} catch (e2: Exception) {
Log.e(TAG, "All launch methods failed", e2)
}
}
// Finish the session - the activity handles everything from here
finish()
}
override fun onHandleAssist(
data: Bundle?,
structure: android.app.assist.AssistStructure?,
content: android.app.assist.AssistContent?
) {
Log.d(TAG, "onHandleAssist called")
}
}
companion object {
private const val TAG = "AgentUI.VISS"
}
}

View File

@@ -0,0 +1,238 @@
package com.agentui.desktop
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import android.view.View
import android.widget.RemoteViews
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
/**
* Samsung Face Widget receiver for the lock screen / AOD.
*
* Samsung's proprietary system sends REQUEST_SERVICEBOX_REMOTEVIEWS
* and expects RESPONSE_SERVICEBOX_REMOTEVIEWS back with RemoteViews
* for both the lock screen ("origin") and AOD ("aod").
*/
class LockScreenWidgetReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "AgentUI.FaceWidget"
private const val ACTION_REQUEST =
"com.samsung.android.intent.action.REQUEST_SERVICEBOX_REMOTEVIEWS"
private const val ACTION_RESPONSE =
"com.samsung.android.intent.action.RESPONSE_SERVICEBOX_REMOTEVIEWS"
private const val PAGE_ID = "agent_ui_transcript"
private val STATUS_COLORS = mapOf(
"idle" to 0xFF6b7280.toInt(),
"thinking" to 0xFF60a5fa.toInt(),
"reading" to 0xFF22d3ee.toInt(),
"writing" to 0xFF4ade80.toInt(),
"toolUse" to 0xFFfbbf24.toInt(),
"permissionRequest" to 0xFFfb923c.toInt(),
"interrupted" to 0xFFf87171.toInt(),
"error" to 0xFFf87171.toInt(),
"sessionStart" to 0xFF60a5fa.toInt(),
"sessionEnd" to 0xFF6b7280.toInt()
)
// Dimmed versions for AOD
private val AOD_STATUS_COLORS = mapOf(
"idle" to 0xFF3b3f47.toInt(),
"thinking" to 0xFF304f7a.toInt(),
"reading" to 0xFF116670.toInt(),
"writing" to 0xFF256b40.toInt(),
"toolUse" to 0xFF7a5f12.toInt(),
"permissionRequest" to 0xFF7a491e.toInt(),
"interrupted" to 0xFF7a3838.toInt(),
"error" to 0xFF7a3838.toInt(),
"sessionStart" to 0xFF304f7a.toInt(),
"sessionEnd" to 0xFF3b3f47.toInt()
)
private val client = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_REQUEST) return
val pageId = intent.getStringExtra("pageId")
if (pageId != PAGE_ID) return
Log.d(TAG, "Face widget update requested for pageId=$pageId")
// Fetch data on a background thread to avoid ANR
val pendingResult = goAsync()
Thread {
try {
val terminals = fetchTerminals(context)
val lockViews = buildLockScreenViews(context, terminals)
val aodViews = buildAodViews(context, terminals)
val response = Intent(ACTION_RESPONSE).apply {
setPackage("com.android.systemui")
putExtra("package", context.packageName)
putExtra("pageId", PAGE_ID)
putExtra("show", true)
putExtra("origin", lockViews)
putExtra("aod", aodViews)
}
context.sendBroadcast(response)
Log.d(TAG, "Face widget response sent with ${terminals.size} terminals")
} catch (e: Exception) {
Log.e(TAG, "Failed to build face widget", e)
// Send empty response so widget doesn't get stuck
val response = Intent(ACTION_RESPONSE).apply {
setPackage("com.android.systemui")
putExtra("package", context.packageName)
putExtra("pageId", PAGE_ID)
putExtra("show", true)
putExtra("origin", buildEmptyLockScreenViews(context))
putExtra("aod", buildEmptyAodViews(context))
}
context.sendBroadcast(response)
} finally {
pendingResult.finish()
}
}.start()
}
private fun buildLockScreenViews(
context: Context,
terminals: List<FaceWidgetTerminal>
): RemoteViews {
val views = RemoteViews(context.packageName, R.layout.face_widget_lockscreen)
val statusText = if (terminals.isEmpty()) "offline"
else "${terminals.size} agent${if (terminals.size > 1) "s" else ""}"
views.setTextViewText(R.id.fw_status, statusText)
if (terminals.isEmpty()) {
views.setViewVisibility(R.id.fw_empty, View.VISIBLE)
} else {
views.setViewVisibility(R.id.fw_empty, View.GONE)
}
// Lockscreen terminal slot IDs
val slotIds = listOf(
Triple(R.id.fw_terminal_1, R.id.fw_dot_1, R.id.fw_name_1),
Triple(R.id.fw_terminal_2, R.id.fw_dot_2, R.id.fw_name_2),
Triple(R.id.fw_terminal_3, R.id.fw_dot_3, R.id.fw_name_3)
)
for (i in slotIds.indices) {
val (container, dot, name) = slotIds[i]
if (i < terminals.size) {
val t = terminals[i]
views.setViewVisibility(container, View.VISIBLE)
views.setTextColor(dot, t.statusColor)
views.setTextViewText(name, "T${t.index} ${t.agent} ${t.status}")
} else {
views.setViewVisibility(container, View.GONE)
}
}
return views
}
private fun buildAodViews(
context: Context,
terminals: List<FaceWidgetTerminal>
): RemoteViews {
val views = RemoteViews(context.packageName, R.layout.face_widget_aod)
if (terminals.isEmpty()) {
views.setViewVisibility(R.id.fw_aod_empty, View.VISIBLE)
} else {
views.setViewVisibility(R.id.fw_aod_empty, View.GONE)
}
val slotIds = listOf(
Triple(R.id.fw_aod_terminal_1, R.id.fw_aod_dot_1, R.id.fw_aod_name_1),
Triple(R.id.fw_aod_terminal_2, R.id.fw_aod_dot_2, R.id.fw_aod_name_2),
Triple(R.id.fw_aod_terminal_3, R.id.fw_aod_dot_3, R.id.fw_aod_name_3)
)
for (i in slotIds.indices) {
val (container, dot, name) = slotIds[i]
if (i < terminals.size) {
val t = terminals[i]
views.setViewVisibility(container, View.VISIBLE)
val aodColor = AOD_STATUS_COLORS[t.status] ?: AOD_STATUS_COLORS["idle"]!!
views.setTextColor(dot, aodColor)
views.setTextViewText(name, "T${t.index} ${t.agent}")
} else {
views.setViewVisibility(container, View.GONE)
}
}
return views
}
private fun buildEmptyLockScreenViews(context: Context): RemoteViews {
return RemoteViews(context.packageName, R.layout.face_widget_lockscreen)
}
private fun buildEmptyAodViews(context: Context): RemoteViews {
return RemoteViews(context.packageName, R.layout.face_widget_aod)
}
private fun fetchTerminals(context: Context): List<FaceWidgetTerminal> {
val apiBase = ServerConfig.apiBaseUrl(context) ?: return emptyList()
try {
val url = "$apiBase/session-state"
val req = Request.Builder().url(url).build()
val resp = client.newCall(req).execute()
if (!resp.isSuccessful) return emptyList()
val json = JSONObject(resp.body?.string() ?: "{}")
val registry = json.optJSONArray("registry") ?: return emptyList()
val agents = json.optJSONObject("agents")
val result = mutableListOf<FaceWidgetTerminal>()
for (i in 0 until minOf(registry.length(), 3)) { // Max 3 for face widget
val entry = registry.getJSONObject(i)
val agentName = entry.optString("agent", "")
val alive = entry.optBoolean("alive", false)
val agentState = agents?.optJSONObject(agentName)
val status = agentState?.optString("status", if (alive) "idle" else "closed")
?: if (alive) "idle" else "closed"
val statusColor = STATUS_COLORS[status] ?: STATUS_COLORS["idle"]!!
result.add(
FaceWidgetTerminal(
index = i + 1,
agent = agentName,
status = status,
statusColor = statusColor
)
)
}
return result
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch terminals for face widget", e)
return emptyList()
}
}
private data class FaceWidgetTerminal(
val index: Int,
val agent: String,
val status: String,
val statusColor: Int
)
}

View File

@@ -1,40 +1,279 @@
package com.agentui.desktop package com.agentui.desktop
import android.app.PendingIntent
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.res.Configuration
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.util.Rational
import android.webkit.WebView
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import org.json.JSONObject import androidx.core.view.ViewCompat
import java.io.File import androidx.core.view.WindowInsetsCompat
class MainActivity : TauriActivity() { class MainActivity : TauriActivity() {
companion object {
private const val ACTION_PIP_MIC = "com.agentui.desktop.PIP_MIC"
private const val ACTION_PIP_EXPAND = "com.agentui.desktop.PIP_EXPAND"
private const val PIP_REQUEST_CODE_MIC = 2001
private const val PIP_REQUEST_CODE_EXPAND = 2002
}
private var pendingRoute: String? = null
private var pendingVoiceTerminal: String? = null
private val pipReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_PIP_MIC -> {
Log.d("AgentUI", "PiP mic button pressed")
// Launch voice command from PiP
val voiceIntent = Intent(context, VoiceCommandActivity::class.java).apply {
action = Intent.ACTION_VOICE_COMMAND
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(voiceIntent)
}
ACTION_PIP_EXPAND -> {
Log.d("AgentUI", "PiP expand button pressed")
// Bring app to foreground full-screen
val expandIntent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
startActivity(expandIntent)
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
syncServerUrlToPrefs() syncServerUrlToPrefs()
injectSafeAreaInsets()
handleWidgetIntent(intent)
handleVoiceIntent(intent)
registerPipReceiver()
}
override fun onDestroy() {
super.onDestroy()
try { unregisterReceiver(pipReceiver) } catch (_: Exception) {}
}
private fun registerPipReceiver() {
val filter = IntentFilter().apply {
addAction(ACTION_PIP_MIC)
addAction(ACTION_PIP_EXPAND)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(pipReceiver, filter, RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(pipReceiver, filter)
}
}
/**
* Enter PiP when user leaves the app (home button / swipe).
*/
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// Only enter PiP via voice command flow, not on regular minimize
}
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
Log.d("AgentUI", "PiP mode changed: $isInPictureInPictureMode")
}
private fun enterPipIfSupported() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
val actions = buildPipActions()
val builder = PictureInPictureParams.Builder()
.setAspectRatio(Rational(9, 16))
.setActions(actions)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(false)
builder.setSeamlessResizeEnabled(true)
}
enterPictureInPictureMode(builder.build())
} catch (e: Exception) {
Log.w("AgentUI", "Failed to enter PiP: $e")
}
}
}
private fun buildPipActions(): List<RemoteAction> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return emptyList()
val actions = mutableListOf<RemoteAction>()
// Mic button — launch voice command
val micIntent = PendingIntent.getBroadcast(
this, PIP_REQUEST_CODE_MIC,
Intent(ACTION_PIP_MIC).setPackage(packageName),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
actions.add(RemoteAction(
Icon.createWithResource(this, android.R.drawable.ic_btn_speak_now),
"Voice", "Send voice command",
micIntent
))
// Expand button — bring app to foreground
val expandIntent = PendingIntent.getBroadcast(
this, PIP_REQUEST_CODE_EXPAND,
Intent(ACTION_PIP_EXPAND).setPackage(packageName),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
actions.add(RemoteAction(
Icon.createWithResource(this, android.R.drawable.ic_menu_view),
"Open", "Open full app",
expandIntent
))
return actions
}
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
handleWidgetIntent(intent)
handleVoiceIntent(intent)
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
syncServerUrlToPrefs() syncServerUrlToPrefs()
pendingRoute?.let { route ->
pendingRoute = null
navigateWebView(route)
}
}
private fun handleWidgetIntent(intent: android.content.Intent?) {
val terminalIndex = intent?.getIntExtra("terminalIndex", -1) ?: -1
if (terminalIndex > 0) {
val route = "/transcript-debug/$terminalIndex"
Log.d("AgentUI", "Widget click → navigate to $route")
pendingRoute = route
navigateWebView(route)
}
}
private fun handleVoiceIntent(intent: android.content.Intent?) {
if (intent?.action == "com.agentui.desktop.VOICE_TERMINAL") {
val sessionId = intent.getStringExtra("ephemeralSessionId") ?: return
if (sessionId.isNotEmpty()) {
Log.d("AgentUI", "Voice intent → open terminal $sessionId")
pendingVoiceTerminal = sessionId
// Don't call openVoiceTerminal here — WebView may not exist yet.
// Instead, poll until the WebView is ready.
pollForWebViewAndOpenTerminal(sessionId, 0)
}
}
} }
/** /**
* Reads the Tauri plugin-store settings.json and syncs serverUrl * Retry finding the WebView up to ~3 seconds (15 attempts x 200ms).
* to SharedPreferences so native components (widget, voice) can use it. * Once found, inject JS to open floating transcript and enter PiP.
*/ */
private fun syncServerUrlToPrefs() { private fun pollForWebViewAndOpenTerminal(ephemeralSessionId: String, attempt: Int) {
try { if (attempt > 15) {
val storeFile = File(filesDir, "app_tauri-plugin-store/settings.json") Log.w("AgentUI", "Gave up waiting for WebView after ${attempt} attempts")
if (!storeFile.exists()) return pendingVoiceTerminal = null
return
}
val json = JSONObject(storeFile.readText()) val webView = try { findWebView(window.decorView) } catch (_: Exception) { null }
val serverUrl = json.optString("serverUrl", "")
if (serverUrl.isNotEmpty()) { if (webView != null) {
ServerConfig.setServerUrl(this, serverUrl) val js = "window.__VOICE_OPEN_TERMINAL__ && window.__VOICE_OPEN_TERMINAL__('$ephemeralSessionId')"
Log.d("AgentUI", "Synced serverUrl to prefs: $serverUrl") webView.evaluateJavascript(js, null)
} pendingVoiceTerminal = null
} catch (e: Exception) { Log.d("AgentUI", "Voice terminal JS dispatched (attempt $attempt): $ephemeralSessionId")
Log.w("AgentUI", "Failed to sync server URL", e)
// Enter PiP after the WebView has time to render
webView.postDelayed({ enterPipIfSupported() }, 500)
} else {
Log.d("AgentUI", "WebView not ready (attempt $attempt), retrying in 200ms")
window.decorView.postDelayed({
pollForWebViewAndOpenTerminal(ephemeralSessionId, attempt + 1)
}, 200)
} }
} }
private fun navigateWebView(route: String) {
try {
val decorView = window.decorView
val webView = findWebView(decorView)
if (webView != null) {
val js = "window.__WIDGET_NAVIGATE__ && window.__WIDGET_NAVIGATE__('$route') || (window.location.href = '$route')"
webView.evaluateJavascript(js, null)
pendingRoute = null
}
} catch (e: Exception) {
Log.w("AgentUI", "Failed to navigate WebView: $e")
}
}
private fun injectSafeAreaInsets() {
val decorView = window.decorView
ViewCompat.setOnApplyWindowInsetsListener(decorView) { view, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val density = resources.displayMetrics.density
val topPx = systemBars.top
val bottomPx = systemBars.bottom
val leftPx = systemBars.left
val rightPx = systemBars.right
val topDp = topPx / density
val bottomDp = bottomPx / density
val leftDp = leftPx / density
val rightDp = rightPx / density
val js = """
document.documentElement.style.setProperty('--sat', '${topDp}px');
document.documentElement.style.setProperty('--sab', '${bottomDp}px');
document.documentElement.style.setProperty('--sal', '${leftDp}px');
document.documentElement.style.setProperty('--sar', '${rightDp}px');
""".trimIndent()
try {
val webView = view.findViewWithTag<WebView>("tauri_webview")
?: view.findViewById<WebView>(android.R.id.content)?.let {
findWebView(it)
}
webView?.evaluateJavascript(js, null)
Log.d("AgentUI", "Injected safe-area: top=${topDp}dp bottom=${bottomDp}dp")
} catch (e: Exception) {
Log.w("AgentUI", "Failed to inject safe-area insets: $e")
}
ViewCompat.onApplyWindowInsets(view, insets)
}
}
private fun findWebView(view: android.view.View): WebView? {
if (view is WebView) return view
if (view is android.view.ViewGroup) {
for (i in 0 until view.childCount) {
val found = findWebView(view.getChildAt(i))
if (found != null) return found
}
}
return null
}
private fun syncServerUrlToPrefs() {
val url = ServerConfig.getServerUrl(this)
Log.d("AgentUI", "syncServerUrlToPrefs: resolved url=$url")
}
} }

View File

@@ -1,21 +1,149 @@
package com.agentui.desktop package com.agentui.desktop
import android.content.Context import android.content.Context
import android.util.Log
import org.json.JSONObject
import java.io.File
object ServerConfig { object ServerConfig {
private const val TAG = "ServerConfig"
private const val PREFS_NAME = "agent_ui_config" private const val PREFS_NAME = "agent_ui_config"
private const val KEY_SERVER_URL = "server_url" private const val KEY_SERVER_URL = "server_url"
fun getServerUrl(context: Context): String? { fun getServerUrl(context: Context): String? {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val stored = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_SERVER_URL, null) .getString(KEY_SERVER_URL, null)
if (!stored.isNullOrEmpty()) {
Log.d(TAG, "Found serverUrl in SharedPreferences: $stored")
return stored
}
// Fallback: search for Tauri store file in multiple possible locations
return readFromTauriStore(context)
} }
fun setServerUrl(context: Context, url: String) { fun setServerUrl(context: Context, url: String) {
val normalized = url.trimEnd('/')
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit() .edit()
.putString(KEY_SERVER_URL, url.trimEnd('/')) .putString(KEY_SERVER_URL, normalized)
.apply() .apply()
Log.d(TAG, "Saved serverUrl to SharedPreferences: $normalized")
}
/**
* Searches for serverUrl in the Tauri plugin-store settings.json.
* Tries multiple possible file locations since the Tauri store path
* can vary by plugin version and platform.
*/
private fun readFromTauriStore(context: Context): String? {
Log.d(TAG, "SharedPreferences empty, searching Tauri store files...")
// Try multiple possible paths where Tauri plugin-store may save settings.json
val possiblePaths = listOf(
File(context.filesDir, "app_tauri-plugin-store/settings.json"),
File(context.filesDir, "settings.json"),
File(context.dataDir, "app_tauri-plugin-store/settings.json"),
File(context.dataDir, "settings.json"),
File(context.filesDir, ".tauri/settings.json"),
File(context.filesDir, "tauri-plugin-store/settings.json")
)
for (file in possiblePaths) {
Log.d(TAG, " Checking: ${file.absolutePath} exists=${file.exists()}")
if (file.exists()) {
try {
val content = file.readText()
Log.d(TAG, " Found store file! Content: ${content.take(200)}")
val url = extractServerUrl(content)
if (url != null) {
setServerUrl(context, url)
Log.d(TAG, " Extracted and cached serverUrl: $url")
return url
}
} catch (e: Exception) {
Log.w(TAG, " Failed to parse: ${file.absolutePath}", e)
}
}
}
// Also search recursively for any settings.json in the data directory
val found = findFile(context.filesDir, "settings.json")
if (found != null) {
Log.d(TAG, " Found via recursive search: ${found.absolutePath}")
try {
val content = found.readText()
Log.d(TAG, " Content: ${content.take(200)}")
val url = extractServerUrl(content)
if (url != null) {
setServerUrl(context, url)
return url
}
} catch (e: Exception) {
Log.w(TAG, " Failed to parse found file", e)
}
}
// Debug: dump directory tree so we can see where files actually are
Log.w(TAG, " Could not find settings.json. Directory tree of filesDir:")
dumpTree(context.filesDir, " ")
Log.w(TAG, " Directory tree of dataDir:")
dumpTree(context.dataDir, " ")
return null
}
/**
* Extracts serverUrl from JSON content.
* Handles both flat format {"serverUrl":"..."} and
* possible wrapped formats.
*/
private fun extractServerUrl(content: String): String? {
try {
val json = JSONObject(content)
// Direct key
val direct = json.optString("serverUrl", "")
if (direct.isNotEmpty()) return direct.trimEnd('/')
// Maybe nested under a "store" or "data" key
for (key in json.keys()) {
val inner = json.optJSONObject(key)
if (inner != null) {
val nested = inner.optString("serverUrl", "")
if (nested.isNotEmpty()) return nested.trimEnd('/')
}
}
} catch (e: Exception) {
Log.w(TAG, "extractServerUrl parse error", e)
}
return null
}
/** Recursively find a file by name */
private fun findFile(dir: File, name: String): File? {
val files = dir.listFiles() ?: return null
for (file in files) {
if (file.isFile && file.name == name) return file
if (file.isDirectory) {
val found = findFile(file, name)
if (found != null) return found
}
}
return null
}
/** Dump directory tree for debugging */
private fun dumpTree(dir: File, indent: String) {
val files = dir.listFiles() ?: return
for (file in files) {
if (file.isDirectory) {
Log.d(TAG, "$indent${file.name}/")
dumpTree(file, "$indent ")
} else {
Log.d(TAG, "$indent${file.name} (${file.length()} bytes)")
}
}
} }
/** e.g. "http://192.168.1.10:4103" */ /** e.g. "http://192.168.1.10:4103" */

View File

@@ -0,0 +1,260 @@
package com.agentui.desktop
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.RemoteViews
import android.widget.RemoteViewsService
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class TerminalListWidgetService : RemoteViewsService() {
override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
return TerminalListFactory(applicationContext)
}
}
data class TerminalItem(
val ephemeralSessionId: String,
val agent: String,
val label: String,
val status: String,
val alive: Boolean,
val hookBadges: String,
val lastUserPrompt: String,
val statusColor: Int,
val terminalIndex: Int // 1-based, maps to /transcript-debug/:terminalIndex
)
class TerminalListFactory(private val context: Context) : RemoteViewsService.RemoteViewsFactory {
companion object {
private const val TAG = "WidgetListFactory"
// Refresh button states
private const val ICON_NORMAL = "\u21BB" // ↻
private const val ICON_LOADING = "\u23F3" // ⏳
private const val ICON_OK = "\u2713" // ✓
private const val ICON_ERROR = "\u26A0" // ⚠
private const val COLOR_NORMAL = 0xFF8888FF.toInt()
private const val COLOR_LOADING = 0xFF60a5fa.toInt()
private const val COLOR_OK = 0xFF4ade80.toInt()
private const val COLOR_ERROR = 0xFFf87171.toInt()
private val client = OkHttpClient.Builder()
.connectTimeout(8, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.build()
private val STATUS_COLORS = mapOf(
"idle" to 0xFF6b7280.toInt(),
"thinking" to 0xFF60a5fa.toInt(),
"reading" to 0xFF22d3ee.toInt(),
"writing" to 0xFF4ade80.toInt(),
"toolUse" to 0xFFfbbf24.toInt(),
"permissionRequest" to 0xFFfb923c.toInt(),
"interrupted" to 0xFFf87171.toInt(),
"error" to 0xFFf87171.toInt(),
"sessionStart" to 0xFF60a5fa.toInt(),
"sessionEnd" to 0xFF6b7280.toInt()
)
private val TOOL_EVENTS = setOf("PreToolUse", "PostToolUse", "PostToolUseFailure")
private val PERM_EVENTS = setOf("PermissionRequest")
private val SESSION_EVENTS = setOf("SessionStart", "UserPromptSubmit", "Stop", "SessionEnd")
}
private var items = listOf<TerminalItem>()
private val mainHandler = Handler(Looper.getMainLooper())
override fun onCreate() {}
override fun onDataSetChanged() {
setRefreshButton(ICON_LOADING, COLOR_LOADING)
val result = fetchTerminals()
if (result == null) {
// Network error — keep previous items, show error on button
setRefreshButton(ICON_ERROR, COLOR_ERROR)
scheduleResetButton(3000)
} else {
items = result
// Brief success flash, then back to normal
setRefreshButton(ICON_OK, COLOR_OK)
scheduleResetButton(1500)
}
}
override fun onDestroy() {
items = emptyList()
}
override fun getCount(): Int = items.size
override fun getViewAt(position: Int): RemoteViews {
val views = RemoteViews(context.packageName, R.layout.widget_terminal_item)
if (position >= items.size) return views
val item = items[position]
views.setTextColor(R.id.item_dot, item.statusColor)
val statusLabel = if (item.alive) item.status else "closed"
views.setTextViewText(R.id.item_name, "T${item.terminalIndex} ${item.agent} $statusLabel")
views.setTextViewText(R.id.item_badges, item.hookBadges)
// Always show the registry label (unique per terminal)
views.setTextViewText(R.id.item_label, item.label)
val fillIntent = Intent().apply {
putExtra("terminalIndex", item.terminalIndex)
putExtra("agent", item.agent)
}
views.setOnClickFillInIntent(R.id.item_root, fillIntent)
return views
}
override fun getLoadingView(): RemoteViews? = null
override fun getViewTypeCount(): Int = 1
override fun getItemId(position: Int): Long = position.toLong()
override fun hasStableIds(): Boolean = false
// ── Refresh button state management ──
/**
* Update just the refresh button via partiallyUpdateAppWidget.
* This doesn't reset the ListView adapter.
*/
private fun setRefreshButton(icon: String, color: Int) {
try {
val views = RemoteViews(context.packageName, R.layout.widget_transcript)
views.setTextViewText(R.id.btn_refresh, icon)
views.setTextColor(R.id.btn_refresh, color)
val mgr = AppWidgetManager.getInstance(context)
val ids = mgr.getAppWidgetIds(
ComponentName(context, TranscriptWidgetProvider::class.java)
)
mgr.partiallyUpdateAppWidget(ids, views)
} catch (e: Exception) {
Log.w(TAG, "Failed to update refresh button", e)
}
}
/**
* Schedule resetting the button back to normal after a delay.
*/
private fun scheduleResetButton(delayMs: Long) {
mainHandler.postDelayed({
setRefreshButton(ICON_NORMAL, COLOR_NORMAL)
}, delayMs)
}
// ── Data fetching ──
/**
* Returns list on success, null on error.
* Items keep the same order as the terminal registry (T1, T2, T3...)
* so the index maps directly to /transcript-debug/:terminalIndex
*/
private fun fetchTerminals(): List<TerminalItem>? {
val apiBase = ServerConfig.apiBaseUrl(context) ?: return emptyList()
try {
val url = "$apiBase/session-state"
val req = Request.Builder().url(url).build()
val resp = client.newCall(req).execute()
if (!resp.isSuccessful) return null
val json = JSONObject(resp.body?.string() ?: "{}")
val registry = json.optJSONArray("registry")
val agents = json.optJSONObject("agents")
if (registry == null || registry.length() == 0) return emptyList()
val result = mutableListOf<TerminalItem>()
for (i in 0 until registry.length()) {
val entry = registry.getJSONObject(i)
val agentName = entry.optString("agent", "")
val ephId = entry.optString("ephemeralSessionId", "")
val label = entry.optString("label", "")
val alive = entry.optBoolean("alive", false)
val terminalIndex = i + 1 // 1-based, maps to /transcript-debug/:terminalIndex
val agentState = agents?.optJSONObject(agentName)
val status = agentState?.optString("status", if (alive) "idle" else "closed")
?: if (alive) "idle" else "closed"
val statusColor = STATUS_COLORS[status] ?: STATUS_COLORS["idle"]!!
val lastUserPrompt = if (agentState != null) extractLastUserPrompt(agentState) else ""
val hookBadges = if (agentState != null) buildBadgeString(agentState) else ""
result.add(
TerminalItem(
ephemeralSessionId = ephId,
agent = agentName,
label = label,
status = status,
alive = alive,
hookBadges = hookBadges,
lastUserPrompt = lastUserPrompt,
statusColor = statusColor,
terminalIndex = terminalIndex
)
)
}
// Keep registry order (don't sort) — index must match T1, T2, T3...
return result
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch terminals", e)
return null
}
}
private fun extractLastUserPrompt(state: JSONObject): String {
val history = state.optJSONArray("hookHistory") ?: return ""
for (i in history.length() - 1 downTo 0) {
val entry = history.optJSONObject(i) ?: continue
if (entry.optString("event") == "UserPromptSubmit") {
val detail = entry.optString("detail", "")
if (detail.isNotEmpty()) return detail.take(120)
}
}
val stopResp = state.optString("lastStopResponse", "")
if (stopResp.isNotEmpty()) return "< ${stopResp.take(100)}"
return ""
}
private fun buildBadgeString(state: JSONObject): String {
val history = state.optJSONArray("hookHistory") ?: return ""
var tools = 0; var perms = 0; var sessions = 0
for (i in 0 until history.length()) {
val entry = history.optJSONObject(i) ?: continue
val event = entry.optString("event", "")
when {
event in TOOL_EVENTS -> tools++
event in PERM_EVENTS -> perms++
event in SESSION_EVENTS -> sessions++
}
}
val parts = mutableListOf<String>()
if (tools > 0) parts.add("T:$tools")
if (perms > 0) parts.add("P:$perms")
if (sessions > 0) parts.add("S:$sessions")
return parts.joinToString(" ")
}
}

View File

@@ -3,12 +3,19 @@ package com.agentui.desktop
import android.app.PendingIntent import android.app.PendingIntent
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.widget.RemoteViews import android.widget.RemoteViews
class TranscriptWidgetProvider : AppWidgetProvider() { class TranscriptWidgetProvider : AppWidgetProvider() {
companion object {
const val ACTION_REFRESH = "com.agentui.desktop.WIDGET_REFRESH"
const val ACTION_ITEM_CLICK = "com.agentui.desktop.WIDGET_ITEM_CLICK"
}
override fun onUpdate( override fun onUpdate(
context: Context, context: Context,
appWidgetManager: AppWidgetManager, appWidgetManager: AppWidgetManager,
@@ -17,22 +24,61 @@ class TranscriptWidgetProvider : AppWidgetProvider() {
for (appWidgetId in appWidgetIds) { for (appWidgetId in appWidgetIds) {
val views = RemoteViews(context.packageName, R.layout.widget_transcript) val views = RemoteViews(context.packageName, R.layout.widget_transcript)
// Tap widget → open main app // ListView adapter
val intent = Intent(context, MainActivity::class.java) val serviceIntent = Intent(context, TerminalListWidgetService::class.java).apply {
val pendingIntent = PendingIntent.getActivity( putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
context, 0, intent, data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
}
views.setRemoteAdapter(R.id.terminal_list, serviceIntent)
views.setEmptyView(R.id.terminal_list, R.id.empty_view)
// Item click template → opens app
val itemClickIntent = Intent(context, MainActivity::class.java).apply {
action = ACTION_ITEM_CLICK
}
val itemClickPending = PendingIntent.getActivity(
context, 0, itemClickIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
views.setPendingIntentTemplate(R.id.terminal_list, itemClickPending)
// Refresh button
val refreshIntent = Intent(context, TranscriptWidgetProvider::class.java).apply {
action = ACTION_REFRESH
}
val refreshPending = PendingIntent.getBroadcast(
context, 1, refreshIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
views.setOnClickPendingIntent(R.id.widget_root, pendingIntent) views.setOnClickPendingIntent(R.id.btn_refresh, refreshPending)
// Title tap → open app
val appIntent = Intent(context, MainActivity::class.java)
val appPending = PendingIntent.getActivity(
context, 2, appIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_title, appPending)
appWidgetManager.updateAppWidget(appWidgetId, views) appWidgetManager.updateAppWidget(appWidgetId, views)
} }
}
// Start periodic refresh worker override fun onReceive(context: Context, intent: Intent) {
TranscriptWidgetWorker.enqueue(context) super.onReceive(context, intent)
if (intent.action == ACTION_REFRESH) {
// Notify the ListView adapter to refresh its data
val appWidgetManager = AppWidgetManager.getInstance(context)
val widgetIds = appWidgetManager.getAppWidgetIds(
ComponentName(context, TranscriptWidgetProvider::class.java)
)
appWidgetManager.notifyAppWidgetViewDataChanged(widgetIds, R.id.terminal_list)
}
} }
override fun onEnabled(context: Context) { override fun onEnabled(context: Context) {
// Periodic refresh via WorkManager for background updates
TranscriptWidgetWorker.enqueue(context) TranscriptWidgetWorker.enqueue(context)
} }

View File

@@ -3,14 +3,13 @@ package com.agentui.desktop
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.widget.RemoteViews
import androidx.work.* import androidx.work.*
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/**
* Periodic background worker that triggers the widget ListView to refresh.
* The actual data fetching happens in TerminalListFactory.onDataSetChanged().
*/
class TranscriptWidgetWorker( class TranscriptWidgetWorker(
private val context: Context, private val context: Context,
params: WorkerParameters params: WorkerParameters
@@ -18,13 +17,6 @@ class TranscriptWidgetWorker(
companion object { companion object {
const val WORK_NAME = "transcript_widget_refresh" const val WORK_NAME = "transcript_widget_refresh"
private val MSG_IDS = intArrayOf(
R.id.msg1, R.id.msg2, R.id.msg3, R.id.msg4, R.id.msg5
)
private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
fun enqueue(context: Context) { fun enqueue(context: Context) {
val request = OneTimeWorkRequestBuilder<TranscriptWidgetWorker>() val request = OneTimeWorkRequestBuilder<TranscriptWidgetWorker>()
@@ -39,108 +31,20 @@ class TranscriptWidgetWorker(
} }
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val messages = fetchMessages() // Tell the widget's ListView adapter to re-fetch data
updateWidget(messages)
scheduleNext()
return Result.success()
}
private fun fetchMessages(): List<String> {
val apiBase = ServerConfig.apiBaseUrl(context) ?: return listOf("No server configured")
try {
// Get most recent session for 'ejecutor' agent
val sessionsUrl = "$apiBase/transcript-debug/sessions?agent=ejecutor"
val sessionsReq = Request.Builder().url(sessionsUrl).build()
val sessionsResp = client.newCall(sessionsReq).execute()
if (!sessionsResp.isSuccessful) return listOf("Failed to fetch sessions")
val sessionsJson = JSONArray(sessionsResp.body?.string() ?: "[]")
if (sessionsJson.length() == 0) return listOf("No sessions found")
// Get the most recent session (sorted by mtime desc from server)
val latestSession = sessionsJson.getJSONObject(0)
val sessionId = latestSession.getString("id")
// Fetch raw JSONL
val rawUrl = "$apiBase/transcript-debug/$sessionId/raw?agent=ejecutor"
val rawReq = Request.Builder().url(rawUrl).build()
val rawResp = client.newCall(rawReq).execute()
if (!rawResp.isSuccessful) return listOf("Failed to fetch transcript")
val rawText = rawResp.body?.string() ?: return listOf("Empty transcript")
return parseJsonlMessages(rawText)
} catch (e: Exception) {
return listOf("Error: ${e.message?.take(40)}")
}
}
private fun parseJsonlMessages(jsonl: String): List<String> {
val messages = mutableListOf<String>()
val lines = jsonl.trim().split("\n").filter { it.isNotBlank() }
for (line in lines) {
try {
val obj = JSONObject(line)
val type = obj.optString("type", "")
when (type) {
"human" -> {
val content = obj.optString("message", "")
.ifEmpty { extractMessageContent(obj) }
if (content.isNotEmpty()) {
messages.add("> ${content.take(80)}")
}
}
"assistant" -> {
val content = extractMessageContent(obj)
if (content.isNotEmpty()) {
messages.add("< ${content.take(80)}")
}
}
}
} catch (_: Exception) { }
}
return messages.takeLast(5).ifEmpty { listOf("No messages yet") }
}
private fun extractMessageContent(obj: JSONObject): String {
// Try "message" field first (simple string)
val msg = obj.optString("message", "")
if (msg.isNotEmpty()) return msg
// Try "message" as array of content blocks
val msgArray = obj.optJSONArray("message")
if (msgArray != null) {
for (i in 0 until msgArray.length()) {
val block = msgArray.optJSONObject(i) ?: continue
if (block.optString("type") == "text") {
return block.optString("text", "")
}
}
}
return ""
}
private fun updateWidget(messages: List<String>) {
val views = RemoteViews(context.packageName, R.layout.widget_transcript)
for (i in MSG_IDS.indices) {
views.setTextViewText(MSG_IDS[i], messages.getOrElse(i) { "" })
}
val appWidgetManager = AppWidgetManager.getInstance(context) val appWidgetManager = AppWidgetManager.getInstance(context)
val widgetComponent = ComponentName(context, TranscriptWidgetProvider::class.java) val widgetIds = appWidgetManager.getAppWidgetIds(
appWidgetManager.updateAppWidget(widgetComponent, views) ComponentName(context, TranscriptWidgetProvider::class.java)
} )
appWidgetManager.notifyAppWidgetViewDataChanged(widgetIds, R.id.terminal_list)
private fun scheduleNext() { // Schedule next refresh
val request = OneTimeWorkRequestBuilder<TranscriptWidgetWorker>() val next = OneTimeWorkRequestBuilder<TranscriptWidgetWorker>()
.setInitialDelay(30, TimeUnit.SECONDS) .setInitialDelay(30, TimeUnit.SECONDS)
.build() .build()
WorkManager.getInstance(context) WorkManager.getInstance(context)
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request) .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, next)
return Result.success()
} }
} }

View File

@@ -10,7 +10,9 @@ import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject import org.json.JSONObject
import java.util.concurrent.CountDownLatch import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class VoiceCommandActivity : Activity() { class VoiceCommandActivity : Activity() {
@@ -27,28 +29,44 @@ class VoiceCommandActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d(TAG, "VoiceCommandActivity created, action=${intent?.action}")
// Check if launched via share intent when (intent?.action) {
if (intent?.action == Intent.ACTION_SEND && intent.type == "text/plain") { // Launched via share intent
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) Intent.ACTION_SEND -> {
if (!sharedText.isNullOrBlank()) { if (intent.type == "text/plain") {
sendToServer(sharedText) val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
if (!sharedText.isNullOrBlank()) {
sendTranscript(sharedText, "share-intent")
return
}
}
}
// Launched as assist app or voice command
Intent.ACTION_ASSIST,
Intent.ACTION_VOICE_COMMAND,
"android.intent.action.VOICE_COMMAND" -> {
Log.d(TAG, "Launched as assistant, starting speech recognition")
startSpeechRecognition()
return return
} }
} }
// Otherwise, start speech recognition // Default fallback: start speech recognition
startSpeechRecognition() startSpeechRecognition()
} }
private fun startSpeechRecognition() { private fun startSpeechRecognition() {
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_PROMPT, "Agent UI - Speak your command") putExtra(RecognizerIntent.EXTRA_LANGUAGE, "es-HN")
putExtra(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE, "es-HN")
putExtra(RecognizerIntent.EXTRA_PROMPT, "Agent UI - Decí tu comando")
} }
try { try {
startActivityForResult(intent, SPEECH_REQUEST_CODE) startActivityForResult(intent, SPEECH_REQUEST_CODE)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Speech recognition not available", e)
Toast.makeText(this, "Speech recognition not available", Toast.LENGTH_SHORT).show() Toast.makeText(this, "Speech recognition not available", Toast.LENGTH_SHORT).show()
finish() finish()
} }
@@ -62,95 +80,90 @@ class VoiceCommandActivity : Activity() {
val results = data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) val results = data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
val text = results?.firstOrNull() val text = results?.firstOrNull()
if (!text.isNullOrBlank()) { if (!text.isNullOrBlank()) {
sendToServer(text) Log.d(TAG, "Speech recognized: $text")
sendTranscript(text, "voice-assistant")
return return
} }
} }
Log.d(TAG, "Speech recognition cancelled or no result")
finish() finish()
} }
} }
private fun sendToServer(text: String) { /**
* Sends the transcribed text to the server's /api/voice-transcript endpoint,
* then launches MainActivity with the target terminal so the floating
* transcript debug opens on the correct session.
*/
private fun sendTranscript(text: String, source: String) {
Thread { Thread {
try { try {
val terminalBase = ServerConfig.terminalBaseUrl(this) val apiBase = ServerConfig.apiBaseUrl(this)
val terminalWs = ServerConfig.terminalWsUrl(this)
if (terminalBase == null || terminalWs == null) { if (apiBase == null) {
Log.w(TAG, "No server configured, cannot send transcript")
showToastAndFinish("No server configured") showToastAndFinish("No server configured")
return@Thread return@Thread
} }
// 1. Create a new terminal session val timestamp = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
val createBody = JSONObject().apply { .format(Date())
put("agent", "ejecutor")
put("transcriptSessionId", "__new__") val body = JSONObject().apply {
put("label", text.take(60)) put("text", text)
put("command", "ejecutor") put("timestamp", timestamp)
put("source", source)
}.toString().toRequestBody("application/json".toMediaType()) }.toString().toRequestBody("application/json".toMediaType())
val createReq = Request.Builder() val request = Request.Builder()
.url("$terminalBase/create-terminal") .url("$apiBase/voice-transcript")
.post(createBody) .post(body)
.build() .build()
val createResp = client.newCall(createReq).execute() Log.d(TAG, "Sending transcript to $apiBase/voice-transcript")
if (!createResp.isSuccessful) {
showToastAndFinish("Failed to create terminal") val response = client.newCall(request).execute()
return@Thread val responseBody = response.body?.string() ?: ""
if (response.isSuccessful) {
Log.d(TAG, "Server response: $responseBody")
// Parse the ephemeralSessionId from the response
val json = JSONObject(responseBody)
val terminal = json.optString("ephemeralSessionId", "")
// Launch MainActivity with the terminal info to open floating transcript
openMainWithTerminal(terminal, text)
} else {
Log.e(TAG, "Server error ${response.code}: $responseBody")
showToastAndFinish("Server error: ${response.code}")
} }
val respJson = JSONObject(createResp.body?.string() ?: "{}")
val sessionId = respJson.optString("ephemeralSessionId", "")
if (sessionId.isEmpty()) {
showToastAndFinish("No session ID returned")
return@Thread
}
// 2. Connect via WebSocket and send the input
val wsUrl = "$terminalWs/ws/terminal?session=$sessionId"
val wsReq = Request.Builder().url(wsUrl).build()
val latch = CountDownLatch(1)
client.newWebSocket(wsReq, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
// Send the text as input
val inputMsg = JSONObject().apply {
put("type", "input")
put("data", text)
}.toString()
webSocket.send(inputMsg)
// Send Enter key after a short delay
Thread.sleep(80)
val enterMsg = JSONObject().apply {
put("type", "input")
put("data", "\r")
}.toString()
webSocket.send(enterMsg)
// Close after sending
Thread.sleep(200)
webSocket.close(1000, "done")
latch.countDown()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(TAG, "WebSocket failed", t)
latch.countDown()
}
})
latch.await(5, TimeUnit.SECONDS)
showToastAndFinish("Sent: ${text.take(40)}")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to send command", e) Log.e(TAG, "Failed to send transcript", e)
showToastAndFinish("Error: ${e.message?.take(40)}") showToastAndFinish("Error: ${e.message?.take(50)}")
} }
}.start() }.start()
} }
/**
* Opens MainActivity and tells the WebView to open FloatingTranscriptDebug
* on the terminal that received the voice command.
*/
private fun openMainWithTerminal(terminal: String, text: String) {
runOnUiThread {
Toast.makeText(this, "Sent: ${text.take(50)}", Toast.LENGTH_SHORT).show()
val intent = Intent(this, MainActivity::class.java).apply {
action = "com.agentui.desktop.VOICE_TERMINAL"
putExtra("ephemeralSessionId", terminal)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
startActivity(intent)
finish()
}
}
private fun showToastAndFinish(message: String) { private fun showToastAndFinish(message: String) {
runOnUiThread { runOnUiThread {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show() Toast.makeText(this, message, Toast.LENGTH_SHORT).show()

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#ff171717" />
<corners android:radius="22dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#99010101" />
<corners android:radius="22dp" />
</shape>

View File

@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="12dp"
android:background="@drawable/face_widget_bg_aod">
<!-- Minimal AOD layout: title + terminal count -->
<TextView
android:id="@+id/fw_aod_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Agent UI"
android:textColor="#555588"
android:textSize="11sp"
android:fontFamily="monospace"
android:paddingBottom="4dp" />
<!-- Terminal 1 -->
<LinearLayout
android:id="@+id/fw_aod_terminal_1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingTop="1dp"
android:paddingBottom="1dp"
android:visibility="gone">
<TextView
android:id="@+id/fw_aod_dot_1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\u25CF"
android:textColor="#444466"
android:textSize="7sp"
android:paddingEnd="4dp" />
<TextView
android:id="@+id/fw_aod_name_1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#777777"
android:textSize="9sp"
android:fontFamily="monospace"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>
<!-- Terminal 2 -->
<LinearLayout
android:id="@+id/fw_aod_terminal_2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingTop="1dp"
android:paddingBottom="1dp"
android:visibility="gone">
<TextView
android:id="@+id/fw_aod_dot_2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\u25CF"
android:textColor="#444466"
android:textSize="7sp"
android:paddingEnd="4dp" />
<TextView
android:id="@+id/fw_aod_name_2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#777777"
android:textSize="9sp"
android:fontFamily="monospace"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>
<!-- Terminal 3 -->
<LinearLayout
android:id="@+id/fw_aod_terminal_3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingTop="1dp"
android:paddingBottom="1dp"
android:visibility="gone">
<TextView
android:id="@+id/fw_aod_dot_3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\u25CF"
android:textColor="#444466"
android:textSize="7sp"
android:paddingEnd="4dp" />
<TextView
android:id="@+id/fw_aod_name_3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#777777"
android:textSize="9sp"
android:fontFamily="monospace"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>
<!-- Empty state -->
<TextView
android:id="@+id/fw_aod_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="No agents"
android:textColor="#444444"
android:textSize="9sp"
android:fontFamily="monospace"
android:gravity="center" />
</LinearLayout>

View File

@@ -0,0 +1,140 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="12dp"
android:background="@drawable/face_widget_bg_dark">
<!-- Title bar -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingBottom="6dp">
<TextView
android:id="@+id/fw_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Agent UI"
android:textColor="#8888FF"
android:textSize="12sp"
android:fontFamily="monospace" />
<TextView
android:id="@+id/fw_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#888888"
android:textSize="10sp"
android:fontFamily="monospace" />
</LinearLayout>
<!-- Terminal 1 -->
<LinearLayout
android:id="@+id/fw_terminal_1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:visibility="gone">
<TextView
android:id="@+id/fw_dot_1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\u25CF"
android:textColor="#6b7280"
android:textSize="8sp"
android:paddingEnd="4dp" />
<TextView
android:id="@+id/fw_name_1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#DDDDDD"
android:textSize="10sp"
android:fontFamily="monospace"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>
<!-- Terminal 2 -->
<LinearLayout
android:id="@+id/fw_terminal_2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:visibility="gone">
<TextView
android:id="@+id/fw_dot_2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\u25CF"
android:textColor="#6b7280"
android:textSize="8sp"
android:paddingEnd="4dp" />
<TextView
android:id="@+id/fw_name_2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#DDDDDD"
android:textSize="10sp"
android:fontFamily="monospace"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>
<!-- Terminal 3 -->
<LinearLayout
android:id="@+id/fw_terminal_3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:visibility="gone">
<TextView
android:id="@+id/fw_dot_3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\u25CF"
android:textColor="#6b7280"
android:textSize="8sp"
android:paddingEnd="4dp" />
<TextView
android:id="@+id/fw_name_3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#DDDDDD"
android:textSize="10sp"
android:fontFamily="monospace"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>
<!-- Empty state -->
<TextView
android:id="@+id/fw_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="No agents active"
android:textColor="#666666"
android:textSize="10sp"
android:fontFamily="monospace"
android:gravity="center" />
</LinearLayout>

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/item_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="3dp"
android:paddingBottom="3dp"
android:background="?android:attr/selectableItemBackground">
<!-- Header: dot + agent name + status + badges -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<!-- Status dot -->
<TextView
android:id="@+id/item_dot"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\u25CF"
android:textColor="#6b7280"
android:textSize="8sp"
android:paddingEnd="4dp" />
<!-- Agent name + status -->
<TextView
android:id="@+id/item_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="#DDDDDD"
android:textSize="10sp"
android:fontFamily="monospace"
android:maxLines="1"
android:ellipsize="end" />
<!-- Hook badges -->
<TextView
android:id="@+id/item_badges"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#888888"
android:textSize="9sp"
android:fontFamily="monospace"
android:paddingStart="4dp" />
</LinearLayout>
<!-- Label / last user prompt -->
<TextView
android:id="@+id/item_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#9999CC"
android:textSize="10sp"
android:fontFamily="monospace"
android:maxLines="2"
android:ellipsize="end"
android:paddingStart="12dp"
android:paddingTop="1dp" />
</LinearLayout>

View File

@@ -4,67 +4,57 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:padding="12dp" android:padding="10dp"
android:background="#DD1A1A2E"> android:background="#DD1A1A2E">
<TextView <!-- Title bar -->
android:id="@+id/widget_title" <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Agent UI Transcript" android:orientation="horizontal"
android:textColor="#8888FF" android:gravity="center_vertical"
android:textSize="12sp" android:paddingBottom="4dp">
android:fontFamily="monospace"
android:paddingBottom="4dp" />
<TextView <TextView
android:id="@+id/msg1" android:id="@+id/widget_title"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textColor="#CCCCCC" android:layout_weight="1"
android:textSize="11sp" android:text="Agent UI"
android:fontFamily="monospace" android:textColor="#8888FF"
android:maxLines="1" android:textSize="11sp"
android:ellipsize="end" /> android:fontFamily="monospace" />
<TextView <TextView
android:id="@+id/msg2" android:id="@+id/btn_refresh"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textColor="#CCCCCC" android:text="\u21BB"
android:textSize="11sp" android:textColor="#8888FF"
android:fontFamily="monospace" android:textSize="14sp"
android:maxLines="1" android:paddingStart="8dp"
android:ellipsize="end" /> android:paddingEnd="4dp"
android:background="?android:attr/selectableItemBackground" />
</LinearLayout>
<TextView <!-- Terminal list (dynamic, scrollable) -->
android:id="@+id/msg3" <ListView
android:id="@+id/terminal_list"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:textColor="#CCCCCC" android:divider="@null"
android:textSize="11sp" android:dividerHeight="0dp"
android:fontFamily="monospace" android:scrollbars="none" />
android:maxLines="1"
android:ellipsize="end" />
<!-- Fallback when empty -->
<TextView <TextView
android:id="@+id/msg4" android:id="@+id/empty_view"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:textColor="#CCCCCC" android:text="No terminals open"
android:textSize="11sp" android:textColor="#666666"
android:textSize="10sp"
android:fontFamily="monospace" android:fontFamily="monospace"
android:maxLines="1" android:gravity="center" />
android:ellipsize="end" />
<TextView
android:id="@+id/msg5"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#CCCCCC"
android:textSize="11sp"
android:fontFamily="monospace"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout> </LinearLayout>

View File

@@ -0,0 +1,7 @@
{
"agent_ui_transcript": {
"menuInSetting": 1,
"labelResNameInSetting": "face_widget_label",
"actionDetailSetting": "com.agentui.desktop.FACE_WIDGET_SETTINGS"
}
}

View File

@@ -2,4 +2,5 @@
<string name="app_name">Agent UI</string> <string name="app_name">Agent UI</string>
<string name="main_activity_title">Agent UI</string> <string name="main_activity_title">Agent UI</string>
<string name="widget_description">Shows recent transcript messages from Agent UI</string> <string name="widget_description">Shows recent transcript messages from Agent UI</string>
<string name="face_widget_label">Agent UI Terminals</string>
</resources> </resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<recognition-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="com.agentui.desktop.MainActivity" />

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp" android:minWidth="250dp"
android:minHeight="110dp" android:minHeight="140dp"
android:updatePeriodMillis="1800000" android:updatePeriodMillis="1800000"
android:initialLayout="@layout/widget_transcript" android:initialLayout="@layout/widget_transcript"
android:resizeMode="horizontal|vertical" android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen" android:widgetCategory="home_screen|keyguard"
android:description="@string/widget_description" /> android:description="@string/widget_description" />

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<voice-interaction-service xmlns:android="http://schemas.android.com/apk/res/android"
android:sessionService="com.agentui.desktop.AgentVoiceInteractionSessionService"
android:recognitionService="com.agentui.desktop.AgentRecognitionService"
android:supportsAssist="true"
android:supportsLaunchVoiceAssistFromKeyguard="true"
android:supportsLocalInteraction="true"
android:settingsActivity="com.agentui.desktop.MainActivity" />

View File

@@ -6,7 +6,7 @@
"build": { "build": {
"frontendDist": "../frontend/dist", "frontendDist": "../frontend/dist",
"devUrl": "http://localhost:4100", "devUrl": "http://localhost:4100",
"beforeDevCommand": "cd frontend && npm run dev", "beforeDevCommand": "",
"beforeBuildCommand": "cd frontend && npx vite build" "beforeBuildCommand": "cd frontend && npx vite build"
}, },
"app": { "app": {
@@ -17,7 +17,7 @@
"height": 800, "height": 800,
"minWidth": 800, "minWidth": 800,
"minHeight": 600, "minHeight": 600,
"decorations": true, "decorations": false,
"resizable": true "resizable": true
} }
], ],