Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
11 changes: 11 additions & 0 deletions desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,17 @@ func (a *App) Submit(input string) {
}
}

// RunShell executes a shell command directly (bypassing the model) and streams
// output as events on eventChannel.
func (a *App) RunShell(command string) {
a.mu.RLock()
ctrl := a.ctrl
a.mu.RUnlock()
if ctrl != nil {
ctrl.RunShell(command)
}
}

// SubmitDisplay runs input as a turn while recording a shorter UI-only display
// string for the saved desktop transcript. The model still receives input.
func (a *App) SubmitDisplay(display, input string) {
Expand Down
35 changes: 34 additions & 1 deletion desktop/frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import { ShellExpandProvider, useShellExpand } from "./lib/shellExpand";
import {
SquarePen,
Brain,
Expand Down Expand Up @@ -134,10 +135,29 @@ function sessionTime(ms: number): string {
return new Date(ms).toLocaleDateString([], { month: "short", day: "numeric" });
}


/** Global hotkey handler for shell-expand toggle (Ctrl/Cmd+B). */
function ShellHotkeys() {
const shellExpand = useShellExpand();
useEffect(() => {
if (!shellExpand) return;
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "b") {
e.preventDefault();
shellExpand.toggleLast();
}
};
document.addEventListener("keydown", onKey as (e: globalThis.KeyboardEvent) => void);
return () => document.removeEventListener("keydown", onKey as (e: globalThis.KeyboardEvent) => void);
}, [shellExpand]);
return null;
}

