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
20 changes: 20 additions & 0 deletions apps/code/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,26 @@ const useTaskStore = create<TaskState>((set) => ({
}));
```

### Learned Hints

The settings store (`src/renderer/features/settings/stores/settingsStore.ts`) provides a reusable "learned hints" system for progressive feature discovery. Hints are shown a limited number of times until the user demonstrates they've learned the behavior.

```typescript
// In the store: hints is Record<string, { count: number; learned: boolean }>
const store = useFeatureSettingsStore.getState();

// Check if a hint should still be shown (max N times, not yet learned)
if (store.shouldShowHint("my-hint-key", 3)) {
store.recordHintShown("my-hint-key");
toast.info("Did you know?", "You can do X with Y.");
}

// When the user demonstrates the behavior, mark it learned (stops showing)
store.markHintLearned("my-hint-key");
```

Hint state is persisted via `electronStorage`. Use this pattern instead of ad-hoc boolean flags when introducing new discoverable features.

## Services

Services encapsulate business logic and exist in both processes:
Expand Down
8 changes: 8 additions & 0 deletions apps/code/src/renderer/constants/keyboard-shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const SHORTCUTS = {
OPEN_IN_EDITOR: "mod+o",
COPY_PATH: "mod+shift+c",
TOGGLE_FOCUS: "mod+r",
PASTE_AS_FILE: "mod+shift+v",
BLUR: "escape",
SUBMIT_BLUR: "mod+enter",
} as const;
Expand Down Expand Up @@ -137,6 +138,13 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
category: "panels",
context: "Task detail",
},
{
id: "paste-as-file",
keys: SHORTCUTS.PASTE_AS_FILE,
description: "Paste as file attachment",
category: "editor",
context: "Message editor",
},
{
id: "prompt-history-prev",
keys: "shift+up",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface MentionChipAttrs {
type: ChipType;
id: string;
label: string;
pastedText: boolean;
}

declare module "@tiptap/core" {
Expand All @@ -36,6 +37,7 @@ export const MentionChipNode = Node.create({
type: { default: "file" as ChipType },
id: { default: "" },
label: { default: "" },
pastedText: { default: false },
};
},

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { Tooltip } from "@components/ui/Tooltip";
import { useSettingsStore as useFeatureSettingsStore } from "@features/settings/stores/settingsStore";
import { trpcClient } from "@renderer/trpc/client";
import type { Node as PmNode } from "@tiptap/pm/model";
import type { Editor } from "@tiptap/react";
import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import type { MentionChipAttrs } from "./MentionChipNode";

Expand All @@ -16,12 +21,68 @@ function DefaultChip({ type, label }: { type: string; label: string }) {
);
}

export function MentionChipView({ node }: NodeViewProps) {
const { type, label } = node.attrs as MentionChipAttrs;
function PastedTextChip({
label,
filePath,
editor,
node,
getPos,
}: {
label: string;
filePath: string;
editor: Editor;
node: PmNode;
getPos: () => number | undefined;
}) {
const handleClick = async () => {
useFeatureSettingsStore.getState().markHintLearned("paste-as-file");

const content = await trpcClient.fs.readAbsoluteFile.query({
filePath,
});
if (!content) return;

const pos = getPos();
if (pos == null) return;

editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + node.nodeSize })
.insertContentAt(pos, content)
.run();
};

return (
<Tooltip content="Click to paste as text instead">
<button
type="button"
className="cli-file-mention inline cursor-pointer select-all rounded-[var(--radius-1)] border-none bg-[var(--accent-a3)] px-1 py-px font-medium text-[var(--accent-11)] text-xs hover:bg-[var(--accent-a4)]"
contentEditable={false}
onClick={handleClick}
>
@{label}
</button>
</Tooltip>
);
}

export function MentionChipView({ node, getPos, editor }: NodeViewProps) {
const { type, id, label, pastedText } = node.attrs as MentionChipAttrs;

return (
<NodeViewWrapper as="span" className="inline">
<DefaultChip type={type} label={label} />
{pastedText ? (
<PastedTextChip
label={label}
filePath={id}
editor={editor}
node={node}
getPos={getPos}
/>
) : (
<DefaultChip type={type} label={label} />
)}
</NodeViewWrapper>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { useSettingsStore as useFeatureSettingsStore } from "@features/settings/
import { trpcClient } from "@renderer/trpc/client";
import { toast } from "@renderer/utils/toast";
import { useSettingsStore } from "@stores/settingsStore";
import type { EditorView } from "@tiptap/pm/view";
import { useEditor } from "@tiptap/react";
import type React from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { usePromptHistoryStore } from "../stores/promptHistoryStore";
import type { FileAttachment, MentionChip } from "../utils/content";
Expand Down Expand Up @@ -37,6 +39,37 @@ export interface UseTiptapEditorOptions {
const EDITOR_CLASS =
"cli-editor min-h-[1.5em] w-full break-words border-none bg-transparent font-mono text-[12px] text-[var(--gray-12)] outline-none [overflow-wrap:break-word] [white-space:pre-wrap] [word-break:break-word]";

async function pasteTextAsFile(
view: EditorView,
text: string,
pasteCountRef: React.MutableRefObject<number>,
): Promise<void> {
const result = await trpcClient.os.saveClipboardText.mutate({ text });
pasteCountRef.current += 1;
const lineCount = text.split("\n").length;
const label = `Pasted text #${pasteCountRef.current} (${lineCount} lines)`;
const chipNode = view.state.schema.nodes.mentionChip.create({
type: "file",
id: result.path,
label,
pastedText: true,
});
const space = view.state.schema.text(" ");
const { tr } = view.state;
tr.replaceSelectionWith(chipNode).insert(tr.selection.from, space);
view.dispatch(tr);
view.focus();
}

