Skip to content

Commit a3bfe14

Browse files
committed
feat(code): adjust pasted text UX, add explicit shortcut
1 parent 08b4260 commit a3bfe14

7 files changed

Lines changed: 206 additions & 26 deletions

File tree

apps/code/ARCHITECTURE.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,26 @@ const useTaskStore = create<TaskState>((set) => ({
281281
}));
282282
```
283283

284+
### Learned Hints
285+
286+
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.
287+
288+
```typescript
289+
// In the store: hints is Record<string, { count: number; learned: boolean }>
290+
const store = useFeatureSettingsStore.getState();
291+
292+
// Check if a hint should still be shown (max N times, not yet learned)
293+
if (store.shouldShowHint("my-hint-key", 3)) {
294+
store.recordHintShown("my-hint-key");
295+
toast.info("Did you know?", "You can do X with Y.");
296+
}
297+
298+
// When the user demonstrates the behavior, mark it learned (stops showing)
299+
store.markHintLearned("my-hint-key");
300+
```
301+
302+
Hint state is persisted via `electronStorage`. Use this pattern instead of ad-hoc boolean flags when introducing new discoverable features.
303+
284304
## Services
285305

286306
Services encapsulate business logic and exist in both processes:

apps/code/src/renderer/constants/keyboard-shortcuts.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const SHORTCUTS = {
1717
OPEN_IN_EDITOR: "mod+o",
1818
COPY_PATH: "mod+shift+c",
1919
TOGGLE_FOCUS: "mod+r",
20+
PASTE_AS_FILE: "mod+shift+v",
2021
BLUR: "escape",
2122
SUBMIT_BLUR: "mod+enter",
2223
} as const;
@@ -137,6 +138,13 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
137138
category: "panels",
138139
context: "Task detail",
139140
},
141+
{
142+
id: "paste-as-file",
143+
keys: SHORTCUTS.PASTE_AS_FILE,
144+
description: "Paste as file attachment",
145+
category: "editor",
146+
context: "Message editor",
147+
},
140148
{
141149
id: "prompt-history-prev",
142150
keys: "shift+up",

apps/code/src/renderer/features/message-editor/tiptap/MentionChipNode.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface MentionChipAttrs {
1414
type: ChipType;
1515
id: string;
1616
label: string;
17+
pastedText: boolean;
1718
}
1819

1920
declare module "@tiptap/core" {
@@ -36,6 +37,7 @@ export const MentionChipNode = Node.create({
3637
type: { default: "file" as ChipType },
3738
id: { default: "" },
3839
label: { default: "" },
40+
pastedText: { default: false },
3941
};
4042
},
4143

apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import { Tooltip } from "@components/ui/Tooltip";
2+
import { useSettingsStore as useFeatureSettingsStore } from "@features/settings/stores/settingsStore";
3+
import { trpcClient } from "@renderer/trpc/client";
4+
import type { Node as PmNode } from "@tiptap/pm/model";
5+
import type { Editor } from "@tiptap/react";
16
import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
27
import type { MentionChipAttrs } from "./MentionChipNode";
38

@@ -16,12 +21,68 @@ function DefaultChip({ type, label }: { type: string; label: string }) {
1621
);
1722
}
1823

19-
export function MentionChipView({ node }: NodeViewProps) {
20-
const { type, label } = node.attrs as MentionChipAttrs;
24+
function PastedTextChip({
25+
label,
26+
filePath,
27+
editor,
28+
node,
29+
getPos,
30+
}: {
31+
label: string;
32+
filePath: string;
33+
editor: Editor;
34+
node: PmNode;
35+
getPos: () => number | undefined;
36+
}) {
37+
const handleClick = async () => {
38+
useFeatureSettingsStore.getState().markHintLearned("paste-as-file");
39+
40+
const content = await trpcClient.fs.readAbsoluteFile.query({
41+
filePath,
42+
});
43+
if (!content) return;
44+
45+
const pos = getPos();
46+
if (pos == null) return;
47+
48+
editor
49+
.chain()
50+
.focus()
51+
.deleteRange({ from: pos, to: pos + node.nodeSize })
52+
.insertContentAt(pos, content)
53+
.run();
54+
};
55+
56+
return (
57+
<Tooltip content="Click to paste as text instead">
58+
<button
59+
type="button"
60+
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)]"
61+
contentEditable={false}
62+
onClick={handleClick}
63+
>
64+
@{label}
65+
</button>
66+
</Tooltip>
67+
);
68+
}
69+
70+
export function MentionChipView({ node, getPos, editor }: NodeViewProps) {
71+
const { type, id, label, pastedText } = node.attrs as MentionChipAttrs;
2172

2273
return (
2374
<NodeViewWrapper as="span" className="inline">
24-
<DefaultChip type={type} label={label} />
75+
{pastedText ? (
76+
<PastedTextChip
77+
label={label}
78+
filePath={id}
79+
editor={editor}
80+
node={node}
81+
getPos={getPos}
82+
/>
83+
) : (
84+
<DefaultChip type={type} label={label} />
85+
)}
2586
</NodeViewWrapper>
2687
);
2788
}

apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { useSettingsStore as useFeatureSettingsStore } from "@features/settings/
33
import { trpcClient } from "@renderer/trpc/client";
44
import { toast } from "@renderer/utils/toast";
55
import { useSettingsStore } from "@stores/settingsStore";
6+
import type { EditorView } from "@tiptap/pm/view";
67
import { useEditor } from "@tiptap/react";
8+
import type React from "react";
79
import { useCallback, useEffect, useRef, useState } from "react";
810
import { usePromptHistoryStore } from "../stores/promptHistoryStore";
911
import type { FileAttachment, MentionChip } from "../utils/content";
@@ -37,6 +39,37 @@ export interface UseTiptapEditorOptions {
3739
const EDITOR_CLASS =
3840
"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]";
3941

42+
async function pasteTextAsFile(
43+
view: EditorView,
44+
text: string,
45+
pasteCountRef: React.MutableRefObject<number>,
46+
): Promise<void> {
47+
const result = await trpcClient.os.saveClipboardText.mutate({ text });
48+
pasteCountRef.current += 1;
49+
const lineCount = text.split("\n").length;
50+
const label = `Pasted text #${pasteCountRef.current} (${lineCount} lines)`;
51+
const chipNode = view.state.schema.nodes.mentionChip.create({
52+
type: "file",
53+
id: result.path,
54+
label,
55+
pastedText: true,
56+
});
57+
const space = view.state.schema.text(" ");
58+
const { tr } = view.state;
59+
tr.replaceSelectionWith(chipNode).insert(tr.selection.from, space);
60+
view.dispatch(tr);
61+
view.focus();
62+
}
63+
64+
function showPasteHint(message: string, description: string): void {
65+
const store = useFeatureSettingsStore.getState();
66+
const key =
67+
message === "Pasted as file attachment" ? "paste-as-file" : "paste-inline";
68+
if (!store.shouldShowHint(key)) return;
69+
store.recordHintShown(key);
70+
toast.info(message, description);
71+
}
72+
4073
export function useTiptapEditor(options: UseTiptapEditorOptions) {
4174
const {
4275
sessionId,
@@ -118,6 +151,27 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
118151
},
119152
},
120153
handleKeyDown: (view, event) => {
154+
if (
155+
event.key === "v" &&
156+
(event.metaKey || event.ctrlKey) &&
157+
event.shiftKey
158+
) {
159+
event.preventDefault();
160+
(async () => {
161+
try {
162+
const text = await navigator.clipboard.readText();
163+
if (!text?.trim()) return;
164+
useFeatureSettingsStore
165+
.getState()
166+
.markHintLearned("paste-inline");
167+
await pasteTextAsFile(view, text, pasteCountRef);
168+
} catch (_error) {
169+
toast.error("Failed to paste as file attachment");
170+
}
171+
})();
172+
return true;
173+
}
174+
121175
if (event.key === "Enter") {
122176
const sendMessagesWith =
123177
useSettingsStore.getState().sendMessagesWith;
@@ -307,26 +361,11 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
307361

308362
(async () => {
309363
try {
310-
const result = await trpcClient.os.saveClipboardText.mutate({
311-
text: pastedText,
312-
});
313-
314-
pasteCountRef.current += 1;
315-
const lineCount = pastedText.split("\n").length;
316-
const label = `Pasted text #${pasteCountRef.current} (${lineCount} lines)`;
317-
const chipNode = view.state.schema.nodes.mentionChip.create({
318-
type: "file",
319-
id: result.path,
320-
label,
321-
});
322-
const space = view.state.schema.text(" ");
323-
const { tr } = view.state;
324-
tr.replaceSelectionWith(chipNode).insert(
325-
tr.selection.from,
326-
space,
364+
await pasteTextAsFile(view, pastedText, pasteCountRef);
365+
showPasteHint(
366+
"Pasted as file attachment",
367+
"Click the chip to convert back to text.",
327368
);
328-
view.dispatch(tr);
329-
view.focus();
330369
} catch (_error) {
331370
toast.error("Failed to convert pasted text to attachment");
332371
}
@@ -335,6 +374,13 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
335374
return true;
336375
}
337376

377+
if (pastedText && pastedText.length > 200) {
378+
showPasteHint(
379+
"Pasted as text",
380+
"Use ⌘⇧V to paste as a file attachment instead.",
381+
);
382+
}
383+
338384
return false;
339385
},
340386
},
@@ -469,6 +515,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
469515
type: chip.type,
470516
id: chip.id,
471517
label: chip.label,
518+
pastedText: false,
472519
});
473520
draft.saveDraft(editor, attachments);
474521
},

apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,9 +555,10 @@ export function GeneralSettings() {
555555
<Select.Trigger style={{ minWidth: "120px" }} />
556556
<Select.Content>
557557
<Select.Item value="off">Off</Select.Item>
558-
<Select.Item value="500">500 chars</Select.Item>
559558
<Select.Item value="1000">1,000 chars</Select.Item>
560559
<Select.Item value="2500">2,500 chars</Select.Item>
560+
<Select.Item value="5000">5,000 chars</Select.Item>
561+
<Select.Item value="10000">10,000 chars</Select.Item>
561562
</Select.Content>
562563
</Select.Root>
563564
</SettingRow>

apps/code/src/renderer/features/settings/stores/settingsStore.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ export type LocalWorkspaceMode = "worktree" | "local";
88
export type SendMessagesWith = "enter" | "cmd+enter";
99
export type CompletionSound = "none" | "guitar" | "danilo" | "revi" | "meep";
1010
export type AgentAdapter = "claude" | "codex";
11-
export type AutoConvertLongText = "off" | "500" | "1000" | "2500";
11+
export type AutoConvertLongText = "off" | "1000" | "2500" | "5000" | "10000";
12+
13+
export interface HintState {
14+
count: number;
15+
learned: boolean;
16+
}
1217
export type DiffOpenMode = "auto" | "split" | "same-pane" | "last-active-pane";
1318

1419
interface SettingsStore {
@@ -32,6 +37,11 @@ interface SettingsStore {
3237
customInstructions: string;
3338
diffOpenMode: DiffOpenMode;
3439
hedgehogMode: boolean;
40+
hints: Record<string, HintState>;
41+
42+
shouldShowHint: (key: string, max?: number) => boolean;
43+
recordHintShown: (key: string) => void;
44+
markHintLearned: (key: string) => void;
3545

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

5868
export const useSettingsStore = create<SettingsStore>()(
5969
persist(
60-
(set) => ({
70+
(set, get) => ({
6171
defaultRunMode: "last_used",
6272
lastUsedRunMode: "local",
6373
lastUsedLocalWorkspaceMode: "local",
@@ -70,14 +80,41 @@ export const useSettingsStore = create<SettingsStore>()(
7080
completionSound: "none",
7181
completionVolume: 80,
7282

73-
autoConvertLongText: "1000",
83+
autoConvertLongText: "2500",
7484
sendMessagesWith: "enter",
7585
allowBypassPermissions: false,
7686
preventSleepWhileRunning: false,
7787
debugLogsCloudRuns: false,
7888
customInstructions: "",
7989
diffOpenMode: "auto",
8090
hedgehogMode: false,
91+
hints: {},
92+
93+
shouldShowHint: (key, max = 3) => {
94+
const hint = get().hints[key];
95+
if (!hint) return true;
96+
return !hint.learned && hint.count < max;
97+
},
98+
recordHintShown: (key) =>
99+
set((state) => {
100+
const current = state.hints[key] ?? { count: 0, learned: false };
101+
return {
102+
hints: {
103+
...state.hints,
104+
[key]: { ...current, count: current.count + 1 },
105+
},
106+
};
107+
}),
108+
markHintLearned: (key) =>
109+
set((state) => {
110+
const current = state.hints[key] ?? { count: 0, learned: false };
111+
return {
112+
hints: {
113+
...state.hints,
114+
[key]: { ...current, learned: true },
115+
},
116+
};
117+
}),
81118

82119
setCompletionSound: (sound) => set({ completionSound: sound }),
83120
setCompletionVolume: (volume) => set({ completionVolume: volume }),
@@ -131,6 +168,7 @@ export const useSettingsStore = create<SettingsStore>()(
131168
customInstructions: state.customInstructions,
132169
diffOpenMode: state.diffOpenMode,
133170
hedgehogMode: state.hedgehogMode,
171+
hints: state.hints,
134172
}),
135173
merge: (persisted, current) => {
136174
const merged = {
@@ -141,6 +179,9 @@ export const useSettingsStore = create<SettingsStore>()(
141179
(merged as Record<string, unknown>).autoConvertLongText =
142180
merged.autoConvertLongText ? "1000" : "off";
143181
}
182+
if ((merged.autoConvertLongText as string) === "500") {
183+
(merged as Record<string, unknown>).autoConvertLongText = "1000";
184+
}
144185
return merged;
145186
},
146187
},

0 commit comments

Comments
 (0)