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
5 changes: 5 additions & 0 deletions .changeset/composer-selection-pill-row.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge-app": patch
---

Ask AI composer: the captured-selection chip now shares one wrapping row with the file-context chips instead of stacking on its own line above them. The chips sit on the same row and only break to a second line when they overflow the available width. The selection chip continues to truncate a long file name so its label no longer spills past the chip's edge, and the expanded selection preview still drops to its own line beneath the chips.
6 changes: 6 additions & 0 deletions .changeset/terminal-launch-prompt-load-read-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@inkeep/open-knowledge-core": patch
"@inkeep/open-knowledge-app": patch
---

Tighten the docked-terminal launch prompt. Opening a file, folder, or project in the desktop terminal ("Open in terminal" / file-tree + empty-space context menus) now hands the agent a minimal directive: it states the agent is running in the Open Knowledge desktop app terminal, loads the OK runtime contract, reads the open file via the OK MCP server when one is focused, then stops — instead of the previous open-ended "Let's work on X" invitation. A bare launch that isn't on a focused file (a folder right-click, empty-space, or project) deliberately reduces to "state surface, load OK, then stop" — the prior folder-scoped "Let's work on the `folder`" directive is intentionally dropped in favor of that minimalism. Launches that carry explicit user intent (a typed "Open with AI" instruction, or the empty-state "Create with Claude CLI" brief) are unchanged — they keep threading that intent through. The web deep-link handoff is unaffected.
129 changes: 67 additions & 62 deletions packages/app/src/components/BottomComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,10 +395,14 @@ export function BottomComposer({
>
<ChevronDown className="size-3.5" aria-hidden />
</Button>
{/* Removable top-row file-context chips — the set of files touched while
drafting (minus dismissed, minus any referenced inline). Typed
`@`-mentions stay inline as their own chips, never duplicated here.
X'ing a chip sticky-dismisses its path for this draft. */}
{/* One wrapping context row. The removable file-context chips (files
touched while drafting, minus dismissed / inline `@`-mentions; typed
`@`-mentions stay inline as their own chips) and the captured-selection
pill are siblings in a single flex-wrap row, so they sit on the same
line and only break to a second line on overflow. X'ing a file chip
sticky-dismisses its path for this draft. The expanded selection
preview carries `basis-full`, dropping onto its own line beneath the
chips. */}
<ComposerContextChips
files={fileChips}
onRemoveFile={(path) =>
Expand All @@ -408,65 +412,66 @@ export function BottomComposer({
return next;
})
}
/>
{pinnedSelection ? (
<div className="flex flex-col items-start gap-1">
<span
data-testid="composer-selection-pill"
className="group/chip inline-flex max-w-[16rem] items-center gap-1 rounded-md border bg-muted/40 py-0.5 pr-1.5 pl-1 text-muted-foreground text-xs"
>
{/* The LEADING glyph IS the remove control (mirrors the file chip):
a fixed-size cell holding the selection's TextQuote glyph and an
X, cross-faded by opacity on chip hover / `:focus-within` / button
focus. The cell never resizes, so the pill box is identical at
rest vs hover → no reflow. TextQuote stays the at-rest icon (this
is a text selection). opacity only — never layout. */}
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t`Remove selection`}
onClick={() => {
setPinnedSelection(null);
setSelectionExpanded(false);
}}
className="group/remove relative size-3.5 shrink-0 rounded-sm text-muted-foreground/80 hover:text-foreground"
>
<TextQuote
className="absolute top-1/2 left-1/2 size-3 -translate-x-1/2 -translate-y-1/2 opacity-100 transition-opacity duration-150 ease-out group-hover/chip:opacity-0 group-focus-within/chip:opacity-0 motion-reduce:transition-none"
aria-hidden
/>
<X
className="absolute top-1/2 left-1/2 size-3 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity duration-150 ease-out group-hover/chip:opacity-100 group-focus-within/chip:opacity-100 motion-reduce:transition-none"
aria-hidden
/>
</Button>
{/* The chip label is compact (`name (range)`); clicking it peeks
the light-rendered preview (expand/collapse), Cursor-style. */}
<Button
type="button"
variant="ghost"
aria-expanded={selectionExpanded}
aria-label={
selectionExpanded ? t`Hide selection preview` : t`Show selection preview`
}
onClick={() => setSelectionExpanded((open) => !open)}
data-testid="composer-selection-peek"
className="h-auto min-h-0 min-w-0 justify-start truncate px-0 py-0 text-left font-normal text-muted-foreground text-xs hover:bg-transparent hover:text-foreground"
>
{pinnedLabel}
</Button>
</span>
{selectionExpanded && pinnedPreview !== '' ? (
<p
className="max-h-24 w-full overflow-y-auto whitespace-pre-wrap text-2xs text-muted-foreground/80 subtle-scrollbar"
data-testid="composer-selection-preview"
>
{pinnedSelection ? (
<>
<span
data-testid="composer-selection-pill"
className="group/chip inline-flex max-w-[16rem] items-center gap-1 rounded-md border bg-muted/40 py-0.5 pr-1.5 pl-1 text-muted-foreground text-xs"
>
{pinnedPreview}
</p>
) : null}
</div>
) : null}
{/* The LEADING glyph IS the remove control (mirrors the file chip):
a fixed-size cell holding the selection's TextQuote glyph and an
X, cross-faded by opacity on chip hover / `:focus-within` / button
focus. The cell never resizes, so the pill box is identical at
rest vs hover → no reflow. TextQuote stays the at-rest icon (this
is a text selection). opacity only — never layout. */}
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t`Remove selection`}
onClick={() => {
setPinnedSelection(null);
setSelectionExpanded(false);
}}
className="group/remove relative size-3.5 shrink-0 rounded-sm text-muted-foreground/80 hover:text-foreground"
>
<TextQuote
className="absolute top-1/2 left-1/2 size-3 -translate-x-1/2 -translate-y-1/2 opacity-100 transition-opacity duration-150 ease-out group-hover/chip:opacity-0 group-focus-within/chip:opacity-0 motion-reduce:transition-none"
aria-hidden
/>
<X
className="absolute top-1/2 left-1/2 size-3 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity duration-150 ease-out group-hover/chip:opacity-100 group-focus-within/chip:opacity-100 motion-reduce:transition-none"
aria-hidden
/>
</Button>
{/* The chip label is compact (`name (range)`); clicking it peeks
the light-rendered preview (expand/collapse), Cursor-style. */}
<Button
type="button"
variant="ghost"
aria-expanded={selectionExpanded}
aria-label={
selectionExpanded ? t`Hide selection preview` : t`Show selection preview`
}
onClick={() => setSelectionExpanded((open) => !open)}
data-testid="composer-selection-peek"
className="h-auto min-h-0 min-w-0 justify-start truncate px-0 py-0 text-left font-normal text-muted-foreground text-xs hover:bg-transparent hover:text-foreground"
>
{pinnedLabel}
</Button>
</span>
{selectionExpanded && pinnedPreview !== '' ? (
<p
className="max-h-24 w-full basis-full overflow-y-auto whitespace-pre-wrap text-2xs text-muted-foreground/80 subtle-scrollbar"
data-testid="composer-selection-preview"
>
{pinnedPreview}
</p>
) : null}
</>
) : null}
</ComposerContextChips>
<div className="flex items-end gap-2">
<div className="relative flex-1">
<ComposerMentionInput
Expand Down
12 changes: 10 additions & 2 deletions packages/app/src/components/ComposerContextChips.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useLingui } from '@lingui/react/macro';
import { X } from 'lucide-react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { getFileIcon, mentionPathToDescriptor } from '@/editor/registry/file-icons';
import { cn } from '@/lib/utils';
Expand Down Expand Up @@ -64,15 +65,21 @@ export function ComposerContextChips({
files,
onRemoveFile,
className,
children,
}: {
/** Ordered set of workspace-relative file paths to show as removable top-row
* chips (already deduped against inline mentions + sticky-dismissed by the
* host). Empty → the row renders nothing. */
* host). Empty → only `children` (if any) render. */
files: readonly string[];
onRemoveFile: (path: string) => void;
className?: string;
/** Extra context chips rendered as siblings in the SAME flex-wrap row (e.g.
* the captured-selection pill), so every reference shares one wrapping row and
* only breaks to a second line on overflow. A child carrying `basis-full`
* (the expanded selection preview) drops onto its own line beneath the chips. */
children?: ReactNode;
}) {
if (files.length === 0) return null;
if (files.length === 0 && children == null) return null;
return (
<div
className={cn('flex flex-wrap items-center gap-1', className)}
Expand All @@ -81,6 +88,7 @@ export function ComposerContextChips({
{files.map((path) => (
<FileChip key={path} path={path} onRemove={() => onRemoveFile(path)} />
))}
{children}
</div>
);
}
120 changes: 102 additions & 18 deletions packages/app/src/components/handoff/useHandoffDispatch.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { describe, expect, mock, test } from 'bun:test';
import { setTimeout as wait } from 'node:timers/promises';
import type { HandoffOutcome, HandoffPayload, HandoffTarget } from '@inkeep/open-knowledge-core';
import { withSkillPointer } from '@inkeep/open-knowledge-core';
import {
composeTerminalBareLaunchPrompt,
OK_TERMINAL_SURFACE_PREAMBLE,
withSkillPointer,
} from '@inkeep/open-knowledge-core';
import type {
HandoffDispatchDeps,
HandoffDispatchInput,
Expand Down Expand Up @@ -1396,8 +1400,8 @@ describe('runHandoffDispatch — selection scope', () => {
});
});

describe('composeTerminalLaunchPrompt — docked-terminal launcher always suppresses the trailer', () => {
test('file scope omits the "Open the OK editor in web view." trailer', async () => {
describe('composeTerminalLaunchPrompt — docked-terminal bare launch is load + read + stop', () => {
test('file scope reads the open file via OK MCP, then stops', async () => {
const { composeTerminalLaunchPrompt } = await import('./useHandoffDispatch');
const out = composeTerminalLaunchPrompt(
{
Expand All @@ -1407,11 +1411,14 @@ describe('composeTerminalLaunchPrompt — docked-terminal launcher always suppre
},
'claude',
);
expect(out).toBe(withSkillPointer("Let's work on `notes/today.md` using Open Knowledge."));
expect(out).toBe(composeTerminalBareLaunchPrompt('notes/today.md'));
expect(out).toContain(OK_TERMINAL_SURFACE_PREAMBLE);
expect(out).toContain('Read `notes/today.md` via the Open Knowledge MCP server, then stop.');
expect(out).not.toContain("Let's work on");
expect(out).not.toContain('Open the OK editor');
});

test('folder scope omits the trailer', async () => {
test('folder scope has no file to read — just load and stop', async () => {
const { composeTerminalLaunchPrompt } = await import('./useHandoffDispatch');
const out = composeTerminalLaunchPrompt(
{
Expand All @@ -1422,13 +1429,14 @@ describe('composeTerminalLaunchPrompt — docked-terminal launcher always suppre
},
'claude',
);
expect(out).toBe(
withSkillPointer("Let's work on the `specs/foo` folder using Open Knowledge."),
);
expect(out).not.toContain('Open the OK editor');
expect(out).toBe(composeTerminalBareLaunchPrompt(null));
expect(out).toContain(OK_TERMINAL_SURFACE_PREAMBLE);
expect(out.endsWith('Then stop.')).toBe(true);
expect(out).not.toContain('Read `');
expect(out).not.toContain("Let's work on");
});

test('project / empty-space scope omits the trailer', async () => {
test('project / empty-space scope — just load and stop', async () => {
const { composeTerminalLaunchPrompt } = await import('./useHandoffDispatch');
const out = composeTerminalLaunchPrompt(
{
Expand All @@ -1438,36 +1446,112 @@ describe('composeTerminalLaunchPrompt — docked-terminal launcher always suppre
},
'claude',
);
expect(out).toBe(withSkillPointer("Let's work on this project using Open Knowledge."));
expect(out).not.toContain('Open the OK editor');
expect(out).toBe(composeTerminalBareLaunchPrompt(null));
expect(out.endsWith('Then stop.')).toBe(true);
expect(out).not.toContain("Let's work on");
});

test('codex / cursor compose the same directive prompt as claude (target only varies for selection scope)', async () => {
test('bare launch is CLI-agnostic — codex / cursor compose the same prompt as claude', async () => {
const { composeTerminalLaunchPrompt } = await import('./useHandoffDispatch');
const input = {
docContext: { relativePath: 'notes/today.md' },
projectDir: '/proj',
docPath: '/proj/notes/today.md',
};
const claudeOut = composeTerminalLaunchPrompt(input, 'claude');
expect(claudeOut).toBe(composeTerminalBareLaunchPrompt('notes/today.md'));
expect(composeTerminalLaunchPrompt(input, 'codex')).toBe(claudeOut);
expect(composeTerminalLaunchPrompt(input, 'cursor')).toBe(claudeOut);
});

test('terminal launch drops the trailer while the web deep-link handoff retains it for the same input', async () => {
test('typed instruction is preserved per CLI (keeps the directive composer, no "stop")', async () => {
const { composeTerminalLaunchPrompt, selectScopedPrompt } = await import(
'./useHandoffDispatch'
);
const input = {
docContext: { relativePath: 'notes/today.md' },
projectDir: '/proj',
docPath: '/proj/notes/today.md',
instruction: 'summarize the open questions',
};
const terminalPrompt = composeTerminalLaunchPrompt(input, 'claude');
const webHandoffPrompt = selectScopedPrompt(input, 'claude-code', true);
expect(composeTerminalLaunchPrompt(input, 'claude')).toBe(
selectScopedPrompt(input, 'claude-code', false),
);
expect(composeTerminalLaunchPrompt(input, 'codex')).toBe(
selectScopedPrompt(input, 'codex', false),
);
expect(composeTerminalLaunchPrompt(input, 'cursor')).toBe(
selectScopedPrompt(input, 'cursor', false),
);
const claudeOut = composeTerminalLaunchPrompt(input, 'claude');
expect(claudeOut).toContain('summarize the open questions');
expect(claudeOut).not.toContain(OK_TERMINAL_SURFACE_PREAMBLE);
});

test('whitespace-only instruction is treated as a bare launch', async () => {
const { composeTerminalLaunchPrompt } = await import('./useHandoffDispatch');
const out = composeTerminalLaunchPrompt(
{
docContext: { relativePath: 'notes/today.md' },
projectDir: '/proj',
docPath: '/proj/notes/today.md',
instruction: ' ',
},
'claude',
);
expect(out).toBe(composeTerminalBareLaunchPrompt('notes/today.md'));
expect(out).toContain(OK_TERMINAL_SURFACE_PREAMBLE);
expect(out).not.toContain('Instruction:');
expect(out).not.toContain("Let's work on");
});

expect(terminalPrompt).not.toContain('Open the OK editor');
expect(webHandoffPrompt).toContain('Open the OK editor in web view.');
test('create brief is preserved (keeps the directive composer)', async () => {
const { composeTerminalLaunchPrompt, selectScopedPrompt } = await import(
'./useHandoffDispatch'
);
const input = {
docContext: null,
createDescription: 'a fishing log',
createScenario: 'new-project' as const,
projectDir: '/proj',
docPath: '',
};
const out = composeTerminalLaunchPrompt(input, 'claude');
expect(out).toBe(selectScopedPrompt(input, 'claude-code', false));
expect(out).toContain('a fishing log');
expect(out).not.toContain(OK_TERMINAL_SURFACE_PREAMBLE);
});

test('empty create brief still routes to the directive composer (createDescription !== undefined)', async () => {
const { composeTerminalLaunchPrompt, selectScopedPrompt } = await import(
'./useHandoffDispatch'
);
const input = {
docContext: null,
createDescription: '',
createScenario: 'new-project' as const,
projectDir: '/proj',
docPath: '',
};
const out = composeTerminalLaunchPrompt(input, 'claude');
expect(out).toBe(selectScopedPrompt(input, 'claude-code', false));
expect(out).not.toBe(composeTerminalBareLaunchPrompt(null));
expect(out).not.toContain(OK_TERMINAL_SURFACE_PREAMBLE);
});

test('bare terminal launch never carries the web-view preview trailer', async () => {
const { composeTerminalLaunchPrompt, selectScopedPrompt } = await import(
'./useHandoffDispatch'
);
const input = {
docContext: { relativePath: 'notes/today.md' },
projectDir: '/proj',
docPath: '/proj/notes/today.md',
};
expect(composeTerminalLaunchPrompt(input, 'claude')).not.toContain('Open the OK editor');
expect(selectScopedPrompt(input, 'claude-code', true)).toContain(
'Open the OK editor in web view.',
);
});
});

Expand Down
7 changes: 6 additions & 1 deletion packages/app/src/components/handoff/useHandoffDispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
composeFilePrompt,
composeFolderPrompt,
composeSelectionPrompt,
composeTerminalBareLaunchPrompt,
type DocContext,
type HandoffOutcome,
type HandoffPayload,
Expand Down Expand Up @@ -381,7 +382,11 @@ export function selectScopedPrompt(
}

export function composeTerminalLaunchPrompt(input: HandoffDispatchInput, cli: TerminalCli): string {
return selectScopedPrompt(input, TERMINAL_CLIS[cli].handoffTarget, false);
const hasInstruction = typeof input.instruction === 'string' && input.instruction.trim() !== '';
if (input.createDescription !== undefined || hasInstruction) {
return selectScopedPrompt(input, TERMINAL_CLIS[cli].handoffTarget, false);
}
return composeTerminalBareLaunchPrompt(input.docContext?.relativePath ?? null);
}

export async function runHandoffDispatch(
Expand Down
Loading