export default function App() {
const {
state,
send,
runShell,
notice,
cancel,
approve,
Expand Down Expand Up @@ -288,6 +308,16 @@ export default function App() {
const handleSend = useCallback(
async (displayText: string, submitText = displayText) => {
const trimmed = displayText.trim();
// "!<cmd>" runs a shell command directly, bypassing the model.
if (trimmed.startsWith("!")) {
const cmd = trimmed.slice(1).trim();
if (!cmd) {
notice("usage: !<command> (e.g. !ls -la)");
return;
}
runShell(cmd);
return;
}
const model = /^\/model\s+(\S+)$/.exec(trimmed);
if (model) {
void switchModel(model[1]);
Expand Down Expand Up @@ -324,7 +354,7 @@ export default function App() {
await syncModeToController(mode);
send(trimmed, submitText.trim());
},
[switchModel, openMemory, syncModeToController, mode, send, notice, t],
[switchModel, openMemory, syncModeToController, mode, send, runShell, notice, t],
);

const addToChat = useCallback((text: string) => {
Expand Down Expand Up @@ -677,6 +707,8 @@ export default function App() {
: t("sidebar.collapse");

return (
<ShellExpandProvider>
<ShellHotkeys />
<div className="app">
<div
className={[
Expand Down Expand Up @@ -1068,5 +1100,6 @@ export default function App() {

{needsOnboarding && <OnboardingOverlay onComplete={() => setNeedsOnboarding(false)} />}
</div>
</ShellExpandProvider>
);
}
4 changes: 2 additions & 2 deletions desktop/frontend/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -865,12 +865,12 @@ export function Composer({
onDoubleClick={resetComposerHeight}
/>
<div
className={`composer${dragOver ? " composer--dragover" : ""}${disabled ? " composer--disabled" : ""}`}
className={`composer${dragOver ? " composer--dragover" : ""}${disabled ? " composer--disabled" : ""}${text.trimStart().startsWith("!") ? " composer--shell" : ""}`}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
>
<span className="composer__caret"></span>
<span className="composer__caret">{text.trimStart().startsWith("!") ? "$" : "›"}</span>
<textarea
ref={taRef}
className="composer__input"
Expand Down
46 changes: 43 additions & 3 deletions desktop/frontend/src/components/ToolCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, useState } from "react";
import { memo, useEffect, useState } from "react";
import {
Ban,
Check,
Expand All @@ -19,6 +19,7 @@ import { CodeViewer } from "./CodeViewer";
import { DiffView } from "./DiffView";
import { useT } from "../lib/i18n";
import { diffsFor, subjectOf, summarize } from "../lib/tools";
import { useShellExpand } from "../lib/shellExpand";
import type { Item } from "../lib/useController";

type ToolItem = Extract<Item, { kind: "tool" }>;
Expand All @@ -36,6 +37,9 @@ const ICONS: Record<string, LucideIcon> = {
task: ListTree,
};

/** Lines shown by default in a shell output block before the "show all" button. */
const SHELL_PREVIEW_LINES = 10;

function pretty(json: string): string {
try {
return JSON.stringify(JSON.parse(json), null, 2);
Expand All @@ -51,6 +55,14 @@ function StatusGlyph({ status }: { status: ToolItem["status"] }) {
return <Check className="ico ico--ok" size={13} />;
}

/** Returns the first n lines of text and the total line count. */
function splitPreview(text: string, n: number): { preview: string; total: number; hasMore: boolean } {
const lines = text.split("\n");
const total = lines.length;
if (total <= n) return { preview: text, total, hasMore: false };
return { preview: lines.slice(0, n).join("\n"), total, hasMore: true };
}

// ToolCard renders one tool call. `subcalls` are sub-agent calls nested under a
// `task` card (their ParentID points at this call); they render inline, live, so
// the sub-agent's work is visible as it happens.
Expand All @@ -72,17 +84,31 @@ export const ToolCard = memo(function ToolCard({ item, subcalls }: { item: ToolI

// edit diffs are the point of the card, so they're shown inline; everything
// else folds its args/output away by default. Nested children always show.
// Shell commands default to open so the output is immediately visible.
const hasBody = diffs.length === 0 && (!!item.args || !!item.output);
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(item.isShell && hasBody);
const [showAll, setShowAll] = useState(false);
const expandable = hasBody;

// Register this shell card's toggle with the global ShellExpand context so
// Ctrl/Cmd+B can expand/collapse the most recent shell output.
const shellExpand = useShellExpand();
useEffect(() => {
if (!item.isShell || !shellExpand) return;
return shellExpand.register(item.id, () => setOpen((v) => !v));
}, [item.isShell, item.id, shellExpand]);

// Read-only "research" calls (read/grep/ls/glob/web_fetch) are quieted to a
// slim, borderless, dim row so a long run of them doesn't bury the few calls
// that matter — writers, bash, sub-agents, and anything that failed keep the
// full card. Uses the readOnly flag, not a tool-name list.
const quiet =
item.readOnly && !hasNested && item.status !== "error" && item.status !== "stopped";

// Shell output: split into preview + "show all" toggle.
const shellOutput = item.isShell && item.output ? item.output : null;
const shellPreview = shellOutput ? splitPreview(shellOutput, SHELL_PREVIEW_LINES) : null;

return (
<div className={`tool tool--${item.status} ${quiet ? "tool--quiet" : ""}`}>
<div
Expand Down Expand Up @@ -119,7 +145,21 @@ export const ToolCard = memo(function ToolCard({ item, subcalls }: { item: ToolI
</div>
)}

{hasBody && open && (
{/* Shell output: always visible (auto-open), with preview/show-all toggle */}
{shellPreview && open && (
<div className="tool__body">
<CodeViewer value={showAll ? shellOutput! : shellPreview.preview} maxHeight={showAll ? 480 : 260} />
{shellPreview.hasMore && !showAll && (
<button className="tool__showall" onClick={() => setShowAll(true)}>
{t("tool.showAllLines", { n: shellPreview.total })}
</button>
)}
{item.truncated && <div className="tool__note">{t("tool.truncated")}</div>}
</div>
)}

{/* Non-shell body: args + output, gated by open */}
{!shellPreview && hasBody && open && (
<div className="tool__body">
{item.args && <CodeViewer value={pretty(item.args)} language="json" maxHeight={180} />}
{item.output && (
Expand Down
16 changes: 16 additions & 0 deletions desktop/frontend/src/lib/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface AppBindings {
Platform(): Promise<string>;
Submit(input: string): Promise<void>;
SubmitDisplay(display: string, input: string): Promise<void>;
RunShell(command: string): Promise<void>;
Cancel(): Promise<void>;
Approve(id: string, allow: boolean, session: boolean, persist: boolean): Promise<void>;
AnswerQuestion(id: string, answers: QuestionAnswer[]): Promise<void>;
Expand Down Expand Up @@ -514,6 +515,21 @@ function makeMockApp(): AppBindings {
async SubmitDisplay(_display, input) {
await this.Submit(input);
},
async RunShell(command) {
cancelled = false;
emit({ kind: "turn_started" });
await delay(100);
if (cancelled) return;
const id = `shell-${command.slice(0, 32)}`;
emit({ kind: "tool_dispatch", tool: { id, name: "bash", args: JSON.stringify({ command }), readOnly: false } });
await delay(200);
if (cancelled) return;
emit({ kind: "tool_progress", tool: { id, name: "bash", output: `$ ${command}\n(mock output)\n`, readOnly: false } });
await delay(100);
if (cancelled) return;
emit({ kind: "tool_result", tool: { id, name: "bash", output: `$ ${command}\n(mock output)\n`, readOnly: false } });
emit({ kind: "turn_done" });
},
async Cancel() {
cancelled = true;
emit({ kind: "turn_done" });
Expand Down
43 changes: 43 additions & 0 deletions desktop/frontend/src/lib/shellExpand.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react";

// Shell-expand coordination: ToolCards register their toggle callbacks, and
// Cmd+B (from App) calls the most recent one.

type ToggleFn = () => void;

interface ShellExpandCtx {
register: (id: string, toggle: ToggleFn) => void;
toggleLast: () => void;
}

const Ctx = createContext<ShellExpandCtx | null>(null);

export function ShellExpandProvider({ children }: { children: ReactNode }) {
const mapRef = useRef(new Map<string, ToggleFn>());
const orderRef: { current: string[] } = useRef<string[]>([]);

const register = useCallback((id: string, toggle: ToggleFn) => {
mapRef.current.set(id, toggle);
if (!orderRef.current.includes(id)) {
orderRef.current.push(id);
}
return () => {
mapRef.current.delete(id);
orderRef.current = orderRef.current.filter((x) => x !== id);
};
}, []);

const toggleLast = useCallback(() => {
const ids = orderRef.current;
if (ids.length === 0) return;
const fn = mapRef.current.get(ids[ids.length - 1]);
fn?.();
}, []);

const value = useMemo(() => ({ register, toggleLast }), [register, toggleLast]);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}

export function useShellExpand() {
return useContext(Ctx);
}
18 changes: 8 additions & 10 deletions desktop/frontend/src/lib/useController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export type Item =
output?: string;
error?: string;
truncated?: boolean;
isShell?: boolean; // true for !-prefix shell commands (controls default expand)
parentId?: string; // a sub-agent call nests under the `task` call with this id
};

Expand Down Expand Up @@ -229,16 +230,7 @@ function applyEvent(s: State, e: WireEvent): State {
}
return { ...s, items: next };
}
const item: Item = {
kind: "tool",
id,
name: t.name,
args: t.args ?? "",
readOnly: t.readOnly,
status: "running",
parentId: t.parentId,
};
return { ...s, seq: s.seq + 1, items: [...s.items, item] };
return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "tool", id, name: t.name, args: t.args ?? "", readOnly: t.readOnly, status: "running", isShell: id.startsWith("shell-"), parentId: t.parentId }] };
}

case "tool_result": {
Expand Down Expand Up @@ -556,6 +548,11 @@ export function useController() {
call.catch(() => {});
}, []);

const runShell = useCallback((command: string) => {
dispatch({ type: "user", text: `!${command}` });
app.RunShell(command).catch(() => {});
}, []);

const notice = useCallback((text: string, level: "info" | "warn" = "info") => {
dispatch({ type: "local_notice", level, text });
}, []);
Expand Down Expand Up @@ -751,6 +748,7 @@ export function useController() {
return {
state,
send,
runShell,
notice,
cancel,
approve,
Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ export const en = {
"tool.stepOne": "{n} step",
"tool.stepOther": "{n} steps",
"tool.truncated": "output truncated",
"tool.showAllLines": "show all {n} lines",
"tool.lineOne": "{n} line",
"tool.lineOther": "{n} lines",
"tool.matchOne": "{n} match",
Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ export const zh: Record<DictKey, string> = {
"tool.stepOne": "{n} 步",
"tool.stepOther": "{n} 步",
"tool.truncated": "输出已截断",
"tool.showAllLines": "显示全部 {n} 行",
"tool.lineOne": "{n} 行",
"tool.lineOther": "{n} 行",
"tool.matchOne": "{n} 处匹配",
Expand Down
26 changes: 26 additions & 0 deletions desktop/frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
--err: #e0696a;
--danger: #e5484d;
--danger-fg: #fff;
--shell-accent: #22c55e;

/* component recipes */
--list-row-height: 38px;
Expand Down Expand Up @@ -1458,6 +1459,21 @@ body {
color: var(--fg-faint);
margin-top: 3px;
}
.tool__showall {
display: inline-block;
margin-top: 4px;
padding: 2px 8px;
font-size: 11px;
color: var(--accent);
background: var(--accent-soft);
border: none;
border-radius: 4px;
cursor: pointer;
line-height: 1.6;
}
.tool__showall:hover {
background: color-mix(in srgb, var(--accent-soft) 60%, transparent);
}
.tool__err {
margin: 0 11px 10px 30px;
padding: 7px 10px;
Expand Down Expand Up @@ -1742,6 +1758,16 @@ body {
border-color: var(--border);
box-shadow: none;
}
.composer--shell {
border-bottom-color: var(--shell-accent);
}
.composer--shell:focus-within {
border-bottom-color: var(--shell-accent);
box-shadow: 0 1px 0 0 var(--shell-accent);
}
.composer--shell .composer__caret {
color: var(--shell-accent);
}
.composer__caret {
color: var(--accent);
font-family: var(--mono);
Expand Down
Loading