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
13 changes: 13 additions & 0 deletions CHAGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ Keep proposed and unmerged changes out of released sections.

## [Unreleased]

- Proposed [PR #1039](https://github.com/BitterSecurity/Decepticon/pull/1039):
TUI error-recovery polish — esc-to-dismiss error banners, number-key question
selection (including the "Other" free-text entry, previously only reachable with
the arrow keys), a Blue footer that only renders while monitoring is relevant, removal of
dead client code (SessionPicker, SpinnerIcon) and English blue notification templates.
Auto-reconnect and transcript interactivity are deferred as designed follow-ups.
Partially addresses issue #1034.
- Proposed [PR #1050](https://github.com/BitterSecurity/Decepticon/pull/1050):
page `assistants.search` in the MCP `decepticon_list_graphs` tool so every
exposed graph is returned. The LangGraph SDK defaults to `limit=10`, which
Expand Down Expand Up @@ -107,6 +114,12 @@ Keep proposed and unmerged changes out of released sections.
finalized by the matching values snapshot, and in-flight tool calls render as a compact
`● Tool(args…) …` header. Pure Ink primitives, no platform-specific terminal behavior.
Addresses issue #1032.
- Proposed [PR #1039](https://github.com/BitterSecurity/Decepticon/pull/1039):
TUI error-recovery polish — esc-to-dismiss error banners, number-key question
selection, a Blue footer that only renders while monitoring is relevant, removal of
dead client code (SessionPicker, SpinnerIcon) and English blue notification templates.
Auto-reconnect and transcript interactivity are deferred as designed follow-ups.
Partially addresses issue #1034.
- Proposed [PR #1045](https://github.com/BitterSecurity/Decepticon/pull/1045):
bind MCP `decepticon_start_engagement` to an explicit `workspace_path`
(default `/workspace`, traversal-validated) and return the binding in
Expand Down
64 changes: 64 additions & 0 deletions clients/cli/src/components/QuestionPicker.numbers.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
import React from "react";
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { QuestionPicker } from "./QuestionPicker.js";
import type { ActiveQuestion } from "../types.js";

const keyboard = vi.hoisted<{
handler: null | ((input: string, key: Record<string, boolean>) => void);
}>(() => ({ handler: null }));
vi.mock("ink", async () => {
const React = await import("react");
return {
Box: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
Text: ({ children }: { children: React.ReactNode }) => React.createElement("span", null, children),
useInput: (handler: typeof keyboard.handler) => { keyboard.handler = handler; },
};
});
vi.mock("../hooks/useTerminalSize.js", () => ({ useTerminalSize: () => ({ columns: 80, rows: 24 }) }));
vi.mock("@inkjs/ui", () => ({
TextInput: ({ placeholder }: { placeholder: string }) => React.createElement("div", null, placeholder),
}));

afterEach(() => cleanup());

const question: ActiveQuestion = {
sourceId: "q-1",
question: "Pick one",
header: "Q",
options: [
{ label: "alpha" },
{ label: "beta" },
{ label: "gamma" },
],
multiSelect: false,
allowOther: true,
};

function picker(onSubmit = () => {}) {
const view = render(<QuestionPicker question={question} onSubmit={onSubmit} onCancel={() => {}} />);
if (!keyboard.handler) throw new Error("QuestionPicker did not register input");
return view;
}

it("submits an option by its number key", () => {
const onSubmit = vi.fn();
picker(onSubmit);
act(() => keyboard.handler?.("2", {}));
expect(onSubmit).toHaveBeenCalledWith("beta");
});

it("opens the Other text input by its number key", () => {
const view = picker();
act(() => keyboard.handler?.("4", {}));
expect(view.container.textContent).toContain("esc: back");
});

it("ignores number keys past the last entry", () => {
const onSubmit = vi.fn();
const view = picker(onSubmit);
act(() => keyboard.handler?.("9", {}));
expect(onSubmit).not.toHaveBeenCalled();
expect(view.container.textContent).not.toContain("esc: back");
});
26 changes: 26 additions & 0 deletions clients/cli/src/components/QuestionPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,32 @@ export const QuestionPicker = React.memo(function QuestionPicker({
return;
}

// The option list is numbered in the UI (including the "Other" entry);
// accept the same number keys for quick selection without changing the
// arrow-key flow.
const number = Number(_input);
if (Number.isInteger(number) && number >= 1 && number <= totalEntries) {
const next = number - 1;
if (allowOther && next === otherIndex) {
setCursor(next);
setOtherMode(true);
return;
}
if (multiSelect) {
setCursor(next);
setChecked((prev) => {
const nextChecked = new Set(prev);
if (nextChecked.has(next)) nextChecked.delete(next);
else nextChecked.add(next);
return nextChecked;
});
} else {
const label = options[next]?.label;
if (label) onSubmit(label);
}
return;
}

const isOther = allowOther && cursor === otherIndex;

if (key.return) {
Expand Down
141 changes: 0 additions & 141 deletions clients/cli/src/components/SessionPicker.tsx

This file was deleted.

15 changes: 0 additions & 15 deletions clients/cli/src/components/SpinnerIcon.tsx

This file was deleted.

16 changes: 14 additions & 2 deletions clients/cli/src/components/messages/ErrorMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@
*
* Claude Code pattern: MAX_API_ERROR_CHARS = 1000, head/tail split,
* red color coding, "(ctrl+o to expand)" hint on truncation.
* Esc dismisses the banner when onDismiss is provided.
*/

import React from "react";
import { Box, Text } from "ink";
import { Box, Text, useInput } from "ink";
import { CtrlOToExpand } from "../shell/CtrlOToExpand.js";
import { useScreen } from "../shell/ScreenContext.js";

const MAX_ERROR_CHARS = 1000;

interface Props {
content: string;
/** When provided, esc dismisses the banner. */
onDismiss?: () => void;
}

export const ErrorMessage = React.memo(function ErrorMessage({
content,
onDismiss,
}: Props) {
const isTruncated = useScreen() !== "transcript" && content.length > MAX_ERROR_CHARS;
const headLen = Math.floor(MAX_ERROR_CHARS * 0.6);
Expand All @@ -27,13 +31,21 @@ export const ErrorMessage = React.memo(function ErrorMessage({
? content.slice(0, headLen) + "\n...\n" + content.slice(-tailLen)
: content;

useInput(
(_input, key) => {
if (key.escape) onDismiss?.();
},
{ isActive: onDismiss !== undefined },
Comment on lines +34 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent error dismissal from interrupting the active run

When a server error is displayed while runState is still streaming, pressing the advertised Escape key invokes this handler and the non-modal global handler, whose streaming Escape path calls onInterrupt; in transcript mode it also exits the transcript. Thus following the new dismissal hint can unexpectedly pause the live run or navigate away. Give error dismissal priority in the global keybinding flow or otherwise suppress the conflicting handler.

Useful? React with 👍 / 👎.

);

return (
<Box flexDirection="column" marginTop={1}>
<Text>
<Text color="red" bold>{"error> "}</Text>
<Text color="red" wrap="wrap">{display}</Text>
</Text>
{onDismiss && <Text dimColor>{isTruncated ? "ctrl+o to expand · esc to dismiss" : "esc to dismiss"}</Text>}
{isTruncated && <CtrlOToExpand />}
</Box>
);
});
});
8 changes: 8 additions & 0 deletions clients/cli/src/hooks/useAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ interface UseAgentReturn {
answerQuestion: (value: string | string[]) => void;
error: string | null;
clearEvents: () => void;
/** Dismiss the current error banner (esc-to-dismiss). */
clearError: () => void;
addSystemEvent: (content: string) => void;
selectAssistant: (assistantId: string, commit?: () => Promise<void>) => Promise<boolean>;
mutateWorkspace: (commit: () => Promise<void>) => Promise<boolean>;
Expand Down Expand Up @@ -860,6 +862,11 @@ export function useAgent({

// ── Clear ──────────────────────────────────────────────────────

/** Dismiss the current error banner (esc). Next submit also clears it. */
const clearError = useCallback(() => {
setError(null);
}, []);

const clearEvents = useCallback(() => {
if (switchingRef.current || restoringRef.current) return;
if (abortRef.current || runStateRef.current !== "idle" || activeQuestionRef.current || queuedMessageRef.current.length > 0) {
Expand Down Expand Up @@ -1343,6 +1350,7 @@ export function useAgent({
activeQuestion,
answerQuestion,
error,
clearError,
clearEvents,
addSystemEvent,
selectAssistant,
Expand Down
10 changes: 5 additions & 5 deletions clients/cli/src/hooks/useBlueNotifications.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ describe("Blue Cell chat notifications", () => {
evidence: { event_seqs: [7, 9] },
},
});
expect(alert).toContain("상주 감시 알림");
expect(alert).toContain("근거 이벤트 7, 9");
expect(alert).toContain("standing-watch alert");
expect(alert).toContain("Evidence events 7, 9");
const gap = formatBlueNotification({
seq: 4,
kind: "watch_error",
Expand All @@ -164,14 +164,14 @@ describe("Blue Cell chat notifications", () => {
counters: { collector_long_lines_skipped_total: 2 },
},
});
expect(message).toContain("수집 공백");
expect(message).toContain("Coverage gap");
expect(message).toContain("collector_long_lines_skipped_total=2");
const restart = formatBlueNotification({
seq: 6,
kind: "coverage_gap",
payload: { id: "coverage-2", reason: "collector_restarted" },
});
expect(restart).toContain("재시작");
expect(restart).toContain("연속성");
expect(restart).toContain("restarted");
expect(restart).toContain("observation continuity");
});
});
Loading
Loading