@phenx-inc/ctlsurf 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/headless/index.mjs +196 -2
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +300 -23
- package/out/preload/index.js +7 -0
- package/out/renderer/assets/{cssMode-BdYp0fp9.js → cssMode-BbCVOTaJ.js} +3 -3
- package/out/renderer/assets/{freemarker2-Cya-9zr-.js → freemarker2-DryLywQv.js} +1 -1
- package/out/renderer/assets/{handlebars-EJs9W5Ub.js → handlebars-CdUMtSdr.js} +1 -1
- package/out/renderer/assets/{html-Ex4qy2lh.js → html-Cwkf2GMH.js} +1 -1
- package/out/renderer/assets/{htmlMode-CCm0aJ6C.js → htmlMode-C7PzhXxH.js} +3 -3
- package/out/renderer/assets/{index-nvsNYP5c.css → index-BIceGcz3.css} +18 -0
- package/out/renderer/assets/{index-Cj7K9-Ba.js → index-Dg1vMspf.js} +31 -24
- package/out/renderer/assets/{javascript-B2niCouI.js → javascript-Cy7ROXJl.js} +2 -2
- package/out/renderer/assets/{jsonMode-BgCUHaFo.js → jsonMode-BtZtt3QW.js} +3 -3
- package/out/renderer/assets/{liquid-DOQmSo4G.js → liquid-xReAQeYs.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-8SIcRAMh.js → lspLanguageFeatures-Dmx0L0Bw.js} +1 -1
- package/out/renderer/assets/{mdx-DuKdGNKE.js → mdx-YMNFO1Zz.js} +1 -1
- package/out/renderer/assets/{python-BUa4XFG-.js → python-BExaDVxv.js} +1 -1
- package/out/renderer/assets/{razor-BUw8jho0.js → razor-HL8ANmQ4.js} +1 -1
- package/out/renderer/assets/{tsMode-8FLRnobw.js → tsMode-Dqd8vdLE.js} +1 -1
- package/out/renderer/assets/{typescript-rdwAjCi4.js → typescript-Dysz1PTX.js} +1 -1
- package/out/renderer/assets/{xml-HOzT59Qg.js → xml-BHp5Z9nc.js} +1 -1
- package/out/renderer/assets/{yaml-B81JfLsW.js → yaml-CPDHxVup.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/agentState.ts +329 -0
- package/src/main/index.ts +105 -19
- package/src/main/orchestrator.ts +21 -1
- package/src/preload/index.ts +8 -0
- package/src/renderer/App.tsx +23 -2
- package/src/renderer/styles.css +18 -0
package/src/main/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ function log(...args: unknown[]): void {
|
|
|
24
24
|
import { AgentConfig, getDefaultAgent, getAvailableAgents } from './agents'
|
|
25
25
|
import { getSettingsDir } from './settingsDir'
|
|
26
26
|
import { Orchestrator } from './orchestrator'
|
|
27
|
+
import { AttentionController } from './agentState'
|
|
27
28
|
|
|
28
29
|
let mainWindow: BrowserWindow | null = null
|
|
29
30
|
|
|
@@ -50,43 +51,47 @@ function getProjectAbbrev(cwdPath: string): string {
|
|
|
50
51
|
return word.slice(0, 2).toUpperCase()
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
// Draw a 32x32 filled circle overlay. `red` switches between the project-badge
|
|
55
|
+
// blue and the "awaiting input" red dot.
|
|
56
|
+
function createDotOverlay(red: boolean): Electron.NativeImage {
|
|
55
57
|
const size = 32
|
|
56
58
|
const canvas = Buffer.alloc(size * size * 4, 0) // RGBA
|
|
57
|
-
|
|
58
|
-
// Fill with a semi-transparent dark background circle
|
|
59
|
+
const [r, g, b] = red ? [235, 64, 52] : [122, 162, 247]
|
|
59
60
|
for (let y = 0; y < size; y++) {
|
|
60
61
|
for (let x = 0; x < size; x++) {
|
|
61
62
|
const cx = x - size / 2
|
|
62
63
|
const cy = y - size / 2
|
|
63
|
-
|
|
64
|
-
if (dist <= size / 2) {
|
|
64
|
+
if (Math.sqrt(cx * cx + cy * cy) <= size / 2) {
|
|
65
65
|
const idx = (y * size + x) * 4
|
|
66
|
-
canvas[idx] =
|
|
67
|
-
canvas[idx + 1] =
|
|
68
|
-
canvas[idx + 2] =
|
|
69
|
-
canvas[idx + 3] =
|
|
66
|
+
canvas[idx] = r
|
|
67
|
+
canvas[idx + 1] = g
|
|
68
|
+
canvas[idx + 2] = b
|
|
69
|
+
canvas[idx + 3] = 235
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
-
|
|
74
73
|
return nativeImage.createFromBuffer(canvas, { width: size, height: size })
|
|
75
74
|
}
|
|
76
75
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
// Last project abbreviation, so the steady badge can be restored after the
|
|
77
|
+
// "awaiting input" indicator clears.
|
|
78
|
+
let projectAbbrev = ''
|
|
80
79
|
|
|
81
|
-
|
|
80
|
+
function applySteadyBadge(): void {
|
|
82
81
|
if (process.platform === 'darwin' && app.dock) {
|
|
83
|
-
app.dock.setBadge(
|
|
82
|
+
app.dock.setBadge(projectAbbrev)
|
|
84
83
|
}
|
|
85
|
-
|
|
86
|
-
// Windows: overlay icon on taskbar
|
|
87
84
|
if (process.platform === 'win32' && mainWindow) {
|
|
88
|
-
mainWindow.setOverlayIcon(
|
|
85
|
+
mainWindow.setOverlayIcon(projectAbbrev ? createDotOverlay(false) : null, projectAbbrev)
|
|
89
86
|
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function updateProjectBadge(cwdPath: string | null): void {
|
|
90
|
+
if (!cwdPath) return
|
|
91
|
+
projectAbbrev = getProjectAbbrev(cwdPath)
|
|
92
|
+
|
|
93
|
+
// Don't stomp the awaiting-input blink; it restores the steady badge itself.
|
|
94
|
+
if (!blinkTimer) applySteadyBadge()
|
|
90
95
|
|
|
91
96
|
// All platforms: update window title
|
|
92
97
|
if (mainWindow) {
|
|
@@ -95,6 +100,76 @@ function updateProjectBadge(cwdPath: string | null): void {
|
|
|
95
100
|
}
|
|
96
101
|
}
|
|
97
102
|
|
|
103
|
+
// ─── "Awaiting input" attention indicator (cross-platform) ──────────
|
|
104
|
+
//
|
|
105
|
+
// The detection is OS-independent; only the visual differs:
|
|
106
|
+
// macOS — blink a red dot in the dock badge + one bounce
|
|
107
|
+
// Windows — red overlay icon on the taskbar button + flash the button
|
|
108
|
+
// Linux — badge count where supported + flash (window urgency hint)
|
|
109
|
+
// While the window is focused the dock/taskbar nag is suppressed (the in-app
|
|
110
|
+
// dot is enough); the renderer always gets `agent:state` regardless of OS.
|
|
111
|
+
|
|
112
|
+
const attention = new AttentionController()
|
|
113
|
+
let blinkTimer: ReturnType<typeof setInterval> | null = null
|
|
114
|
+
let blinkOn = false
|
|
115
|
+
let bounceId = -1
|
|
116
|
+
|
|
117
|
+
function setAwaitingIndicator(on: boolean): void {
|
|
118
|
+
applyAttention(attention.setAwaiting(on))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function applyAttention(active: boolean): void {
|
|
122
|
+
if (active) startAttention()
|
|
123
|
+
else stopAttention()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function startAttention(): void {
|
|
127
|
+
if (blinkTimer) return
|
|
128
|
+
log('[agent-state] attention START (blink + bounce)')
|
|
129
|
+
|
|
130
|
+
if (process.platform === 'darwin' && app.dock) {
|
|
131
|
+
bounceId = app.dock.bounce('informational')
|
|
132
|
+
}
|
|
133
|
+
if (process.platform === 'win32' && mainWindow) {
|
|
134
|
+
mainWindow.setOverlayIcon(createDotOverlay(true), 'Awaiting input')
|
|
135
|
+
mainWindow.flashFrame(true)
|
|
136
|
+
}
|
|
137
|
+
if (process.platform === 'linux') {
|
|
138
|
+
if (typeof app.badgeCount === 'number') app.badgeCount = 1
|
|
139
|
+
mainWindow?.flashFrame(true)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Blink the macOS dock badge — the whole red pill (project initials and all)
|
|
143
|
+
// toggles on/off, so the project letters are preserved on the "on" frames
|
|
144
|
+
// rather than being replaced by a dot.
|
|
145
|
+
blinkOn = true
|
|
146
|
+
blinkTimer = setInterval(() => {
|
|
147
|
+
blinkOn = !blinkOn
|
|
148
|
+
if (process.platform === 'darwin' && app.dock) {
|
|
149
|
+
app.dock.setBadge(blinkOn ? (projectAbbrev || '●') : '')
|
|
150
|
+
}
|
|
151
|
+
}, 600)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function stopAttention(): void {
|
|
155
|
+
if (blinkTimer) {
|
|
156
|
+
clearInterval(blinkTimer)
|
|
157
|
+
blinkTimer = null
|
|
158
|
+
log('[agent-state] attention STOP')
|
|
159
|
+
}
|
|
160
|
+
if (process.platform === 'darwin' && app.dock) {
|
|
161
|
+
if (bounceId >= 0) { app.dock.cancelBounce(bounceId); bounceId = -1 }
|
|
162
|
+
}
|
|
163
|
+
if (process.platform === 'win32' && mainWindow) {
|
|
164
|
+
mainWindow.flashFrame(false)
|
|
165
|
+
}
|
|
166
|
+
if (process.platform === 'linux') {
|
|
167
|
+
if (typeof app.badgeCount === 'number') app.badgeCount = 0
|
|
168
|
+
mainWindow?.flashFrame(false)
|
|
169
|
+
}
|
|
170
|
+
applySteadyBadge()
|
|
171
|
+
}
|
|
172
|
+
|
|
98
173
|
// ─── Orchestrator (shared logic) ──────────────────
|
|
99
174
|
|
|
100
175
|
const orchestrator = new Orchestrator(
|
|
@@ -107,6 +182,11 @@ const orchestrator = new Orchestrator(
|
|
|
107
182
|
onWorkerRegistered: (data) => mainWindow?.webContents.send('worker:registered', data),
|
|
108
183
|
onProjectChanged: (name) => mainWindow?.webContents.send('app:projectChanged', name),
|
|
109
184
|
onAgentMessage: (text) => mainWindow?.webContents.send('agent:message', text),
|
|
185
|
+
onAgentStateChange: (tabId, state, anyAwaiting) => {
|
|
186
|
+
log(`[agent-state] tab=${tabId} state=${state} anyAwaiting=${anyAwaiting}`)
|
|
187
|
+
mainWindow?.webContents.send('agent:state', tabId, state, anyAwaiting)
|
|
188
|
+
setAwaitingIndicator(anyAwaiting)
|
|
189
|
+
},
|
|
110
190
|
onCwdChanged: () => {
|
|
111
191
|
mainWindow?.webContents.send('app:cwdChanged')
|
|
112
192
|
updateProjectBadge(orchestrator.cwd)
|
|
@@ -146,6 +226,12 @@ function createWindow(): void {
|
|
|
146
226
|
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
|
|
147
227
|
}
|
|
148
228
|
|
|
229
|
+
// Focusing the window means the user is here — drop the dock/taskbar nag (the
|
|
230
|
+
// in-app dot still shows the state). Re-arm it if the agent is still awaiting
|
|
231
|
+
// when focus is lost again.
|
|
232
|
+
mainWindow.on('focus', () => applyAttention(attention.setFocused(true)))
|
|
233
|
+
mainWindow.on('blur', () => applyAttention(attention.setFocused(false)))
|
|
234
|
+
|
|
149
235
|
mainWindow.on('closed', () => {
|
|
150
236
|
mainWindow = null
|
|
151
237
|
})
|
package/src/main/orchestrator.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { AgentConfig, isCodingAgent } from './agents'
|
|
|
7
7
|
import { CtlsurfApi } from './ctlsurfApi'
|
|
8
8
|
import { ConversationBridge } from './bridge'
|
|
9
9
|
import { TranscriptTailer, supportsTranscriptLogging, readLatestAssistantReply } from './transcripts'
|
|
10
|
+
import { AgentStateMonitor, type AgentState } from './agentState'
|
|
10
11
|
import { WorkerWsClient, type WorkerWsStatus, type IncomingMessage } from './workerWs'
|
|
11
12
|
import { TimeTracker } from './timeTracker'
|
|
12
13
|
import { TicketStore, type TicketInput } from './ticketStore'
|
|
@@ -51,6 +52,10 @@ export interface OrchestratorEvents {
|
|
|
51
52
|
// text-to-speech. Optional and wired only by the Electron entry — the
|
|
52
53
|
// headless build leaves it unset, so replies are never spoken in a terminal.
|
|
53
54
|
onAgentMessage?: (text: string) => void
|
|
55
|
+
// Real-time agent activity state (busy vs awaiting the user). `anyAwaiting`
|
|
56
|
+
// is true when any tab is waiting on the user, and drives the dock/taskbar
|
|
57
|
+
// attention indicator. Optional — only the Electron entry wires it.
|
|
58
|
+
onAgentStateChange?: (tabId: string, state: AgentState, anyAwaiting: boolean) => void
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
interface TabState {
|
|
@@ -102,11 +107,17 @@ export class Orchestrator {
|
|
|
102
107
|
private noProjectPollCwd: string | null = null
|
|
103
108
|
private currentProjectName: string | null = null
|
|
104
109
|
private transcriptTailer: TranscriptTailer | null = null
|
|
110
|
+
private readonly stateMonitor: AgentStateMonitor
|
|
105
111
|
|
|
106
112
|
constructor(settingsDir: string, events: OrchestratorEvents) {
|
|
107
113
|
this.settingsDir = settingsDir
|
|
108
114
|
this.events = events
|
|
109
115
|
|
|
116
|
+
this.stateMonitor = new AgentStateMonitor(({ tabId, state, anyAwaiting }) => {
|
|
117
|
+
this.events.onAgentStateChange?.(tabId, state, anyAwaiting)
|
|
118
|
+
})
|
|
119
|
+
this.stateMonitor.start()
|
|
120
|
+
|
|
110
121
|
this.workerWs = new WorkerWsClient({
|
|
111
122
|
onStatusChange: (status: WorkerWsStatus) => {
|
|
112
123
|
log(`[worker-ws] Status: ${status}`)
|
|
@@ -119,9 +130,10 @@ export class Orchestrator {
|
|
|
119
130
|
|
|
120
131
|
if (message.type === 'prompt' || message.type === 'task_dispatch') {
|
|
121
132
|
const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null
|
|
122
|
-
if (activeTab) {
|
|
133
|
+
if (activeTab && this.activeTabId) {
|
|
123
134
|
activeTab.ptyManager.write(message.content + '\r')
|
|
124
135
|
this.bridge.feedInput(message.content + '\r')
|
|
136
|
+
this.stateMonitor.noteInput(this.activeTabId, message.content + '\r')
|
|
125
137
|
}
|
|
126
138
|
}
|
|
127
139
|
},
|
|
@@ -463,10 +475,14 @@ export class Orchestrator {
|
|
|
463
475
|
const ptyManager = new PtyManager(agent, cwd)
|
|
464
476
|
const tab: TabState = { ptyManager, agent, cwd, termStreamBuffer: '', termStreamTimer: null }
|
|
465
477
|
this.tabs.set(tabId, tab)
|
|
478
|
+
// Track all tabs (not just the active one): a background agent finishing is
|
|
479
|
+
// exactly when the dock indicator is most useful.
|
|
480
|
+
this.stateMonitor.trackTab(tabId, agent.id, cwd)
|
|
466
481
|
|
|
467
482
|
ptyManager.onData((data: string) => {
|
|
468
483
|
this.events.onPtyData(tabId, data)
|
|
469
484
|
this.timeTracker.recordActivity(tabId)
|
|
485
|
+
this.stateMonitor.feed(tabId, data)
|
|
470
486
|
if (tabId === this.activeTabId) {
|
|
471
487
|
this.bridge.feedOutput(data)
|
|
472
488
|
this.streamTerminalData(tabId, data)
|
|
@@ -485,6 +501,7 @@ export class Orchestrator {
|
|
|
485
501
|
// Clean up tab state
|
|
486
502
|
const t = this.tabs.get(tabId)
|
|
487
503
|
if (t?.termStreamTimer) clearTimeout(t.termStreamTimer)
|
|
504
|
+
this.stateMonitor.removeTab(tabId)
|
|
488
505
|
})
|
|
489
506
|
|
|
490
507
|
this.startChatLogging(agent, cwd)
|
|
@@ -511,6 +528,7 @@ export class Orchestrator {
|
|
|
511
528
|
|
|
512
529
|
writePty(tabId: string, data: string): void {
|
|
513
530
|
this.tabs.get(tabId)?.ptyManager.write(data)
|
|
531
|
+
this.stateMonitor.noteInput(tabId, data)
|
|
514
532
|
if (tabId === this.activeTabId) {
|
|
515
533
|
this.bridge.feedInput(data)
|
|
516
534
|
}
|
|
@@ -531,6 +549,7 @@ export class Orchestrator {
|
|
|
531
549
|
await this.timeTracker.endSession(tabId)
|
|
532
550
|
tab.ptyManager.kill()
|
|
533
551
|
this.tabs.delete(tabId)
|
|
552
|
+
this.stateMonitor.removeTab(tabId)
|
|
534
553
|
if (tabId === this.activeTabId) {
|
|
535
554
|
this.stopChatLogging()
|
|
536
555
|
if (isCodingAgent(tab.agent)) {
|
|
@@ -702,6 +721,7 @@ export class Orchestrator {
|
|
|
702
721
|
async shutdown(): Promise<void> {
|
|
703
722
|
this.stopNoProjectPolling()
|
|
704
723
|
this.stopChatLogging()
|
|
724
|
+
this.stateMonitor.stop()
|
|
705
725
|
await this.timeTracker.endAll()
|
|
706
726
|
for (const [, tab] of this.tabs) {
|
|
707
727
|
if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer)
|
package/src/preload/index.ts
CHANGED
|
@@ -119,6 +119,14 @@ const api = {
|
|
|
119
119
|
ipcRenderer.on('agent:message', listener)
|
|
120
120
|
return () => ipcRenderer.removeListener('agent:message', listener)
|
|
121
121
|
},
|
|
122
|
+
// Real-time agent activity state for an in-app indicator dot.
|
|
123
|
+
// state: 'idle' | 'busy' | 'awaiting_input' | 'awaiting_decision'
|
|
124
|
+
onAgentState: (callback: (tabId: string, state: string, anyAwaiting: boolean) => void) => {
|
|
125
|
+
const listener = (_event: Electron.IpcRendererEvent, tabId: string, state: string, anyAwaiting: boolean) =>
|
|
126
|
+
callback(tabId, state, anyAwaiting)
|
|
127
|
+
ipcRenderer.on('agent:state', listener)
|
|
128
|
+
return () => ipcRenderer.removeListener('agent:state', listener)
|
|
129
|
+
},
|
|
122
130
|
// Latest assistant reply from the active tab's transcript (null if none).
|
|
123
131
|
getLastReply: (): Promise<string | null> =>
|
|
124
132
|
ipcRenderer.invoke('agent:getLastReply'),
|
package/src/renderer/App.tsx
CHANGED
|
@@ -60,6 +60,7 @@ declare global {
|
|
|
60
60
|
getSpeakReplies: () => Promise<{ enabled: boolean }>
|
|
61
61
|
setSpeakReplies: (enabled: boolean) => Promise<{ enabled: boolean }>
|
|
62
62
|
onAgentMessage: (callback: (text: string) => void) => () => void
|
|
63
|
+
onAgentState: (callback: (tabId: string, state: string, anyAwaiting: boolean) => void) => () => void
|
|
63
64
|
getLastReply: () => Promise<string | null>
|
|
64
65
|
createProject: () => Promise<{ ok: boolean; folder_id?: string; error?: string }>
|
|
65
66
|
getWebviewInfo: () => Promise<{
|
|
@@ -137,6 +138,9 @@ export default function App() {
|
|
|
137
138
|
})
|
|
138
139
|
const [activeTabId, setActiveTabId] = useState<string>(tabs[0].id)
|
|
139
140
|
const [trackingActive, setTrackingActive] = useState(false)
|
|
141
|
+
// Per-tab agent activity ('busy' | 'awaiting_input' | 'awaiting_decision' | 'idle'),
|
|
142
|
+
// driven by the main process. Powers the awaiting-input dot on each tab.
|
|
143
|
+
const [agentStates, setAgentStates] = useState<Record<string, string>>({})
|
|
140
144
|
const [showTicketPanel, setShowTicketPanel] = useState(false)
|
|
141
145
|
// Draggable on-canvas push-to-talk mic; visibility persists across launches.
|
|
142
146
|
const [showFloatingMic, setShowFloatingMic] = useState<boolean>(() => {
|
|
@@ -202,6 +206,16 @@ export default function App() {
|
|
|
202
206
|
return unsub
|
|
203
207
|
}, [])
|
|
204
208
|
|
|
209
|
+
// Track each tab's agent activity for the awaiting-input dot. The aggregate-
|
|
210
|
+
// only signal arrives with an empty tabId, which we ignore here.
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
const unsub = window.worker.onAgentState((tabId, state) => {
|
|
213
|
+
if (!tabId) return
|
|
214
|
+
setAgentStates(prev => (prev[tabId] === state ? prev : { ...prev, [tabId]: state }))
|
|
215
|
+
})
|
|
216
|
+
return unsub
|
|
217
|
+
}, [])
|
|
218
|
+
|
|
205
219
|
// Reflect tracking state for the active tab; refresh on tab switch and poll
|
|
206
220
|
// periodically so the UI catches changes (e.g. a session ending naturally).
|
|
207
221
|
useEffect(() => {
|
|
@@ -424,8 +438,15 @@ export default function App() {
|
|
|
424
438
|
>
|
|
425
439
|
<span className="terminal-tab-label">
|
|
426
440
|
{tab.agent ? tab.agent.name : tab.label}
|
|
427
|
-
{tab.agentStatus === '
|
|
428
|
-
|
|
441
|
+
{tab.agentStatus === 'exited' ? (
|
|
442
|
+
<span className="terminal-tab-dot exited" />
|
|
443
|
+
) : agentStates[tab.id] === 'awaiting_decision' ? (
|
|
444
|
+
<span className="terminal-tab-dot decision" title="Awaiting your approval" />
|
|
445
|
+
) : agentStates[tab.id] === 'awaiting_input' ? (
|
|
446
|
+
<span className="terminal-tab-dot awaiting" title="Awaiting your input" />
|
|
447
|
+
) : tab.agentStatus === 'active' ? (
|
|
448
|
+
<span className="terminal-tab-dot active" />
|
|
449
|
+
) : null}
|
|
429
450
|
</span>
|
|
430
451
|
{tabs.length > 1 && (
|
|
431
452
|
<button
|
package/src/renderer/styles.css
CHANGED
|
@@ -202,6 +202,24 @@ html, body, #root {
|
|
|
202
202
|
background: #565f89;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
/* Agent is waiting on the user — pulse to draw the eye across tabs. */
|
|
206
|
+
.terminal-tab-dot.awaiting {
|
|
207
|
+
background: #e0af68; /* amber: waiting for next prompt */
|
|
208
|
+
color: #e0af68; /* drives the box-shadow glow via currentColor */
|
|
209
|
+
animation: tab-dot-pulse 1.1s ease-in-out infinite;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.terminal-tab-dot.decision {
|
|
213
|
+
background: #f7768e; /* red: blocked on an approval/permission prompt */
|
|
214
|
+
color: #f7768e;
|
|
215
|
+
animation: tab-dot-pulse 1.1s ease-in-out infinite;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
@keyframes tab-dot-pulse {
|
|
219
|
+
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 currentColor; }
|
|
220
|
+
50% { opacity: 0.35; box-shadow: 0 0 4px 1px currentColor; }
|
|
221
|
+
}
|
|
222
|
+
|
|
205
223
|
.terminal-tab-close {
|
|
206
224
|
background: none;
|
|
207
225
|
border: none;
|