function showPasteHint(message: string, description: string): void {
const store = useFeatureSettingsStore.getState();
const key =
message === "Pasted as file attachment" ? "paste-as-file" : "paste-inline";
if (!store.shouldShowHint(key)) return;
store.recordHintShown(key);
toast.info(message, description);
}

export function useTiptapEditor(options: UseTiptapEditorOptions) {
const {
sessionId,
Expand Down Expand Up @@ -118,6 +151,27 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
},
},
handleKeyDown: (view, event) => {
if (
event.key === "v" &&
(event.metaKey || event.ctrlKey) &&
event.shiftKey
) {
event.preventDefault();
(async () => {
try {
const text = await navigator.clipboard.readText();
if (!text?.trim()) return;
useFeatureSettingsStore
.getState()
.markHintLearned("paste-inline");
await pasteTextAsFile(view, text, pasteCountRef);
} catch (_error) {
toast.error("Failed to paste as file attachment");
}
})();
return true;
}

if (event.key === "Enter") {
const sendMessagesWith =
useSettingsStore.getState().sendMessagesWith;
Expand Down Expand Up @@ -307,26 +361,11 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {

(async () => {
try {
const result = await trpcClient.os.saveClipboardText.mutate({
text: pastedText,
});

pasteCountRef.current += 1;
const lineCount = pastedText.split("\n").length;
const label = `Pasted text #${pasteCountRef.current} (${lineCount} lines)`;
const chipNode = view.state.schema.nodes.mentionChip.create({
type: "file",
id: result.path,
label,
});
const space = view.state.schema.text(" ");
const { tr } = view.state;
tr.replaceSelectionWith(chipNode).insert(
tr.selection.from,
space,
await pasteTextAsFile(view, pastedText, pasteCountRef);
showPasteHint(
"Pasted as file attachment",
"Click the chip to convert back to text.",
);
view.dispatch(tr);
view.focus();
} catch (_error) {
toast.error("Failed to convert pasted text to attachment");
}
Expand All @@ -335,6 +374,13 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
return true;
}

if (pastedText && pastedText.length > 200) {
showPasteHint(
"Pasted as text",
"Use ⌘⇧V to paste as a file attachment instead.",
);
}

return false;
},
},
Expand Down Expand Up @@ -469,6 +515,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
type: chip.type,
id: chip.id,
label: chip.label,
pastedText: false,
});
draft.saveDraft(editor, attachments);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -555,9 +555,10 @@ export function GeneralSettings() {
<Select.Trigger style={{ minWidth: "120px" }} />
<Select.Content>
<Select.Item value="off">Off</Select.Item>
<Select.Item value="500">500 chars</Select.Item>
<Select.Item value="1000">1,000 chars</Select.Item>
<Select.Item value="2500">2,500 chars</Select.Item>
<Select.Item value="5000">5,000 chars</Select.Item>
<Select.Item value="10000">10,000 chars</Select.Item>
</Select.Content>
</Select.Root>
</SettingRow>
Expand Down
47 changes: 44 additions & 3 deletions apps/code/src/renderer/features/settings/stores/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ export type LocalWorkspaceMode = "worktree" | "local";
export type SendMessagesWith = "enter" | "cmd+enter";
export type CompletionSound = "none" | "guitar" | "danilo" | "revi" | "meep";
export type AgentAdapter = "claude" | "codex";
export type AutoConvertLongText = "off" | "500" | "1000" | "2500";
export type AutoConvertLongText = "off" | "1000" | "2500" | "5000" | "10000";

export interface HintState {
count: number;
learned: boolean;
}
export type DiffOpenMode = "auto" | "split" | "same-pane" | "last-active-pane";

interface SettingsStore {
Expand All @@ -32,6 +37,11 @@ interface SettingsStore {
customInstructions: string;
diffOpenMode: DiffOpenMode;
hedgehogMode: boolean;
hints: Record<string, HintState>;

shouldShowHint: (key: string, max?: number) => boolean;
recordHintShown: (key: string) => void;
markHintLearned: (key: string) => void;

setCompletionSound: (sound: CompletionSound) => void;
setCompletionVolume: (volume: number) => void;
Expand All @@ -57,7 +67,7 @@ interface SettingsStore {

export const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
(set, get) => ({
defaultRunMode: "last_used",
lastUsedRunMode: "local",
lastUsedLocalWorkspaceMode: "local",
Expand All @@ -70,14 +80,41 @@ export const useSettingsStore = create<SettingsStore>()(
completionSound: "none",
completionVolume: 80,

autoConvertLongText: "1000",
autoConvertLongText: "2500",
sendMessagesWith: "enter",
allowBypassPermissions: false,
preventSleepWhileRunning: false,
debugLogsCloudRuns: false,
customInstructions: "",
diffOpenMode: "auto",
hedgehogMode: false,
hints: {},

shouldShowHint: (key, max = 3) => {
const hint = get().hints[key];
if (!hint) return true;
return !hint.learned && hint.count < max;
},
recordHintShown: (key) =>
set((state) => {
const current = state.hints[key] ?? { count: 0, learned: false };
return {
hints: {
...state.hints,
[key]: { ...current, count: current.count + 1 },
},
};
}),
markHintLearned: (key) =>
set((state) => {
const current = state.hints[key] ?? { count: 0, learned: false };
return {
hints: {
...state.hints,
[key]: { ...current, learned: true },
},
};
}),

setCompletionSound: (sound) => set({ completionSound: sound }),
setCompletionVolume: (volume) => set({ completionVolume: volume }),
Expand Down Expand Up @@ -131,6 +168,7 @@ export const useSettingsStore = create<SettingsStore>()(
customInstructions: state.customInstructions,
diffOpenMode: state.diffOpenMode,
hedgehogMode: state.hedgehogMode,
hints: state.hints,
}),
merge: (persisted, current) => {
const merged = {
Expand All @@ -141,6 +179,9 @@ export const useSettingsStore = create<SettingsStore>()(
(merged as Record<string, unknown>).autoConvertLongText =
merged.autoConvertLongText ? "1000" : "off";
}
if ((merged.autoConvertLongText as string) === "500") {
(merged as Record<string, unknown>).autoConvertLongText = "1000";
}
return merged;
},
},
Expand Down
Loading