Skip to content

Commit 87a41c3

Browse files
authored
Merge pull request #64 from mcintyre94/side-chat-feature-for-wisp-05453dfb
Add Quick Actions feature (Quick Chat + Bash tabs)
2 parents 7454523 + d8bbe76 commit 87a41c3

18 files changed

+1063
-8
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit 7386251ed81d42d9ec2ba9a042308e2973efbc30

.claude/worktrees/remove-chevrons

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit 239e7199768530a4490c8f74dfad0ec37910e7e4

QUICK_ACTIONS_PLAN.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Quick Actions Feature Plan
2+
3+
## Overview
4+
5+
Quick Actions is a lightweight interaction layer that sits alongside the main chat, grouping two features: **Side Chat** (ask Claude a question) and **Bash** (run a shell command). Both are ephemeral, single-shot, and don't interrupt the main chat session.
6+
7+
Replaces and supersedes the current side chat implementation on this branch.
8+
9+
---
10+
11+
## Entry Points
12+
13+
### 1. Sprite-level (context menu)
14+
- Long-press / right-click on a Sprite in the dashboard or detail view
15+
- No chat context — just the Sprite and its default working directory (`/home/sprite/project`)
16+
- Service prefix: `wisp-quick-{UUID}` (separate from main chat services)
17+
18+
### 2. Chat-level (toolbar button)
19+
- Toolbar button in the Chat view navigation bar
20+
- Has full chat context: session ID, working directory (may be a worktree path)
21+
- Same service prefix: `wisp-quick-{UUID}`
22+
23+
---
24+
25+
## Tabs
26+
27+
### Side Chat
28+
29+
**From Sprite (no session):**
30+
```
31+
claude -p --output-format stream-json \
32+
--disallowedTools "Bash,Write,Edit,MultiEdit,WebSearch,WebFetch" \
33+
--model MODEL \
34+
--max-turns 3 \
35+
'QUESTION'
36+
```
37+
- Read, Glob, Grep tools available — useful for asking about files without a full chat
38+
- Fresh session, no prior context
39+
40+
**From Chat (with session):**
41+
- Same command, adds `--resume SESSION_ID`
42+
- Side chat Q&A is appended to main session history — benign, doesn't affect the current stream, and gives Claude useful context on future resumes
43+
44+
**Why `--disallowedTools` instead of `--tools ""`:**
45+
Allowing read-only tools (Read, Glob, Grep) makes the feature meaningfully more useful, especially from the Sprite entry point where there's no prior session context. `--max-turns 3` bounds latency.
46+
47+
**UI:**
48+
- Streaming markdown response (`.wisp` theme)
49+
- `ThinkingShimmerView` while waiting for first tokens
50+
- Single input bar, keyboard auto-focused on open
51+
- Multiple questions supported per sheet session (each resumes same session ID)
52+
53+
---
54+
55+
### Bash
56+
57+
**From Sprite and Chat:**
58+
- Runs command via exec on the Sprite
59+
- Shows stdout + stderr in a terminal-styled output area (monospace, dark background)
60+
61+
**From Chat only:**
62+
- "Insert into chat" button appears after command completes
63+
- Formats output as ` $ command\n{output} ` in a code block, prepends to chat input field, dismisses sheet
64+
- User adds context and sends normally
65+
66+
**Keyboard:**
67+
- `.keyboardType(.asciiCapable)` — no emoji, cleaner for shell input
68+
- `.autocorrectionDisabled()` + `.textInputAutocapitalization(.never)`
69+
- Custom keyboard accessory bar with commonly painful-to-type characters: `` / - | > ~ ` $ & * . ``
70+
71+
**UI:**
72+
- Monospace font on both input and output
73+
- Dark background output area (terminal aesthetic)
74+
- Streaming output as the command runs (if using service exec)
75+
76+
---
77+
78+
## Architecture
79+
80+
### New files
81+
```
82+
Wisp/
83+
├── ViewModels/
84+
│ ├── QuickActionsViewModel.swift # Owns context (sprite, session, workingDir), coordinates tabs
85+
│ └── BashQuickViewModel.swift # Exec, output collection, "insert into chat" callback
86+
└── Views/
87+
└── QuickActions/
88+
├── QuickActionsView.swift # Tab container sheet (Side Chat | Bash)
89+
├── SideChatView.swift # Refactored from current SideChatView
90+
└── BashQuickView.swift # Bash tab UI, keyboard accessory bar
91+
```
92+
93+
### Modified files
94+
- `ChatView.swift` — swap `onSideChat``onQuickActions` toolbar button; present `QuickActionsView`
95+
- `ChatInputBar.swift` — remove `onSideChat`, add `onQuickActions` (or remove entirely — toolbar button may be sufficient)
96+
- `SpriteDetailView.swift` / dashboard — add Quick Actions to Sprite context menu
97+
- `SideChatViewModel.swift` — update `--tools ""``--disallowedTools ...`, add `--max-turns 3`
98+
99+
### Removed
100+
- `ChatInputBar.onSideChat` (replaced by toolbar button)
101+
- Current `SideChatView` and its sheet wiring in `ChatView` (replaced by `QuickActionsView`)
102+
103+
---
104+
105+
## Service Cleanup
106+
- Both tabs use `wisp-quick-{UUID}` service names
107+
- Services deleted on completion or sheet dismiss (cancel in-flight tasks)
108+
- Main chat services (`wisp-claude-{UUID}`) are unaffected
109+
110+
---
111+
112+
## Open Questions
113+
114+
1. **Bash exec mechanism**`runExec()` (WebSocket, simpler, full output on completion) vs `streamService()` (streaming output as it runs). Streaming feels better UX-wise for longer commands; `runExec()` is simpler. Worth deciding before building.
115+
116+
2. **Bash command history** — remember recent commands within a session? Nice-to-have, skip for MVP.
117+
118+
3. **Tab default** — should the sheet remember which tab was last open, or always default to Side Chat?
119+
120+
4. **Side chat from Sprite with no Claude token** — show an error state or hide the side chat tab entirely?

Wisp.xcodeproj/project.pbxproj

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@
3838
A21BF0D8D910819DD5591E88 /* ExecSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2554BB4DE8EC315CA48767BF /* ExecSession.swift */; };
3939
A610B5413A578D4AA424595D /* ToolUseCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A278B4716095105A7024CF8 /* ToolUseCardView.swift */; };
4040
A92BE24BEAF80FF31B4ACA00 /* CheckpointsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AD6D912964E32FC8685EA3 /* CheckpointsViewModel.swift */; };
41+
AA10BB20CC30DD40EE50FF01 /* QuickChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10BB20CC30DD40EE50FF02 /* QuickChatViewModel.swift */; };
42+
AA3100BB00CC00DD00EE0001 /* QuickActionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3100BB00CC00DD00EE0002 /* QuickActionsViewModel.swift */; };
43+
AA3200BB00CC00DD00EE0001 /* BashQuickViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3200BB00CC00DD00EE0002 /* BashQuickViewModel.swift */; };
44+
AA3300BB00CC00DD00EE0001 /* QuickActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3300BB00CC00DD00EE0002 /* QuickActionsView.swift */; };
45+
AA3400BB00CC00DD00EE0001 /* QuickChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3400BB00CC00DD00EE0002 /* QuickChatView.swift */; };
46+
AA3500BB00CC00DD00EE0001 /* BashQuickView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3500BB00CC00DD00EE0002 /* BashQuickView.swift */; };
47+
AA3700BB00CC00DD00EE0001 /* BashQuickViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3700BB00CC00DD00EE0002 /* BashQuickViewModelTests.swift */; };
48+
FE9876543210ABCDEF987601 /* QuickChatViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9876543210ABCDEF987602 /* QuickChatViewModelTests.swift */; };
4149
AA00BB11CC22DD3300000001 /* WispAskCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00BB11CC22DD3300000002 /* WispAskCard.swift */; };
4250
AA11BB22CC33DD44EE55FF02 /* GitHubDeviceFlowClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF01 /* GitHubDeviceFlowClient.swift */; };
4351
AA11BB22CC33DD44EE55FF04 /* GitHubDeviceFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF03 /* GitHubDeviceFlowView.swift */; };
@@ -234,6 +242,14 @@
234242
F7A8B9C0D1E2F30415263749 /* PlanCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlanCardView.swift; sourceTree = "<group>"; };
235243
FF11AA22BB33CC44DD55EE01 /* ChatViewModelHelpersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModelHelpersTests.swift; sourceTree = "<group>"; };
236244
FF11AA22BB33CC44DD55EE03 /* ChatViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModelTests.swift; sourceTree = "<group>"; };
245+
AA10BB20CC30DD40EE50FF02 /* QuickChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickChatViewModel.swift; sourceTree = "<group>"; };
246+
AA3100BB00CC00DD00EE0002 /* QuickActionsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickActionsViewModel.swift; sourceTree = "<group>"; };
247+
AA3200BB00CC00DD00EE0002 /* BashQuickViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BashQuickViewModel.swift; sourceTree = "<group>"; };
248+
AA3300BB00CC00DD00EE0002 /* QuickActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickActionsView.swift; sourceTree = "<group>"; };
249+
AA3400BB00CC00DD00EE0002 /* QuickChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickChatView.swift; sourceTree = "<group>"; };
250+
AA3500BB00CC00DD00EE0002 /* BashQuickView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BashQuickView.swift; sourceTree = "<group>"; };
251+
AA3700BB00CC00DD00EE0002 /* BashQuickViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BashQuickViewModelTests.swift; sourceTree = "<group>"; };
252+
FE9876543210ABCDEF987602 /* QuickChatViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickChatViewModelTests.swift; sourceTree = "<group>"; };
237253
/* End PBXFileReference section */
238254

239255
/* Begin PBXFrameworksBuildPhase section */
@@ -407,6 +423,9 @@
407423
EF49F7E0F4AC02FDF496D379 /* DashboardViewModel.swift */,
408424
C3D4E5F6A7B80920415C6D7E /* SpriteChatListViewModel.swift */,
409425
AC9BFD0F0AC39C136C9E828A /* SpriteOverviewViewModel.swift */,
426+
AA3200BB00CC00DD00EE0002 /* BashQuickViewModel.swift */,
427+
AA10BB20CC30DD40EE50FF02 /* QuickChatViewModel.swift */,
428+
AA3100BB00CC00DD00EE0002 /* QuickActionsViewModel.swift */,
410429
);
411430
path = ViewModels;
412431
sourceTree = "<group>";
@@ -451,6 +470,8 @@
451470
DD11EE22FF334455AA66BB03 /* ServiceTypesTests.swift */,
452471
EE22FF334455AA66BB770001 /* ExecSessionURLTests.swift */,
453472
CC11DD22EE33FF4400110005 /* FileEntryTests.swift */,
473+
AA3700BB00CC00DD00EE0002 /* BashQuickViewModelTests.swift */,
474+
FE9876543210ABCDEF987602 /* QuickChatViewModelTests.swift */,
454475
);
455476
path = WispTests;
456477
sourceTree = "<group>";
@@ -463,11 +484,22 @@
463484
902EF50E1CB6723AF748F886 /* Dashboard */,
464485
A5E41B8F9DACBEC637F8192A /* Settings */,
465486
90D05FE8D8DC0D59A74BAD85 /* SpriteDetail */,
487+
AA3600BB00CC00DD00EE0001 /* QuickActions */,
466488
A7B8C9D0E1F2A3B4C5D6E7F8 /* WebView */,
467489
);
468490
path = Views;
469491
sourceTree = "<group>";
470492
};
493+
AA3600BB00CC00DD00EE0001 /* QuickActions */ = {
494+
isa = PBXGroup;
495+
children = (
496+
AA3300BB00CC00DD00EE0002 /* QuickActionsView.swift */,
497+
AA3400BB00CC00DD00EE0002 /* QuickChatView.swift */,
498+
AA3500BB00CC00DD00EE0002 /* BashQuickView.swift */,
499+
);
500+
path = QuickActions;
501+
sourceTree = "<group>";
502+
};
471503
B22C752D93810C377FF981DD /* Chat */ = {
472504
isa = PBXGroup;
473505
children = (
@@ -746,6 +778,12 @@
746778
C4D5E6F7A8B90A41637E8FA2 /* CheckpointMarkerView.swift in Sources */,
747779
E1F2A3B4C5D6E7F800000014 /* UnifiedDiffView.swift in Sources */,
748780
C575C7232C5FCA1A3F18A9D8 /* WispApp.swift in Sources */,
781+
AA3500BB00CC00DD00EE0001 /* BashQuickView.swift in Sources */,
782+
AA3200BB00CC00DD00EE0001 /* BashQuickViewModel.swift in Sources */,
783+
AA3300BB00CC00DD00EE0001 /* QuickActionsView.swift in Sources */,
784+
AA3100BB00CC00DD00EE0001 /* QuickActionsViewModel.swift in Sources */,
785+
AA3400BB00CC00DD00EE0001 /* QuickChatView.swift in Sources */,
786+
AA10BB20CC30DD40EE50FF01 /* QuickChatViewModel.swift in Sources */,
749787
E1F2A3B4C5D6E7F800000012 /* WispCodeHighlighter.swift in Sources */,
750788
E1F2A3B4C5D6E7F800000013 /* WispMarkdownTheme.swift in Sources */,
751789
);
@@ -774,6 +812,8 @@
774812
E7F8A9B0C1D2E3F405A6B7C2 /* ChatNameTests.swift in Sources */,
775813
0DFF41BB14AC3922E95DF781 /* WorktreeTests.swift in Sources */,
776814
CC11DD22EE33FF4400110006 /* FileEntryTests.swift in Sources */,
815+
AA3700BB00CC00DD00EE0001 /* BashQuickViewModelTests.swift in Sources */,
816+
FE9876543210ABCDEF987601 /* QuickChatViewModelTests.swift in Sources */,
777817
);
778818
runOnlyForDeploymentPostprocessing = 0;
779819
};

Wisp/Utilities/Extensions.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
11
import Foundation
2+
#if canImport(UIKit)
3+
import UIKit
4+
#endif
5+
6+
var isRunningOnMac: Bool {
7+
#if targetEnvironment(macCatalyst)
8+
true
9+
#else
10+
ProcessInfo.processInfo.isiOSAppOnMac
11+
#endif
12+
}
213

314
extension JSONDecoder {
415
static func apiDecoder() -> JSONDecoder {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import Foundation
2+
import os
3+
4+
private let logger = Logger(subsystem: "com.wisp.app", category: "BashQuick")
5+
6+
@Observable
7+
@MainActor
8+
final class BashQuickViewModel {
9+
let spriteName: String
10+
let workingDirectory: String
11+
12+
var command = ""
13+
private(set) var output = ""
14+
private(set) var isRunning = false
15+
private(set) var error: String?
16+
private(set) var lastCommand = ""
17+
18+
private var streamTask: Task<Void, Never>?
19+
20+
init(spriteName: String, workingDirectory: String) {
21+
self.spriteName = spriteName
22+
self.workingDirectory = workingDirectory
23+
}
24+
25+
func send(apiClient: SpritesAPIClient) {
26+
let cmd = command.trimmingCharacters(in: .whitespacesAndNewlines)
27+
guard !cmd.isEmpty, !isRunning else { return }
28+
29+
lastCommand = cmd
30+
command = ""
31+
output = ""
32+
error = nil
33+
isRunning = true
34+
35+
streamTask = Task {
36+
await executeCommand(cmd, apiClient: apiClient)
37+
}
38+
}
39+
40+
func cancel(apiClient: SpritesAPIClient) {
41+
streamTask?.cancel()
42+
streamTask = nil
43+
isRunning = false
44+
}
45+
46+
func insertFormatted() -> String {
47+
BashQuickViewModel.formatInsert(command: lastCommand, output: output)
48+
}
49+
50+
static func formatInsert(command: String, output: String) -> String {
51+
"```\n$ \(command)\n\(output.trimmingCharacters(in: .newlines))\n```"
52+
}
53+
54+
// MARK: - Private
55+
56+
private func executeCommand(_ cmd: String, apiClient: SpritesAPIClient) async {
57+
let fullCommand = "cd \(workingDirectory) 2>/dev/null || true; \(cmd)"
58+
let serviceName = "wisp-quick-\(UUID().uuidString.prefix(8).lowercased())"
59+
let config = ServiceRequest(cmd: "bash", args: ["-c", fullCommand], needs: nil, httpPort: nil)
60+
let stream = apiClient.streamService(spriteName: spriteName, serviceName: serviceName, config: config)
61+
62+
do {
63+
streamLoop: for try await event in stream {
64+
guard !Task.isCancelled else { break streamLoop }
65+
switch event.type {
66+
case .stdout, .stderr:
67+
if let text = event.data {
68+
output += text
69+
}
70+
case .error:
71+
if output.isEmpty {
72+
error = event.data ?? "Service error"
73+
}
74+
case .complete:
75+
break streamLoop
76+
default:
77+
break
78+
}
79+
}
80+
} catch {
81+
if !Task.isCancelled {
82+
self.error = "Connection error"
83+
logger.error("Bash quick stream error: \(error.localizedDescription)")
84+
}
85+
}
86+
87+
Task {
88+
try? await apiClient.deleteService(spriteName: spriteName, serviceName: serviceName)
89+
}
90+
91+
if !Task.isCancelled {
92+
isRunning = false
93+
}
94+
}
95+
}

Wisp/ViewModels/ChatViewModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ final class ChatViewModel {
5454
var isLoadingHistory = false
5555

5656
private var serviceName: String
57-
private var sessionId: String?
57+
private(set) var sessionId: String?
5858
var workingDirectory: String
5959
private(set) var worktreePath: String?
6060
/// True when any chat for this sprite has had a worktree created, indicating the
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import Foundation
2+
3+
@Observable
4+
@MainActor
5+
final class QuickActionsViewModel: Identifiable {
6+
let id = UUID()
7+
let spriteName: String
8+
let sessionId: String?
9+
let workingDirectory: String
10+
11+
let quickChatViewModel: QuickChatViewModel
12+
let bashViewModel: BashQuickViewModel
13+
14+
init(spriteName: String, sessionId: String?, workingDirectory: String) {
15+
self.spriteName = spriteName
16+
self.sessionId = sessionId
17+
self.workingDirectory = workingDirectory
18+
self.quickChatViewModel = QuickChatViewModel(
19+
spriteName: spriteName,
20+
sessionId: sessionId,
21+
workingDirectory: workingDirectory
22+
)
23+
self.bashViewModel = BashQuickViewModel(
24+
spriteName: spriteName,
25+
workingDirectory: workingDirectory
26+
)
27+
}
28+
}

0 commit comments

Comments
 (0)