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
7 changes: 7 additions & 0 deletions .changeset/create-with-claude-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@inkeep/open-knowledge": patch
---

The empty-state create surface can now launch the `claude` CLI in a docked terminal. Alongside the existing app agents, the agent-picker dropdown now offers a **Terminal → Claude** option. Selecting it switches the primary button to **Create with Claude CLI**; clicking Create then opens the docked terminal with the same create-scope prompt built from your typed brief — bringing the new-file empty state to parity with the editor's "Open with AI" menu (desktop only; absent on the web host).

The Open-in-Agent menus ("Open/Edit with AI", the file-tree right-click submenu, and the sidebar empty-space submenu) and the empty-state agent picker are now organized into labeled **Desktop** (app launches) and **Terminal** (CLI launch) sections, so the two launch modes are visually distinct. The CLI row's visible label is now "Claude" (its accessible name remains "Claude CLI"). Section labels render only for non-empty sections, and existing launch behavior is unchanged.
6 changes: 5 additions & 1 deletion docs/content/features/editor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,13 @@ The Claude **claude.ai** web fallback row appears on file-scope dispatches only;

See the [per-integration pages](/docs/integrations/claude-code) for what each agent receives once dispatched.

The menu is organized into two sections. **Desktop** lists the installed agent apps (Claude, Codex, Cursor); selecting one launches it via its deep link. **Terminal** (desktop app only) adds a single **Claude** row that runs the `claude` CLI in Open Knowledge's docked terminal with the same scoped prompt. Both a Desktop **Claude** (the app) and a Terminal **Claude** (the CLI) can therefore appear; screen readers tell them apart by the Terminal row's "Claude CLI" accessible name. Each section renders only when it has something to launch.

The empty-state create composer on a fresh project carries the same Desktop / Terminal split: its Desktop rows pick the default agent the **Create** button uses, while its Terminal **Claude** row (desktop only, disabled until the workspace resolves) launches the `claude` CLI with the brief you typed.

### Open in Terminal

Spawns `open -a Terminal.app <dir>` at the right path: the folder for a folder row, the file's parent for a file row, the project root for the empty-space menu, the parent dir for an asset row. Terminal.app is hardcoded for v1; user-configurable terminal preference is on the roadmap.
Spawns `open -a Terminal.app <dir>` at the right path: the folder for a folder row, the file's parent for a file row, the project root for the empty-space menu, the parent dir for an asset row. Terminal.app is hardcoded for v1; user-configurable terminal preference is on the roadmap. This is distinct from the **Terminal** section inside **Open with AI**, which runs the `claude` CLI in Open Knowledge's docked terminal rather than opening Terminal.app.

## Sidebars and window width

Expand Down
2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
{
"name": "main CSS (gzipped)",
"path": "dist/assets/index-*.css",
"limit": "49 kB",
"limit": "50 kB",
"gzip": true,
"running": false
}
Expand Down
86 changes: 77 additions & 9 deletions packages/app/src/components/EditorArea.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,44 @@ mock.module('@/components/PropertyContext', () => ({
useProperties: () => ({ requestAddProperty: () => {} }),
}));

const FOLDER_DOC_CTX = {
activeDocName: 'folder/index',
activeProvider: null,
activeTarget: { kind: 'folder', target: 'folder', folderPath: 'folder' },
recycleDocument: () => {},
docPanelMode: 'timeline',
docPanelAgentId: null,
docPanelExpandSignal: 0,
};
const EMPTY_DOC_CTX = {
activeDocName: null,
activeProvider: null,
activeTarget: null,
recycleDocument: () => {},
docPanelMode: 'timeline',
docPanelAgentId: null,
docPanelExpandSignal: 0,
};
let docCtx: typeof FOLDER_DOC_CTX | typeof EMPTY_DOC_CTX = FOLDER_DOC_CTX;
mock.module('@/editor/DocumentContext', () => ({
useDocumentContext: () => ({
activeDocName: 'folder/index',
activeProvider: null,
activeTarget: { kind: 'folder', target: 'folder', folderPath: 'folder' },
recycleDocument: () => {},
docPanelMode: 'timeline',
docPanelAgentId: null,
docPanelExpandSignal: 0,
}),
useDocumentContext: () => docCtx,
useDocumentTransition: () => ({ openDocumentTransition: null }),
}));

