Skip to content

Commit 274853c

Browse files
authored
Merge pull request #226 from SoftwareSavants/undefined-leftpad-684
feat(claude): /btw side questions for ephemeral mid-stream Q&A
2 parents e01b741 + 5f372f1 commit 274853c

15 files changed

Lines changed: 606 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- Side question (`/btw`) for Claude sessions: ask an ephemeral question without polluting the transcript. Type `/btw <question>` in the composer (auto-completes from the slash-command palette), or - while the agent is streaming and you've scrolled to the bottom of the chat - click the small "Ask sideways /btw" pill that takes the place of the scroll-to-bottom button (same screen real estate, swapped by context). Q/A are not persisted to `output_lines` and never re-enter conversation history on resume; `synthetic` fallbacks are badged distinctly. Wires through a new `ask_side_question` Tauri command on top of the existing control-request plumbing.
56
- Cmd+Alt+Left/Right now cycles between sessions in the current task when viewing a session (wraps at edges). The shortcut still cycles editor tabs when a file is open, mirroring Ctrl+Tab's view-aware behavior
67
- File edits detected by the worktree watcher now trigger a debounced local git refresh, so status/commits/branch state update automatically as you change code without hitting GitHub
78
- Local git refresh now uses non-locking read-only git commands, so viewing status no longer rewrites `.git/index` and self-triggers endless `git-local-changed` watcher loops

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Pluggable agent backend with first-class support for Claude Code and Codex (plan
1717
## Stay in control without babysitting
1818

1919
- **Steps** - queue follow-up prompts while an agent is working; arm them to auto-send on idle, or fire manually
20+
- **Side question (`/btw`)** - type `/btw <question>` mid-stream (or click the "Ask sideways" button that appears in the corner of the chat while Claude is working) to ask an ephemeral question - the answer shows in a floating panel and never enters conversation history
2021
- **Fork from any message** - hover any past reply to rewind the conversation in place, or branch into a new task with the worktree restored to the code as it was at that exact turn. Verun takes a git snapshot at every turn, so you can undo an entire direction - not just a line of code
2122
- **Tool approval** - configurable trust levels per task: supervised, normal, or full auto
2223
- **UI / Terminal toggle for Claude** - flip any Claude session into a real PTY running `claude --resume` to use the unmodified TUI, while history, fork, and search keep working because Verun tails the on-disk transcript. The mode is sticky per session with an app-wide default in Settings

src-tauri/src/ipc.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,20 @@ pub async fn get_session_context_usage(
935935
task::get_session_context_usage(active.inner(), pending_ctrl.inner(), &session_id).await
936936
}
937937

938+
/// Ask Claude an ephemeral side question (`/btw`) while a turn is mid-stream.
939+
/// Q/A are not persisted and never enter the conversation transcript.
940+
/// Returns `Ok(None)` when the CLI couldn't answer (e.g. before the first
941+
/// turn completes on a resumed session).
942+
#[tauri::command]
943+
pub async fn ask_side_question(
944+
active: State<'_, ActiveMap>,
945+
pending_ctrl: State<'_, PendingControlResponses>,
946+
session_id: String,
947+
question: String,
948+
) -> Result<Option<task::SideQuestionResponse>, String> {
949+
task::send_side_question(active.inner(), pending_ctrl.inner(), &session_id, &question).await
950+
}
951+
938952
/// Return session IDs that currently have an active process
939953
#[tauri::command]
940954
pub async fn get_active_sessions(active: State<'_, ActiveMap>) -> Result<Vec<String>, String> {

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ pub fn run() {
285285
ipc::abort_message,
286286
ipc::interrupt_session,
287287
ipc::get_session_context_usage,
288+
ipc::ask_side_question,
288289
ipc::get_active_sessions,
289290
ipc::respond_to_approval,
290291
ipc::get_pending_approvals,

src-tauri/src/task.rs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2955,6 +2955,91 @@ pub async fn get_session_context_usage(
29552955
}
29562956
}
29572957

2958+
/// Response shape for an ephemeral `/btw` side question. `synthetic = true`
2959+
/// signals a fallback canned reply (e.g. when the turn isn't far enough along
2960+
/// to answer meaningfully).
2961+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2962+
pub struct SideQuestionResponse {
2963+
pub response: String,
2964+
pub synthetic: bool,
2965+
}
2966+
2967+
/// Build the inner `request` body for a `side_question` control_request.
2968+
/// Pure (no I/O) so the wire shape can be asserted in unit tests.
2969+
fn build_side_question_request(question: &str) -> serde_json::Value {
2970+
serde_json::json!({
2971+
"subtype": "side_question",
2972+
"question": question,
2973+
})
2974+
}
2975+
2976+
/// Parse the CLI's `control_response` payload for a `side_question` request.
2977+
/// The Anthropic SDK envelope is `{"response": {"subtype": "success",
2978+
/// "request_id": ..., "response": {"response": "...", "synthetic": false}}}`,
2979+
/// but `stream.rs` already unwraps one `response` layer before resolving the
2980+
/// oneshot, so what arrives here is the inner `{"response": ..., "synthetic": ...}`.
2981+
/// `null` at `response` means the CLI couldn't answer (e.g. before the first
2982+
/// turn completes on a resumed session): returns `Ok(None)`.
2983+
fn parse_side_question_response(
2984+
value: &serde_json::Value,
2985+
) -> Result<Option<SideQuestionResponse>, String> {
2986+
let response_field = value
2987+
.get("response")
2988+
.ok_or_else(|| "side_question reply missing `response` field".to_string())?;
2989+
if response_field.is_null() {
2990+
return Ok(None);
2991+
}
2992+
let response = response_field
2993+
.as_str()
2994+
.ok_or_else(|| "side_question reply `response` is not a string".to_string())?
2995+
.to_string();
2996+
let synthetic = value
2997+
.get("synthetic")
2998+
.and_then(|v| v.as_bool())
2999+
.unwrap_or(false);
3000+
Ok(Some(SideQuestionResponse {
3001+
response,
3002+
synthetic,
3003+
}))
3004+
}
3005+
3006+
/// Ask Claude an ephemeral side question while a turn is mid-stream. The Q/A
3007+
/// is not added to conversation history and never persisted to `output_lines`.
3008+
/// Returns `Ok(None)` when the CLI returned a null response (e.g. nothing to
3009+
/// answer about yet on a freshly resumed session).
3010+
pub async fn send_side_question(
3011+
active: &ActiveMap,
3012+
pending: &PendingControlResponses,
3013+
session_id: &str,
3014+
question: &str,
3015+
) -> Result<Option<SideQuestionResponse>, String> {
3016+
let stdin = active
3017+
.get(session_id)
3018+
.map(|proc| proc.stdin.clone())
3019+
.ok_or_else(|| "No active session".to_string())?;
3020+
3021+
let (request_id, rx) = send_control_request(
3022+
&stdin,
3023+
Some(pending),
3024+
build_side_question_request(question),
3025+
)
3026+
.await?;
3027+
let rx = rx.expect("pending map provided");
3028+
3029+
let result = tokio::time::timeout(Duration::from_secs(60), rx).await;
3030+
3031+
if result.is_err() {
3032+
pending.remove(&request_id);
3033+
}
3034+
3035+
match result {
3036+
Ok(Ok(Ok(v))) => parse_side_question_response(&v),
3037+
Ok(Ok(Err(e))) => Err(e),
3038+
Ok(Err(_)) => Err("Session closed before responding".to_string()),
3039+
Err(_) => Err("Timed out waiting for CLI response".to_string()),
3040+
}
3041+
}
3042+
29583043
fn title_generation_args(prompt: &str) -> Vec<String> {
29593044
vec![
29603045
"-p".into(),
@@ -3661,4 +3746,76 @@ mod tests {
36613746
assert!(args.iter().any(|a| a == "--no-session-persistence"));
36623747
assert!(args.iter().any(|a| a == "haiku"));
36633748
}
3749+
3750+
#[test]
3751+
fn side_question_request_has_correct_wire_shape() {
3752+
let body = build_side_question_request("what was that var name?");
3753+
assert_eq!(body["subtype"], "side_question");
3754+
assert_eq!(body["question"], "what was that var name?");
3755+
// No extra top-level fields.
3756+
let obj = body.as_object().expect("object");
3757+
assert_eq!(obj.len(), 2);
3758+
}
3759+
3760+
// Note on shape: stream.rs unwraps one `response` layer before resolving
3761+
// the oneshot, so what `parse_side_question_response` sees is the inner
3762+
// `{"response": "...", "synthetic": ...}` object - not the SDK's outer
3763+
// `{"response": {"response": ..., "synthetic": ...}}` envelope.
3764+
3765+
#[test]
3766+
fn side_question_response_parses_full_payload() {
3767+
let v = serde_json::json!({ "response": "it was foo", "synthetic": false });
3768+
let parsed = parse_side_question_response(&v).unwrap();
3769+
assert_eq!(
3770+
parsed,
3771+
Some(SideQuestionResponse {
3772+
response: "it was foo".to_string(),
3773+
synthetic: false
3774+
})
3775+
);
3776+
}
3777+
3778+
#[test]
3779+
fn side_question_response_parses_synthetic_true() {
3780+
let v = serde_json::json!({ "response": "fallback", "synthetic": true });
3781+
let parsed = parse_side_question_response(&v).unwrap().unwrap();
3782+
assert!(parsed.synthetic);
3783+
assert_eq!(parsed.response, "fallback");
3784+
}
3785+
3786+
#[test]
3787+
fn side_question_response_defaults_synthetic_to_false_when_missing() {
3788+
let v = serde_json::json!({ "response": "ok" });
3789+
let parsed = parse_side_question_response(&v).unwrap().unwrap();
3790+
assert!(!parsed.synthetic);
3791+
}
3792+
3793+
#[test]
3794+
fn side_question_response_null_response_returns_none() {
3795+
let v = serde_json::json!({ "response": null, "synthetic": false });
3796+
let parsed = parse_side_question_response(&v).unwrap();
3797+
assert_eq!(parsed, None);
3798+
}
3799+
3800+
#[test]
3801+
fn side_question_response_missing_response_field_errors() {
3802+
let v = serde_json::json!({ "synthetic": true });
3803+
assert!(parse_side_question_response(&v).is_err());
3804+
}
3805+
3806+
#[test]
3807+
fn side_question_response_non_string_response_errors() {
3808+
let v = serde_json::json!({ "response": 42 });
3809+
assert!(parse_side_question_response(&v).is_err());
3810+
}
3811+
3812+
#[tokio::test]
3813+
async fn send_side_question_errors_when_session_not_active() {
3814+
let active = new_active_map();
3815+
let pending = new_pending_control_responses();
3816+
let err = send_side_question(&active, &pending, "no-such-session", "hi")
3817+
.await
3818+
.unwrap_err();
3819+
assert!(err.contains("No active session"), "got: {err}");
3820+
}
36643821
}

src/components/ChatView.tsx

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import { createStore, produce, reconcile } from 'solid-js/store'
33
import { clsx } from 'clsx'
44
import { renderMarkdown, handleMarkdownLinkClick, getWorktreePath } from '../lib/markdown'
55
import type { OutputItem, SessionStatus, AttachmentRef } from '../types'
6-
import { ChevronDown, ChevronRight, AlertTriangle, Copy, Check, ArrowUp, ArrowDown, X, GitBranch, RotateCw, Plus, ChevronUp } from 'lucide-solid'
6+
import { ChevronDown, ChevronRight, AlertTriangle, Copy, Check, ArrowUp, ArrowDown, X, GitBranch, RotateCw, Plus, ChevronUp, MessageCircleQuestion } from 'lucide-solid'
77
import { FileMentionBadge } from './FileMentionBadge'
88
import { ImageViewer } from './ImageViewer'
99
import { BlobImage } from './BlobImage'
1010
import { Popover } from './Popover'
1111
import { parseMentions } from '../lib/mentions'
1212
import * as ipc from '../lib/ipc'
13+
import { openSideQuestion, sideQuestionPanel } from '../store/sideQuestion'
1314
import { addToast, setSelectedTaskId, setSelectedSessionIdForTask } from '../store/ui'
1415
import { setSessions, loadOutputLines, loadOlderOutputLines, hasMoreOutputLines, sendMessage, createSession } from '../store/sessions'
1516
import { planModeForSession, thinkingModeForSession, fastModeForSession } from '../store/sessionContext'
@@ -1286,15 +1287,64 @@ export const ChatView: Component<Props> = (props) => {
12861287
</Show>
12871288
</div>
12881289
</div>
1289-
<Show when={!isAtBottom()}>
1290-
<button
1291-
class="absolute bottom-4 right-4 z-10 w-7 h-7 flex items-center justify-center rounded-full bg-surface-2 ring-1 ring-outline/10 shadow-lg text-text-dim hover:text-text-secondary hover:ring-outline/20 transition-colors"
1292-
onClick={scrollToBottom}
1293-
title="Scroll to bottom"
1294-
>
1295-
<ChevronDown size={15} />
1296-
</button>
1297-
</Show>
1290+
{(() => {
1291+
const hasAiTurn = () => props.output.some((i) => i.kind !== 'userMessage')
1292+
const showBtw = () =>
1293+
isAtBottom()
1294+
&& props.agentType === 'claude'
1295+
&& !!props.sessionId
1296+
&& hasAiTurn()
1297+
&& sideQuestionPanel()?.sessionId !== props.sessionId
1298+
const showScroll = () => !isAtBottom()
1299+
// Both buttons share the same bottom-right pivot so they appear to
1300+
// morph out of/into the same anchor point instead of cross-fading
1301+
// as two competing rectangles.
1302+
const morph =
1303+
'absolute bottom-0 right-0 origin-bottom-right will-change-[transform,opacity] '
1304+
+ 'transition-[opacity,transform] duration-[260ms] '
1305+
+ 'ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none'
1306+
return (
1307+
<div class="absolute bottom-4 right-4 z-10 pointer-events-none">
1308+
<button
1309+
class={clsx(
1310+
morph,
1311+
'w-7 h-7 flex items-center justify-center rounded-full bg-surface-2 ring-1 ring-outline/10 shadow-lg text-text-dim hover:text-text-secondary hover:ring-outline/20',
1312+
showScroll()
1313+
? 'opacity-100 scale-100 pointer-events-auto'
1314+
: 'opacity-0 scale-75 pointer-events-none',
1315+
)}
1316+
onClick={scrollToBottom}
1317+
title="Scroll to bottom"
1318+
aria-hidden={!showScroll()}
1319+
tabindex={showScroll() ? 0 : -1}
1320+
>
1321+
<ChevronDown size={15} />
1322+
</button>
1323+
<button
1324+
class={clsx(
1325+
morph,
1326+
'h-7 px-2.5 flex items-center gap-1.5 whitespace-nowrap rounded-full bg-surface-2 ring-1 ring-outline/10 shadow-lg text-text-dim hover:text-text-secondary hover:ring-outline/20 text-[11px]',
1327+
showBtw()
1328+
? 'opacity-100 scale-100 pointer-events-auto'
1329+
: 'opacity-0 scale-75 pointer-events-none',
1330+
)}
1331+
onClick={() => {
1332+
const sid = props.sessionId
1333+
if (sid) openSideQuestion(sid)
1334+
}}
1335+
title="Ask Claude an ephemeral side question (does not enter conversation history)"
1336+
aria-hidden={!showBtw()}
1337+
tabindex={showBtw() ? 0 : -1}
1338+
>
1339+
<MessageCircleQuestion size={12} />
1340+
<span>
1341+
Ask sideways{' '}
1342+
<code class="ml-0.5 px-1 py-0.5 rounded bg-surface-3 text-text-dim text-[9px]">/btw</code>
1343+
</span>
1344+
</button>
1345+
</div>
1346+
)
1347+
})()}
12981348
<Show when={viewerImage()}>
12991349
{(img) => (
13001350
<ImageViewer

src/components/MessageInput.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import * as ipc from '../lib/ipc'
2121
import { Popover } from './Popover'
2222
import { ImageViewer } from './ImageViewer'
2323
import { BlobImage } from './BlobImage'
24+
import { openSideQuestion } from '../store/sideQuestion'
2425
import { serializeAttachments, deserializeAttachments, uploadAttachments } from '../lib/binary'
2526
import { formatCost as fmtCost, formatTokens as fmtTokens, formatDurationShort } from '../lib/format'
2627

@@ -1001,18 +1002,33 @@ export const MessageInput: Component<Props> = (props) => {
10011002
setPlanMode(!planMode())
10021003
break
10031004
}
1005+
case 'btw': {
1006+
const sid = props.sessionId
1007+
if (!sid) break
1008+
// Strip the `/btw ` prefix; whatever's left becomes the side question.
1009+
// Empty rest means "open the panel" - pass undefined so the panel
1010+
// restores the remembered Q/A instead of blanking it.
1011+
const rest = message().trim().replace(/^\/btw\s*/i, '')
1012+
openSideQuestion(sid, rest.length > 0 ? rest : undefined, rest.length > 0)
1013+
break
1014+
}
10041015
}
10051016
setMessage('')
10061017
setShowPalette(false)
10071018
}
10081019

10091020
const handleCommandSelect = (cmd: Command) => {
1010-
if (cmd.category === 'app') {
1021+
// /btw is an app command but takes a free-form question, so insert the
1022+
// prefix and let the user type the question instead of firing immediately
1023+
// with empty text.
1024+
if (cmd.category === 'app' && cmd.name !== 'btw') {
10111025
handleAppCommand(cmd)
10121026
} else {
10131027
setMessage(`/${cmd.name} `)
1028+
setInputContent(`/${cmd.name} `)
10141029
setShowPalette(false)
10151030
inputRef?.focus()
1031+
setCursorToEnd()
10161032
}
10171033
}
10181034

@@ -1308,7 +1324,7 @@ export const MessageInput: Component<Props> = (props) => {
13081324
const msg = message().trim()
13091325
if (msg.startsWith('/')) {
13101326
const cmdName = msg.slice(1).split(/\s+/)[0]
1311-
const appCmds = ['new-session', 'clear', 'plan']
1327+
const appCmds = ['new-session', 'clear', 'plan', 'btw']
13121328
if (appCmds.includes(cmdName)) {
13131329
handleAppCommand({ name: cmdName, description: '', category: 'app' })
13141330
return

0 commit comments

Comments
 (0)