Skip to content

Commit 2f5f353

Browse files
grokifyclaude
andcommitted
feat(desktop): add input detection UI and active pane indicator
Add visual feedback for AI input prompts and keyboard focus: Input Detection: - InputIndicatorView overlay shows when AI is waiting for input - Displays pattern type (permission, yes/no, question, etc.) - Quick action buttons for common responses - Expandable view with matched text preview Active Pane Indicator: - Accent-colored border (2px) on focused pane - Subtle glow shadow effect - Header background tint and focus dot - Bold pane number when focused - Smooth 150ms animation on focus change Implementation: - TerminalContainerView tracks and broadcasts focus state - PaneView listens for focus notifications - 500ms polling for focus state changes - Pass inputMonitor through view hierarchy Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ff17f21 commit 2f5f353

File tree

5 files changed

+386
-14
lines changed

5 files changed

+386
-14
lines changed

apps/desktop/Sources/PlexusOneDesktop/Views/ContentView.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ struct ContentView: View {
1818
appState.windowStateManager
1919
}
2020

21+
private var inputMonitor: InputMonitor {
22+
appState.inputMonitor
23+
}
24+
2125
var body: some View {
2226
VStack(spacing: 0) {
2327
if !isReady {
@@ -34,6 +38,7 @@ struct ContentView: View {
3438
config: gridConfig,
3539
sessions: sessionManager.sessions,
3640
sessionManager: sessionManager,
41+
inputMonitor: inputMonitor,
3742
paneManager: paneManager,
3843
onRequestNewSession: {
3944
showNewSessionSheet = true

apps/desktop/Sources/PlexusOneDesktop/Views/GridLayoutView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ struct GridLayoutView: View {
5454
let config: GridConfig
5555
let sessions: [Session]
5656
let sessionManager: SessionManager
57+
let inputMonitor: InputMonitor
5758
@Bindable var paneManager: PaneManager
5859
let onRequestNewSession: () -> Void
5960

@@ -72,6 +73,7 @@ struct GridLayoutView: View {
7273
paneId: paneId,
7374
sessions: sessions,
7475
sessionManager: sessionManager,
76+
inputMonitor: inputMonitor,
7577
attachedSession: paneManager.binding(for: paneId),
7678
onRequestNewSession: onRequestNewSession
7779
)
@@ -141,6 +143,7 @@ struct LayoutPickerView: View {
141143
Session(name: "reviewer", status: .stuck)
142144
],
143145
sessionManager: SessionManager(),
146+
inputMonitor: InputMonitor(),
144147
paneManager: PaneManager(),
145148
onRequestNewSession: {}
146149
)
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import SwiftUI
2+
import AssistantKit
3+
4+
/// Compact indicator showing an input prompt was detected.
5+
/// Appears as an overlay on the terminal pane.
6+
struct InputIndicatorView: View {
7+
let result: DetectionResult
8+
let onDismiss: () -> Void
9+
10+
@State private var isExpanded = false
11+
12+
var body: some View {
13+
VStack(alignment: .trailing, spacing: 4) {
14+
// Compact badge
15+
Button(action: { isExpanded.toggle() }) {
16+
HStack(spacing: 4) {
17+
Image(systemName: iconName)
18+
.font(.system(size: 10, weight: .bold))
19+
Text(shortLabel)
20+
.font(.system(size: 10, weight: .medium))
21+
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
22+
.font(.system(size: 8))
23+
}
24+
.padding(.horizontal, 8)
25+
.padding(.vertical, 4)
26+
.background(backgroundColor)
27+
.foregroundColor(.white)
28+
.cornerRadius(4)
29+
}
30+
.buttonStyle(.plain)
31+
32+
// Expanded view with actions
33+
if isExpanded {
34+
VStack(alignment: .leading, spacing: 8) {
35+
// Matched text preview
36+
Text(result.matchedText.trimmingCharacters(in: .whitespacesAndNewlines))
37+
.font(.system(size: 11, design: .monospaced))
38+
.lineLimit(3)
39+
.foregroundColor(.primary)
40+
41+
// Quick actions
42+
if !result.suggestedActions.isEmpty {
43+
Divider()
44+
45+
HStack(spacing: 8) {
46+
ForEach(Array(result.suggestedActions.prefix(3).enumerated()), id: \.offset) { _, action in
47+
ActionButton(action: action)
48+
}
49+
50+
Spacer()
51+
52+
Button(action: onDismiss) {
53+
Text("Dismiss")
54+
.font(.system(size: 10))
55+
}
56+
.buttonStyle(.plain)
57+
.foregroundColor(.secondary)
58+
}
59+
}
60+
}
61+
.padding(8)
62+
.background(Color(nsColor: .controlBackgroundColor))
63+
.cornerRadius(6)
64+
.shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2)
65+
.frame(maxWidth: 280)
66+
}
67+
}
68+
}
69+
70+
private var iconName: String {
71+
switch result.pattern.type {
72+
case .permission:
73+
return "lock.shield"
74+
case .yesNo:
75+
return "questionmark.circle"
76+
case .question:
77+
return "questionmark.bubble"
78+
case .raisedHand:
79+
return "hand.raised"
80+
case .continuePrompt:
81+
return "arrow.right.circle"
82+
case .selection:
83+
return "list.number"
84+
case .inputCursor:
85+
return "keyboard"
86+
case .toolUsage:
87+
return "gearshape"
88+
}
89+
}
90+
91+
private var shortLabel: String {
92+
switch result.pattern.type {
93+
case .permission:
94+
return "Permission"
95+
case .yesNo:
96+
return "Y/N"
97+
case .question:
98+
return "Question"
99+
case .raisedHand:
100+
return "Attention"
101+
case .continuePrompt:
102+
return "Continue"
103+
case .selection:
104+
return "Select"
105+
case .inputCursor:
106+
return "Input"
107+
case .toolUsage:
108+
return "Tool"
109+
}
110+
}
111+
112+
private var backgroundColor: Color {
113+
switch result.pattern.type {
114+
case .permission:
115+
return .orange
116+
case .yesNo, .question:
117+
return .blue
118+
case .raisedHand:
119+
return .red
120+
case .continuePrompt:
121+
return .green
122+
case .selection:
123+
return .purple
124+
case .inputCursor:
125+
return .gray
126+
case .toolUsage:
127+
return Color(nsColor: .systemGray)
128+
}
129+
}
130+
}
131+
132+
/// Button for a suggested action
133+
struct ActionButton: View {
134+
let action: SuggestedAction
135+
136+
var body: some View {
137+
Button(action: {
138+
// Send the action input to the terminal
139+
// This would need to be wired up to the terminal view
140+
print("Action: \(action.label) -> send: \(action.input.debugDescription)")
141+
}) {
142+
HStack(spacing: 2) {
143+
Text(action.label)
144+
.font(.system(size: 10, weight: action.isDefault ? .semibold : .regular))
145+
if let shortcut = action.shortcut {
146+
Text(shortcut)
147+
.font(.system(size: 9))
148+
.foregroundColor(.secondary)
149+
}
150+
}
151+
.padding(.horizontal, 6)
152+
.padding(.vertical, 3)
153+
.background(action.isDefault ? Color.accentColor.opacity(0.2) : Color(nsColor: .controlBackgroundColor))
154+
.cornerRadius(4)
155+
.overlay(
156+
RoundedRectangle(cornerRadius: 4)
157+
.stroke(Color(nsColor: .separatorColor), lineWidth: 0.5)
158+
)
159+
}
160+
.buttonStyle(.plain)
161+
}
162+
}
163+
164+
#Preview("Permission Alert") {
165+
let permissionPattern = try! InputPattern(
166+
id: "test-permission",
167+
pattern: "Allow",
168+
type: .permission,
169+
priority: 100
170+
)
171+
let result = DetectionResult(
172+
pattern: permissionPattern,
173+
matchedText: "? Allow Read access to config.json",
174+
range: "? Allow Read access to config.json".startIndex..<"? Allow Read access to config.json".endIndex,
175+
confidence: 1.0,
176+
suggestedActions: [.yes, .no]
177+
)
178+
179+
return InputIndicatorView(result: result, onDismiss: {})
180+
.padding()
181+
.background(Color(nsColor: .textBackgroundColor))
182+
}
183+
184+
#Preview("Yes/No Alert") {
185+
let yesNoPattern = try! InputPattern(
186+
id: "test-yesno",
187+
pattern: "Continue",
188+
type: .yesNo,
189+
priority: 90
190+
)
191+
let result = DetectionResult(
192+
pattern: yesNoPattern,
193+
matchedText: "Continue? [Y/n]",
194+
range: "Continue? [Y/n]".startIndex..<"Continue? [Y/n]".endIndex,
195+
confidence: 1.0,
196+
suggestedActions: [.yes, .no]
197+
)
198+
199+
return InputIndicatorView(result: result, onDismiss: {})
200+
.padding()
201+
.background(Color(nsColor: .textBackgroundColor))
202+
}

apps/desktop/Sources/PlexusOneDesktop/Views/PaneView.swift

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import SwiftUI
2+
import AssistantKit
23

34
/// A single pane with a compact session dropdown header and terminal view
45
struct PaneView: View {
56
let paneId: Int
67
let sessions: [Session]
78
let sessionManager: SessionManager
9+
let inputMonitor: InputMonitor
810
@Binding var attachedSession: Session?
911
let onRequestNewSession: () -> Void
1012

1113
@State private var isHovering = false
14+
@State private var currentInputAlert: DetectionResult?
15+
@State private var isFocused = false
1216

1317
var body: some View {
1418
VStack(spacing: 0) {
@@ -17,6 +21,7 @@ struct PaneView: View {
1721
paneId: paneId,
1822
sessions: sessions,
1923
currentSession: attachedSession,
24+
isFocused: isFocused,
2025
onSelectSession: { session in
2126
attachedSession = session
2227
},
@@ -38,13 +43,28 @@ struct PaneView: View {
3843

3944
// Terminal or detached placeholder
4045
if attachedSession != nil {
41-
AppTerminalViewRepresentable(
42-
attachedSession: $attachedSession,
43-
sessionManager: sessionManager,
44-
onSessionEnded: {
45-
attachedSession = nil
46+
ZStack(alignment: .topTrailing) {
47+
AppTerminalViewRepresentable(
48+
attachedSession: $attachedSession,
49+
sessionManager: sessionManager,
50+
inputMonitor: inputMonitor,
51+
onSessionEnded: {
52+
attachedSession = nil
53+
},
54+
onInputDetected: { result in
55+
currentInputAlert = result
56+
}
57+
)
58+
59+
// Input indicator overlay
60+
if let alert = currentInputAlert {
61+
InputIndicatorView(
62+
result: alert,
63+
onDismiss: { currentInputAlert = nil }
64+
)
65+
.padding(8)
4666
}
47-
)
67+
}
4868
} else {
4969
// Compact detached state
5070
CompactDetachedView(
@@ -60,8 +80,31 @@ struct PaneView: View {
6080
.cornerRadius(4)
6181
.overlay(
6282
RoundedRectangle(cornerRadius: 4)
63-
.stroke(Color(nsColor: .separatorColor), lineWidth: 1)
83+
.stroke(
84+
isFocused ? Color.accentColor : Color(nsColor: .separatorColor),
85+
lineWidth: isFocused ? 2 : 1
86+
)
6487
)
88+
.shadow(color: isFocused ? Color.accentColor.opacity(0.3) : .clear, radius: 4)
89+
.onReceive(NotificationCenter.default.publisher(for: .paneFocusChanged)) { notification in
90+
guard let userInfo = notification.userInfo,
91+
let sessionId = userInfo["sessionId"] as? UUID,
92+
let focused = userInfo["focused"] as? Bool else {
93+
return
94+
}
95+
96+
// Update focus state if this notification is for our session
97+
if sessionId == attachedSession?.id {
98+
withAnimation(.easeInOut(duration: 0.15)) {
99+
isFocused = focused
100+
}
101+
} else if focused {
102+
// Another pane gained focus, so we lose it
103+
withAnimation(.easeInOut(duration: 0.15)) {
104+
isFocused = false
105+
}
106+
}
107+
}
65108
}
66109
}
67110

@@ -70,13 +113,20 @@ struct PaneHeaderView: View {
70113
let paneId: Int
71114
let sessions: [Session]
72115
let currentSession: Session?
116+
let isFocused: Bool
73117
let onSelectSession: (Session) -> Void
74118
let onDetach: () -> Void
75119
let onPopOut: () -> Void
76120
let onNewSession: () -> Void
77121

78122
var body: some View {
79123
HStack(spacing: 4) {
124+
// Focus indicator dot
125+
Circle()
126+
.fill(isFocused ? Color.accentColor : Color.clear)
127+
.frame(width: 6, height: 6)
128+
.padding(.leading, 2)
129+
80130
// Session dropdown
81131
Menu {
82132
if sessions.isEmpty {
@@ -130,10 +180,10 @@ struct PaneHeaderView: View {
130180

131181
Spacer()
132182

133-
// Pane number indicator
183+
// Pane number indicator with focus highlight
134184
Text("#\(paneId)")
135-
.font(.system(size: 9, weight: .medium))
136-
.foregroundColor(Color(nsColor: .tertiaryLabelColor))
185+
.font(.system(size: 9, weight: isFocused ? .bold : .medium))
186+
.foregroundColor(isFocused ? Color.accentColor : Color(nsColor: .tertiaryLabelColor))
137187
.padding(.horizontal, 4)
138188

139189
// Pop-out and Detach buttons (only show if attached)
@@ -157,11 +207,11 @@ struct PaneHeaderView: View {
157207
}
158208
.padding(.horizontal, 6)
159209
.padding(.vertical, 4)
160-
.background(Color(nsColor: .windowBackgroundColor))
210+
.background(isFocused ? Color.accentColor.opacity(0.1) : Color(nsColor: .windowBackgroundColor))
161211
.overlay(
162212
Rectangle()
163213
.frame(height: 1)
164-
.foregroundColor(Color(nsColor: .separatorColor)),
214+
.foregroundColor(isFocused ? Color.accentColor.opacity(0.3) : Color(nsColor: .separatorColor)),
165215
alignment: .bottom
166216
)
167217
}
@@ -232,6 +282,7 @@ struct CompactDetachedView: View {
232282
Session(name: "reviewer", status: .idle)
233283
],
234284
sessionManager: SessionManager(),
285+
inputMonitor: InputMonitor(),
235286
attachedSession: .constant(nil),
236287
onRequestNewSession: {}
237288
)

0 commit comments

Comments
 (0)