mock.module('@/components/EmptyEditorState', () => ({
EmptyEditorState: ({ terminalVisible }: { terminalVisible?: boolean }) => (
<div data-testid="empty-editor-state" data-terminal-visible={String(terminalVisible)} />
),
}));

mock.module('./TerminalDock', () => ({
TerminalDock: ({ children, visible }: { children: ReactNode; visible?: boolean }) => (
<div data-testid="terminal-dock" data-visible={String(visible)}>
{children}
</div>
),
}));

mock.module('react-resizable-panels', () => ({
usePanelRef: () => ({
current: {
Expand Down Expand Up @@ -95,6 +120,7 @@ function renderEditorArea() {
describe('EditorArea SettingsDialogPortal runtime wiring', () => {
beforeEach(() => {
cleanup();
docCtx = FOLDER_DOC_CTX;
settingsRouteOpen = false;
closeSettingsRouteMock = mock(() => {});
shellProps = [];
Expand All @@ -118,3 +144,45 @@ describe('EditorArea SettingsDialogPortal runtime wiring', () => {
expect(closeSettingsRouteMock).toHaveBeenCalledTimes(1);
});
});

describe('EditorArea empty-state terminal host', () => {
beforeEach(() => {
cleanup();
docCtx = EMPTY_DOC_CTX;
});

test('hosts the docked terminal on the empty state when a terminal bridge is present', () => {
render(
<EditorArea
editorMode="wysiwyg"
onModeChange={() => {}}
activeTab="timeline"
onActiveTabChange={() => {}}
terminalBridge={{} as never}
terminalVisible
onTerminalVisibleChange={() => {}}
terminalLaunch={null}
/>,
);

const dock = screen.getByTestId('terminal-dock');
expect(dock.getAttribute('data-visible')).toBe('true');
const emptyState = dock.querySelector('[data-testid="empty-editor-state"]');
expect(emptyState).not.toBeNull();
expect(emptyState?.getAttribute('data-terminal-visible')).toBe('true');
});

test('renders the empty state with no terminal dock on the web host (no bridge)', () => {
render(
<EditorArea
editorMode="wysiwyg"
onModeChange={() => {}}
activeTab="timeline"
onActiveTabChange={() => {}}
/>,
);

expect(screen.queryByTestId('terminal-dock')).toBeNull();
expect(screen.getByTestId('empty-editor-state')).toBeTruthy();
});
});
12 changes: 12 additions & 0 deletions packages/app/src/components/EditorArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,18 @@ function EditorAreaInner({
if (hashDoc !== null) {
return <EditorSkeleton />;
}
if (terminalBridge != null) {
return (
<TerminalDock
bridge={terminalBridge}
visible={terminalVisible}
onVisibleChange={onTerminalVisibleChange ?? (() => {})}
launch={terminalLaunch}
>
<EmptyEditorState terminalVisible={terminalVisible} />
</TerminalDock>
);
}
return <EmptyEditorState />;
}

Expand Down
11 changes: 10 additions & 1 deletion packages/app/src/components/EmptyEditorState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CreatePromptComposer } from '@/components/empty-state/CreatePromptCompo
import { CreateView } from '@/components/empty-state/CreateView';
import { EmptyStateHeader } from '@/components/empty-state/EmptyStateHeader';
import { filterVisibleEntries } from '@/components/file-tree-utils';
import { OkBlob } from '@/components/OkBlob';
import { PackCardGrid } from '@/components/PackCardGrid';
import { SeedDialog } from '@/components/SeedDialog';
import { Button } from '@/components/ui/button';
Expand All @@ -15,7 +16,7 @@ import { emitCreateTopLevelFile } from '@/lib/create-file-events';
import type { OkPackId } from '@/lib/desktop-bridge-types';
import { subscribeToDocumentsChanged } from '@/lib/documents-events';

export function EmptyEditorState() {
export function EmptyEditorState({ terminalVisible = false }: { terminalVisible?: boolean }) {
const [seedDialogOpen, setSeedDialogOpen] = useState(false);
const [seedDialogInitialPackId, setSeedDialogInitialPackId] = useState<OkPackId | undefined>(
undefined,
Expand Down Expand Up @@ -84,6 +85,14 @@ export function EmptyEditorState() {
if (!next) setSeedDialogInitialPackId(undefined);
}

if (terminalVisible) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-end px-6 pb-8 pt-10">
<OkBlob size={64} gaze="down" />
</div>
);
}

return (
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-6 sm:px-12 md:px-16 subtle-scrollbar">
{messageReady ? (
Expand Down
33 changes: 23 additions & 10 deletions packages/app/src/components/OkBlob.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ interface OkBlobProps {
trackMouse?: boolean;
variant?: 'default' | 'sleeping';
celebrateSignal?: number;
/** Fixed gaze direction. `'down'` holds a "peering down" pose (head tilts
* forward, eyes drop) instead of tracking the cursor — used when the mascot
* sits above the docked terminal on the empty state. */
gaze?: 'cursor' | 'down';
}

const MAX_EYE_OFFSET = 1.8;
Expand Down Expand Up @@ -84,6 +88,7 @@ export function OkBlob({
trackMouse = true,
variant = 'default',
celebrateSignal = 0,
gaze = 'cursor',
}: OkBlobProps) {
const wrapperRef = useRef<HTMLSpanElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
Expand Down Expand Up @@ -132,6 +137,7 @@ export function OkBlob({

useEffect(() => {
if (!trackMouse || isSleeping) return;
const gazeDown = gaze === 'down';

const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
if (mq.matches) return;
Expand Down Expand Up @@ -177,17 +183,24 @@ export function OkBlob({
const dy = mouseY - centerY;
const dist = Math.hypot(dx, dy);

const normX = Math.max(-1, Math.min(1, dx / HEAD_DIST_SCALE));
const normY = Math.max(-1, Math.min(1, dy / HEAD_DIST_SCALE));
const targetRotY = normX * MAX_HEAD_ROTATION;
const targetRotX = -normY * MAX_HEAD_ROTATION;

let targetRotX: number;
let targetRotY: number;
let targetEyeX = 0;
let targetEyeY = 0;
if (dist >= 1) {
const scale = Math.min(dist / EYE_DIST_SCALE, 1) * MAX_EYE_OFFSET;
targetEyeX = (dx / dist) * scale;
targetEyeY = (dy / dist) * scale;
if (gazeDown) {
targetRotY = 0;
targetRotX = -MAX_HEAD_ROTATION * 0.8;
targetEyeY = MAX_EYE_OFFSET;
} else {
const normX = Math.max(-1, Math.min(1, dx / HEAD_DIST_SCALE));
const normY = Math.max(-1, Math.min(1, dy / HEAD_DIST_SCALE));
targetRotY = normX * MAX_HEAD_ROTATION;
targetRotX = -normY * MAX_HEAD_ROTATION;
if (dist >= 1) {
const scale = Math.min(dist / EYE_DIST_SCALE, 1) * MAX_EYE_OFFSET;
targetEyeX = (dx / dist) * scale;
targetEyeY = (dy / dist) * scale;
}
}

const settled =
Expand Down Expand Up @@ -226,7 +239,7 @@ export function OkBlob({
eyeOffsetRef.current = { x: 0, y: 0 };
eyesGroupRef.current?.removeAttribute('transform');
};
}, [trackMouse, isSleeping]);
}, [trackMouse, isSleeping, gaze]);

useLayoutEffect(() => {
const g = eyesGroupRef.current;
Expand Down
11 changes: 5 additions & 6 deletions packages/app/src/components/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,22 +39,21 @@ export function TerminalPanel({
launch = null,
}: TerminalPanelProps) {
const { t } = useLingui();
const { resolvedTheme } = useTheme();
const [restartKey, setRestartKey] = useState(0);
return (
<section
aria-label={t`Terminal`}
className={cn(
'relative flex h-full w-full flex-col overflow-hidden bg-background',
className,
)}
style={{ backgroundColor: xtermThemeForMode(resolvedTheme).background }}
className={cn('relative flex h-full w-full flex-col overflow-hidden', className)}
>
{onKill ? (
<div className="flex shrink-0 items-center justify-end border-border border-b px-1.5 py-1">
<div className="flex shrink-0 items-center justify-end px-1.5 py-1">
<Button
size="icon"
variant="ghost"
aria-label={t`Kill Terminal`}
className="size-6"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={onKill}
>
<Trash2 aria-hidden="true" className="size-4" />
Expand Down
Loading