Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: "CodeQL"

on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "27 4 * * 1" # weekly, catches new queries on unchanged code

jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write # upload results to code scanning
packages: read # fetch CodeQL query packs
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: rust
build-mode: none
- language: javascript-typescript
build-mode: none
steps:
- uses: actions/checkout@v7

- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# Uncomment for deeper analysis (more queries, higher false-positive rate):
# queries: security-extended

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.

- **Mention file autocomplete** (#226, @srothgan): Move `@` file matching off the UI path, support raw indexed file and folder mentions with spaces, preserve whole-mention replacement boundaries, and rank shallow project paths ahead of deep dependency matches unless the query explicitly targets the deep path.
- **Atomic input placeholders** (#227, @srothgan): Treat image badges and pasted-text placeholders as shared textarea atoms, keeping cursor movement, deletion, undo/redo, image attachment state, and paste expansion aligned through `tui-textarea-2` `0.12.0` atomic range support.
- **Question and list rendering** (#228, @srothgan): Render markdown lists with indentation instead of injected blank gaps, and show `AskUserQuestion` answers as structured results with selected options, descriptions, previews, and notes.

### Documentation

Expand All @@ -20,10 +21,12 @@ All notable changes to this project will be documented in this file.

### Maintenance

- **Dead test helper cleanup** (#228, @srothgan): Remove stale test-only helpers that kept old production paths alive only through tests.
- **Social discoverability** (#219, @srothgan): Add Open Graph and Twitter Card meta tags with a social preview image to the docs site, and expand the npm package keywords to mirror the repo topics.

### CI and Dependencies

- **CodeQL scanning** (#228, @srothgan): Add a CodeQL workflow for repository code scanning.
- **quinn-proto advisory fix** (#220, @srothgan): Bump `quinn-proto` to `0.11.15` for `RUSTSEC-2026-0185` (remote memory exhaustion from unbounded out-of-order stream reassembly).
- **Security Audit workflow resilience** (#220, @srothgan): Mark the `cargo audit` step `continue-on-error` so newly published advisories still report and file a tracking issue without failing the scheduled Security Audit run.
- **Dependency updates** (#221, #222, #224, #223): Bump `uuid` to `1.23.4`, `anyhow` to `1.0.103`, `tui-markdown` to `0.3.8`, and `knip` to `6.23.0` in `agent-sdk`.
Expand Down
65 changes: 65 additions & 0 deletions agent-sdk/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3681,6 +3681,71 @@ test("requestAskUserQuestionAnswers preserves previews and annotations in update
question_index: 0,
total_questions: 1,
});

const completedQuestionUpdate = events
.map((event) => (event.event === "session_update" ? (event.update as Record<string, unknown>) : undefined))
.find((update) => {
const toolCallUpdate = update?.tool_call_update as Record<string, unknown> | undefined;
const fields = toolCallUpdate?.fields as Record<string, unknown> | undefined;
return toolCallUpdate?.tool_call_id === "tool-question" && fields?.status === "completed";
})?.tool_call_update as Record<string, unknown> | undefined;
const completedFields = completedQuestionUpdate?.fields as Record<string, unknown> | undefined;
assert.deepEqual(completedFields?.raw_input, {
questions: [
{
question: "Pick deployment target",
header: "Target",
multiSelect: true,
options: [
{
label: "Staging",
description: "Low-risk validation",
preview: "Deploy to staging first.",
},
{
label: "Production",
description: "Customer-facing rollout",
preview: "Deploy to production after approval.",
},
],
},
],
answers: {
"Pick deployment target": "Staging, Production",
},
annotations: {
"Pick deployment target": {
preview: "Deploy to staging first.\n\nDeploy to production after approval.",
notes: "Roll out in both environments",
},
},
question_results: [
{
question: "Pick deployment target",
header: "Target",
question_index: 0,
total_questions: 1,
selected_options: [
{
option_id: "question_0",
label: "Staging",
description: "Low-risk validation",
preview: "Deploy to staging first.",
},
{
option_id: "question_1",
label: "Production",
description: "Customer-facing rollout",
preview: "Deploy to production after approval.",
},
],
annotation: {
preview: "Deploy to staging first.\n\nDeploy to production after approval.",
notes: "Roll out in both environments",
},
},
],
});
});

test("normalizeToolKind maps known tool names", () => {
Expand Down
62 changes: 61 additions & 1 deletion agent-sdk/src/bridge/user_interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,41 @@ function askUserQuestionTranscript(
return answers.map((entry) => `${entry.header}: ${entry.answer}\n ${entry.question}`).join("\n");
}

function askUserQuestionCompletedRawInput(
prompts: AskUserQuestionPrompt[],
answers: Record<string, string>,
annotations: Record<string, QuestionAnnotation>,
questionResults: Json[],
): Json {
return {
questions: prompts.map((prompt) => ({
question: prompt.question,
header: prompt.header,
multiSelect: prompt.multiSelect,
options: prompt.options.map((option) => ({
label: option.label,
description: option.description,
...(option.preview ? { preview: option.preview } : {}),
})),
})),
answers,
...(Object.keys(annotations).length > 0 ? { annotations: questionAnnotationsJson(annotations) } : {}),
question_results: questionResults,
};
}

function questionAnnotationsJson(annotations: Record<string, QuestionAnnotation>): { [key: string]: Json } {
return Object.fromEntries(
Object.entries(annotations).map(([question, annotation]) => [
question,
{
...(annotation.preview ? { preview: annotation.preview } : {}),
...(annotation.notes ? { notes: annotation.notes } : {}),
},
]),
);
}

function deriveAnnotation(
selectedOptions: QuestionOption[],
annotation?: QuestionAnnotation,
Expand Down Expand Up @@ -244,6 +279,7 @@ export async function requestAskUserQuestionAnswers(
const answers: Record<string, string> = {};
const annotations: Record<string, QuestionAnnotation> = {};
const transcript: Array<{ header: string; question: string; answer: string }> = [];
const questionResults: Json[] = [];

for (const [index, prompt] of prompts.entries()) {
const promptToolCall = askUserQuestionPromptToolCall(baseToolCall, prompt, index, prompts.length);
Expand Down Expand Up @@ -301,12 +337,36 @@ export async function requestAskUserQuestionAnswers(
annotations[prompt.question] = annotation;
}
transcript.push({ header: prompt.header, question: prompt.question, answer });
questionResults.push({
question: prompt.question,
header: prompt.header,
question_index: index,
total_questions: prompts.length,
selected_options: selectedOptions.map((option) => ({
option_id: option.option_id,
label: option.label,
...(option.description ? { description: option.description } : {}),
...(option.preview ? { preview: option.preview } : {}),
})),
...(annotation
? {
annotation: {
...(annotation.preview ? { preview: annotation.preview } : {}),
...(annotation.notes ? { notes: annotation.notes } : {}),
},
}
: {}),
});

const summary = askUserQuestionTranscript(transcript);
const completed = index + 1 >= prompts.length;
const progressFields: ToolCallUpdateFields = {
status: index + 1 >= prompts.length ? "completed" : "in_progress",
status: completed ? "completed" : "in_progress",
raw_output: summary,
content: [{ type: "content", content: { type: "text", text: summary } }],
...(completed
? { raw_input: askUserQuestionCompletedRawInput(prompts, answers, annotations, questionResults) }
: {}),
};
emitToolCallUpdate(session, toolUseId, progressFields, "summary");
}
Expand Down
33 changes: 33 additions & 0 deletions src/app/events/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4066,6 +4066,39 @@ fn typing_reclaims_input_from_auto_focused_question() {
assert_eq!(question_focus_state(&app, "question-auto"), Some(false));
}

#[test]
fn space_toggles_focused_question_without_reclaiming_input() {
let mut app = make_test_app();
let _response_rx = attach_pending_question(
&mut app,
"question-space",
model::QuestionPrompt::new(
"Choose drinks",
"Drinks",
true,
vec![
model::QuestionOption::new("coffee", "Coffee"),
model::QuestionOption::new("tea", "Tea"),
],
),
true,
);

handle_terminal_event(
&mut app,
Event::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)),
);

assert_eq!(app.focus_owner(), FocusOwner::Permission);
assert_eq!(app.input.text(), "");
let (mi, bi) = app.lookup_tool_call("question-space").expect("question tool call");
let MessageBlock::ToolCall(tc) = app.messages.get(mi).unwrap().blocks.get(bi).unwrap() else {
panic!("expected tool call block");
};
let question = tc.pending_question.as_ref().expect("pending question");
assert!(question.selected_option_indices.contains(&0));
}

#[test]
fn stale_inline_interaction_queue_head_is_pruned_before_enter_response() {
let mut app = make_test_app();
Expand Down
31 changes: 0 additions & 31 deletions src/app/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -571,30 +571,6 @@ fn normalize_line_endings(text: &str) -> String {
out
}

/// Count logical lines for text containing mixed `\n`, `\r`, and `\r\n` endings.
#[cfg(test)]
#[must_use]
fn count_text_lines(text: &str) -> usize {
// Count universal newlines (\n, \r, and \r\n as a single break).
let mut lines = 1;
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\n' => lines += 1,
b'\r' => {
lines += 1;
if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
i += 1;
}
}
_ => {}
}
i += 1;
}
lines
}

/// Count Unicode scalar characters in a text payload.
#[must_use]
pub fn count_text_chars(text: &str) -> usize {
Expand Down Expand Up @@ -1684,13 +1660,6 @@ mod tests {
assert!(!input.append_to_active_paste_block("x"));
}

#[test]
fn count_text_lines_handles_mixed_line_endings() {
assert_eq!(count_text_lines("a\r\nb\nc\rd"), 4);
assert_eq!(count_text_lines("single"), 1);
assert_eq!(count_text_lines("x\r\n"), 2);
}

#[test]
fn count_text_chars_counts_unicode_scalars() {
assert_eq!(count_text_chars("abc"), 3);
Expand Down
7 changes: 7 additions & 0 deletions src/app/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,13 @@ fn should_reclaim_input_focus_before_inline_interaction(app: &App, key: KeyEvent
let question_notes_editing = questions::focused_question_is_editing_notes(app);
match key.code {
KeyCode::Backspace | KeyCode::Delete => !question_notes_editing,
KeyCode::Char(' ')
if questions::has_focused_question(app)
&& is_printable_text_modifiers(key.modifiers)
&& !question_notes_editing =>
{
false
}
KeyCode::Char(_) if is_printable_text_modifiers(key.modifiers) => !question_notes_editing,
_ => false,
}
Expand Down
15 changes: 0 additions & 15 deletions src/app/terminal_runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,6 @@ use std::sync::atomic::{AtomicBool, Ordering};

pub(crate) use release_guard::TerminalReleaseGuard;

#[cfg(test)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SurfaceSessionKind {
Chat,
Fullscreen,
}

enum SurfaceTerminalSession {
Chat(ChatTerminalSession),
Fullscreen(FullscreenTerminalSession),
Expand Down Expand Up @@ -280,14 +273,6 @@ fn restore_terminal_modes(alternate_screen_active: &AtomicBool) -> std::io::Resu
}
}

#[cfg(test)]
fn session_kind_for_surface(surface: SurfaceMode) -> SurfaceSessionKind {
match surface {
SurfaceMode::Chat => SurfaceSessionKind::Chat,
SurfaceMode::Fullscreen(_) => SurfaceSessionKind::Fullscreen,
}
}

fn plan_surface_transition(from: SurfaceMode, to: SurfaceMode) -> SurfaceTransitionPlan {
match (from, to) {
(SurfaceMode::Chat, SurfaceMode::Fullscreen(view)) => {
Expand Down
9 changes: 0 additions & 9 deletions src/app/terminal_runtime/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,6 @@ fn draw_fullscreen_surface_frame_supports_fullscreen_retained_views() {
draw_fullscreen_surface_frame(&mut terminal, &mut app).expect("draw fullscreen view");
}

#[test]
fn session_kind_matches_surface_mode() {
assert_eq!(session_kind_for_surface(SurfaceMode::Chat), SurfaceSessionKind::Chat);
assert_eq!(
session_kind_for_surface(SurfaceMode::Fullscreen(FullscreenView::Trusted)),
SurfaceSessionKind::Fullscreen
);
}

#[test]
fn surface_transition_plan_is_noop_for_chat_to_chat() {
assert_eq!(
Expand Down
Loading
Loading