cue-console 0.1.2 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -3
- package/next-env.d.ts +1 -1
- package/next.config.ts +4 -0
- package/package.json +2 -2
- package/src/app/layout.tsx +1 -1
- package/src/components/chat-view.tsx +66 -7
- package/src/components/conversation-list.tsx +9 -1
- package/src/components/payload-card.tsx +450 -229
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# cue-console
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
| Mobile | Desktop |
|
|
4
|
+
| --- | --- |
|
|
5
|
+
|  |  |
|
|
4
6
|
|
|
5
7
|
---
|
|
6
8
|
|
|
@@ -20,9 +22,11 @@ Think of it as an “all-in-one” collaboration console for your agents and CLI
|
|
|
20
22
|
|
|
21
23
|
Run the console and pair it with `cuemcp`.
|
|
22
24
|
|
|
23
|
-
### Step 1:
|
|
25
|
+
### Step 1: Install `cue-console`
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
```bash
|
|
28
|
+
npm install -g cue-console
|
|
29
|
+
```
|
|
26
30
|
|
|
27
31
|
### Step 2: Start `cue-console`
|
|
28
32
|
|
|
@@ -30,8 +34,22 @@ Add and run the MCP server in your agent/runtime (see [`cue-mcp`](https://github
|
|
|
30
34
|
cue-console dev --port 3000
|
|
31
35
|
```
|
|
32
36
|
|
|
37
|
+
Alternatively, you can run it without installing globally:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx cue-console dev --port 3000
|
|
41
|
+
```
|
|
42
|
+
|
|
33
43
|
Open `http://localhost:3000`.
|
|
34
44
|
|
|
45
|
+
### Step 3: Start `cuemcp`
|
|
46
|
+
|
|
47
|
+
Add and run the MCP server in your agent/runtime (see [`cue-mcp`](https://github.com/nmhjklnm/cue-mcp) for client-specific MCP configuration).
|
|
48
|
+
|
|
49
|
+
### Step 4: Connect your runtime
|
|
50
|
+
|
|
51
|
+
In the agent/runtime you want to use, type `cue` to trigger connect (or reconnect) and route the collaboration flow to `cue-console`.
|
|
52
|
+
|
|
35
53
|
---
|
|
36
54
|
|
|
37
55
|
## Notes
|
|
@@ -39,8 +57,53 @@ Open `http://localhost:3000`.
|
|
|
39
57
|
- The current version can occasionally disconnect (for example, the agent replies directly without calling `cue()` at the end).
|
|
40
58
|
- If you get disconnected, type `cue` in the affected runtime to trigger auto-reconnect.
|
|
41
59
|
|
|
60
|
+
<details>
|
|
61
|
+
<summary>Human Agent Protocol (HAP) rules (required)</summary>
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
Human Agent Protocol (HAP)
|
|
65
|
+
|
|
66
|
+
Hard rule: the agent must call the `cue` tool at the end of every response, no exceptions.
|
|
67
|
+
Always use the cue MCP to provide a final summary, ask a question, or request input.
|
|
68
|
+
|
|
69
|
+
Convention: when the user types `cue`, the user is accepting the HAP rules (supports disconnect + reconnect).
|
|
70
|
+
|
|
71
|
+
- First-time connection / no agent_id: call join() to obtain an agent_id, then call cue().
|
|
72
|
+
- Reconnect / existing agent_id (e.g. the cue call timed out but the chat continued):
|
|
73
|
+
when the user types cue again, prefer calling cue() with the existing agent_id;
|
|
74
|
+
only call join() again if you cannot determine the agent_id.
|
|
75
|
+
|
|
76
|
+
When to call
|
|
77
|
+
|
|
78
|
+
- On first message in a new chat (no history): call join().
|
|
79
|
+
- After completing any task: call cue().
|
|
80
|
+
- Before ending any response: call cue().
|
|
81
|
+
|
|
82
|
+
Forbidden behavior
|
|
83
|
+
|
|
84
|
+
- Using a self-chosen name without calling join() first.
|
|
85
|
+
- Ending a reply without calling cue().
|
|
86
|
+
- Replacing cue() with "let me know if you need anything else".
|
|
87
|
+
- Assuming there are no follow-ups.
|
|
88
|
+
|
|
89
|
+
Notes
|
|
90
|
+
|
|
91
|
+
If you are not sure whether to call it, call it.
|
|
92
|
+
|
|
93
|
+
Not calling cue() means the user cannot continue the interaction.
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
</details>
|
|
97
|
+
|
|
42
98
|
---
|
|
43
99
|
|
|
100
|
+
## Design notes
|
|
101
|
+
|
|
102
|
+
- **Group chat UX**: collaboration requests and responses are organized like a chat, so you can scan context quickly.
|
|
103
|
+
- **Mentions (`@...`)**: lightweight addressing to route a response or bring a specific agent/human into the thread.
|
|
104
|
+
- **Option cards**: responses can be captured as tappable cards with a responsive layout that works on both mobile and desktop.
|
|
105
|
+
- **Keyboard-first + mobile-friendly**: input affordances aim to work well with both quick desktop workflows and on-the-go usage.
|
|
106
|
+
|
|
44
107
|
## Pairing with cuemcp
|
|
45
108
|
|
|
46
109
|
**Rule #1:** both sides must agree on the same DB location.
|
package/next-env.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="next" />
|
|
2
2
|
/// <reference types="next/image-types/global" />
|
|
3
|
-
import "./.next/
|
|
3
|
+
import "./.next/types/routes.d.ts";
|
|
4
4
|
|
|
5
5
|
// NOTE: This file should not be edited
|
|
6
6
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
package/next.config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { NextConfig } from "next";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
|
|
3
4
|
const nextConfig: NextConfig = {
|
|
4
5
|
experimental: {
|
|
@@ -6,6 +7,9 @@ const nextConfig: NextConfig = {
|
|
|
6
7
|
bodySizeLimit: "10mb",
|
|
7
8
|
},
|
|
8
9
|
},
|
|
10
|
+
turbopack: {
|
|
11
|
+
root: path.resolve(__dirname),
|
|
12
|
+
},
|
|
9
13
|
};
|
|
10
14
|
|
|
11
15
|
export default nextConfig;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cue-console",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Cue Hub console launcher (Next.js UI)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"keywords": ["mcp", "cue", "console", "nextjs"],
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"@radix-ui/react-separator": "^1.1.8",
|
|
34
34
|
"@radix-ui/react-slot": "^1.2.4",
|
|
35
35
|
"@tailwindcss/postcss": "^4",
|
|
36
|
+
"@types/node": "^20",
|
|
36
37
|
"better-sqlite3": "^12.5.0",
|
|
37
38
|
"class-variance-authority": "^0.7.1",
|
|
38
39
|
"clsx": "^2.1.1",
|
|
@@ -51,7 +52,6 @@
|
|
|
51
52
|
},
|
|
52
53
|
"devDependencies": {
|
|
53
54
|
"@types/better-sqlite3": "^7.6.13",
|
|
54
|
-
"@types/node": "^20",
|
|
55
55
|
"@types/react": "^19",
|
|
56
56
|
"@types/react-dom": "^19",
|
|
57
57
|
"@types/uuid": "^11.0.0",
|
package/src/app/layout.tsx
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
type CueResponse,
|
|
39
39
|
type AgentTimelineItem,
|
|
40
40
|
} from "@/lib/actions";
|
|
41
|
-
import { ChevronLeft } from "lucide-react";
|
|
41
|
+
import { ChevronLeft, Github } from "lucide-react";
|
|
42
42
|
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
|
43
43
|
import { PayloadCard } from "@/components/payload-card";
|
|
44
44
|
import { ChatComposer } from "@/components/chat-composer";
|
|
@@ -423,11 +423,51 @@ export function ChatView({ type, id, name, onBack }: ChatViewProps) {
|
|
|
423
423
|
closeMention();
|
|
424
424
|
};
|
|
425
425
|
|
|
426
|
-
const pasteToInput = (
|
|
426
|
+
const pasteToInput = (
|
|
427
|
+
text: string,
|
|
428
|
+
mode: "replace" | "append" | "upsert" = "replace"
|
|
429
|
+
) => {
|
|
427
430
|
const cleaned = (text || "").trim();
|
|
428
431
|
if (!cleaned) return;
|
|
429
432
|
|
|
430
433
|
const next = (() => {
|
|
434
|
+
if (mode === "replace") return cleaned;
|
|
435
|
+
|
|
436
|
+
if (mode === "upsert") {
|
|
437
|
+
// Upsert by "<field>:" prefix (first colon defines the key)
|
|
438
|
+
const colon = cleaned.indexOf(":");
|
|
439
|
+
if (colon <= 0) {
|
|
440
|
+
// No clear field key; fall back to append behavior
|
|
441
|
+
mode = "append";
|
|
442
|
+
} else {
|
|
443
|
+
const key = cleaned.slice(0, colon).trim();
|
|
444
|
+
if (!key) {
|
|
445
|
+
mode = "append";
|
|
446
|
+
} else {
|
|
447
|
+
const rawLines = input.split(/\r?\n/);
|
|
448
|
+
const lines = rawLines.map((s) => s.replace(/\s+$/, ""));
|
|
449
|
+
const needle = key + ":";
|
|
450
|
+
|
|
451
|
+
let replaced = false;
|
|
452
|
+
const out = lines.map((line) => {
|
|
453
|
+
const t = line.trimStart();
|
|
454
|
+
if (!replaced && t.startsWith(needle)) {
|
|
455
|
+
replaced = true;
|
|
456
|
+
return cleaned;
|
|
457
|
+
}
|
|
458
|
+
return line;
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
if (!replaced) {
|
|
462
|
+
const base = out.join("\n").trim() ? out.join("\n").replace(/\s+$/, "") : "";
|
|
463
|
+
return base ? base + "\n" + cleaned : cleaned;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return out.join("\n");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
431
471
|
if (mode !== "append") return cleaned;
|
|
432
472
|
|
|
433
473
|
const lines = input
|
|
@@ -811,10 +851,19 @@ export function ChatView({ type, id, name, onBack }: ChatViewProps) {
|
|
|
811
851
|
const isPending = (r: CueRequest) => r.status === "PENDING";
|
|
812
852
|
|
|
813
853
|
if (type === "agent") {
|
|
814
|
-
// Direct chat:
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
|
|
854
|
+
// Direct chat: reply only to the latest pending request for this agent
|
|
855
|
+
const latestPending = pendingRequests
|
|
856
|
+
.filter(isPending)
|
|
857
|
+
.slice()
|
|
858
|
+
.sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""))[0];
|
|
859
|
+
|
|
860
|
+
if (latestPending) {
|
|
861
|
+
const result = await submitResponse(
|
|
862
|
+
latestPending.request_id,
|
|
863
|
+
input,
|
|
864
|
+
currentImages,
|
|
865
|
+
draftMentions
|
|
866
|
+
);
|
|
818
867
|
if (!result.success) {
|
|
819
868
|
setError(result.error || "Send failed");
|
|
820
869
|
setBusy(false);
|
|
@@ -1035,6 +1084,16 @@ export function ChatView({ type, id, name, onBack }: ChatViewProps) {
|
|
|
1035
1084
|
</p>
|
|
1036
1085
|
)}
|
|
1037
1086
|
</div>
|
|
1087
|
+
<Button variant="ghost" size="icon" asChild>
|
|
1088
|
+
<a
|
|
1089
|
+
href="https://github.com/nmhjklnm/cue-console"
|
|
1090
|
+
target="_blank"
|
|
1091
|
+
rel="noreferrer"
|
|
1092
|
+
title="https://github.com/nmhjklnm/cue-console"
|
|
1093
|
+
>
|
|
1094
|
+
<Github className="h-5 w-5" />
|
|
1095
|
+
</a>
|
|
1096
|
+
</Button>
|
|
1038
1097
|
{type === "group" && (
|
|
1039
1098
|
<span className="hidden sm:inline text-[11px] text-muted-foreground select-none mr-1" title="Type @ to mention members">
|
|
1040
1099
|
@ mention
|
|
@@ -1235,7 +1294,7 @@ function MessageBubble({
|
|
|
1235
1294
|
disabled?: boolean;
|
|
1236
1295
|
currentInput?: string;
|
|
1237
1296
|
isGroup?: boolean;
|
|
1238
|
-
onPasteChoice?: (text: string, mode?: "replace" | "append") => void;
|
|
1297
|
+
onPasteChoice?: (text: string, mode?: "replace" | "append" | "upsert") => void;
|
|
1239
1298
|
onMentionAgent?: (agentId: string) => void;
|
|
1240
1299
|
onReply?: () => void;
|
|
1241
1300
|
onCancel?: () => void;
|
|
@@ -241,7 +241,15 @@ export function ConversationList({
|
|
|
241
241
|
</div>
|
|
242
242
|
) : (
|
|
243
243
|
<div className="flex w-full items-center justify-between gap-2">
|
|
244
|
-
<
|
|
244
|
+
<a
|
|
245
|
+
href="https://github.com/nmhjklnm/cue-console"
|
|
246
|
+
target="_blank"
|
|
247
|
+
rel="noreferrer"
|
|
248
|
+
className="text-lg font-semibold hover:underline underline-offset-4"
|
|
249
|
+
title="Open cue-console repository"
|
|
250
|
+
>
|
|
251
|
+
cue-console
|
|
252
|
+
</a>
|
|
245
253
|
<div className="flex items-center gap-1">
|
|
246
254
|
<Button
|
|
247
255
|
variant="ghost"
|
|
@@ -1,210 +1,396 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useMemo } from "react";
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
4
|
import { Badge } from "@/components/ui/badge";
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
6
|
import { cn } from "@/lib/utils";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
type PasteMode = "replace" | "append" | "upsert";
|
|
9
|
+
type OnPasteChoice = (text: string, mode?: PasteMode) => void;
|
|
10
|
+
|
|
11
|
+
type ParsedChoice = { id?: string; label?: string } | string;
|
|
12
|
+
type ParsedField =
|
|
13
|
+
| {
|
|
14
|
+
id?: string;
|
|
15
|
+
label?: string;
|
|
16
|
+
kind?: string;
|
|
17
|
+
allow_multiple?: boolean;
|
|
18
|
+
options?: ParsedChoice[];
|
|
19
|
+
}
|
|
20
|
+
| string;
|
|
21
|
+
|
|
22
|
+
type ParsedViewModel =
|
|
23
|
+
| { kind: "raw"; raw: string }
|
|
24
|
+
| { kind: "unknown"; pretty: string }
|
|
25
|
+
| { kind: "choice"; allowMultiple: boolean; options: ParsedChoice[] }
|
|
26
|
+
| { kind: "confirm"; text: string; confirmLabel: string; cancelLabel: string }
|
|
27
|
+
| { kind: "form"; fields: ParsedField[] };
|
|
28
|
+
|
|
29
|
+
function parsePayload(raw?: string | null): ParsedViewModel | null {
|
|
30
|
+
if (!raw) return null;
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
33
|
+
if (!parsed || typeof parsed !== "object") {
|
|
34
|
+
return { kind: "raw", raw: String(raw) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const obj = parsed as Record<string, unknown>;
|
|
38
|
+
const type = typeof obj.type === "string" ? obj.type : "unknown";
|
|
39
|
+
|
|
40
|
+
if (type === "choice") {
|
|
41
|
+
return {
|
|
42
|
+
kind: "choice",
|
|
43
|
+
allowMultiple: Boolean(obj.allow_multiple),
|
|
44
|
+
options: Array.isArray(obj.options) ? (obj.options as ParsedChoice[]) : [],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (type === "confirm") {
|
|
49
|
+
return {
|
|
50
|
+
kind: "confirm",
|
|
51
|
+
text: typeof obj.text === "string" ? obj.text : "",
|
|
52
|
+
confirmLabel:
|
|
53
|
+
typeof obj.confirm_label === "string" ? obj.confirm_label : "Confirm",
|
|
54
|
+
cancelLabel:
|
|
55
|
+
typeof obj.cancel_label === "string" ? obj.cancel_label : "Cancel",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (type === "form") {
|
|
60
|
+
return {
|
|
61
|
+
kind: "form",
|
|
62
|
+
fields: Array.isArray(obj.fields) ? (obj.fields as ParsedField[]) : [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { kind: "unknown", pretty: JSON.stringify(parsed, null, 2) };
|
|
67
|
+
} catch {
|
|
68
|
+
return { kind: "raw", raw };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatChoiceLabel(opt: ParsedChoice): string {
|
|
73
|
+
if (opt && typeof opt === "object") {
|
|
74
|
+
const o = opt as Record<string, unknown>;
|
|
75
|
+
const label = typeof o.label === "string" ? o.label : "";
|
|
76
|
+
return label.trim();
|
|
77
|
+
}
|
|
78
|
+
return String(opt || "").trim();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function fieldDisplayName(f: ParsedField, idx: number): string {
|
|
82
|
+
if (f && typeof f === "object") {
|
|
83
|
+
const fo = f as Record<string, unknown>;
|
|
84
|
+
const id = typeof fo.id === "string" ? fo.id : "";
|
|
85
|
+
const label = typeof fo.label === "string" ? fo.label : "";
|
|
86
|
+
return (label || id || `Field ${idx + 1}`).trim();
|
|
87
|
+
}
|
|
88
|
+
return String(f || `Field ${idx + 1}`).trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function findFieldLine(selectedLines: Set<string>, fieldKey: string): string | null {
|
|
92
|
+
const needle = `${fieldKey}:`;
|
|
93
|
+
for (const line of selectedLines) {
|
|
94
|
+
const t = (line || "").trim();
|
|
95
|
+
if (t.startsWith(needle)) return t;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseMultiValues(line: string, fieldKey: string): string[] {
|
|
101
|
+
const needle = `${fieldKey}:`;
|
|
102
|
+
const idx = line.indexOf(needle);
|
|
103
|
+
if (idx < 0) return [];
|
|
104
|
+
const rest = line.slice(idx + needle.length).trim();
|
|
105
|
+
if (!rest) return [];
|
|
106
|
+
return rest
|
|
107
|
+
.split(",")
|
|
108
|
+
.map((s) => s.trim())
|
|
109
|
+
.filter(Boolean);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function toggleValue(values: string[], v: string): string[] {
|
|
113
|
+
const next = new Set(values);
|
|
114
|
+
if (next.has(v)) next.delete(v);
|
|
115
|
+
else next.add(v);
|
|
116
|
+
return Array.from(next);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function PayloadChoiceView({
|
|
120
|
+
vm,
|
|
10
121
|
disabled,
|
|
11
122
|
onPasteChoice,
|
|
12
123
|
selectedLines,
|
|
13
124
|
}: {
|
|
14
|
-
|
|
125
|
+
vm: Extract<ParsedViewModel, { kind: "choice" }>;
|
|
15
126
|
disabled?: boolean;
|
|
16
|
-
onPasteChoice?:
|
|
127
|
+
onPasteChoice?: OnPasteChoice;
|
|
17
128
|
selectedLines?: Set<string>;
|
|
18
129
|
}) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
130
|
+
const selected = selectedLines ?? new Set<string>();
|
|
131
|
+
return (
|
|
132
|
+
<div className="mt-2 rounded-xl border bg-linear-to-b from-background to-muted/20 p-2.5 text-xs shadow-sm">
|
|
133
|
+
<div className="mb-2 flex items-center justify-between gap-2">
|
|
134
|
+
<div className="flex items-center gap-2">
|
|
135
|
+
<Badge variant="secondary" className="text-[11px]">
|
|
136
|
+
Choice
|
|
137
|
+
</Badge>
|
|
138
|
+
<Badge variant="outline" className="text-[11px]">
|
|
139
|
+
{vm.allowMultiple ? "多选" : "单选"}
|
|
140
|
+
</Badge>
|
|
141
|
+
</div>
|
|
142
|
+
<span className="text-[11px] text-muted-foreground">
|
|
143
|
+
{vm.allowMultiple
|
|
144
|
+
? "点击选项追加到输入框(可多次选择)"
|
|
145
|
+
: "点击选项填入输入框(单选替换)"}
|
|
146
|
+
</span>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="grid grid-cols-1 gap-2">
|
|
149
|
+
{vm.options.length > 0 ? (
|
|
150
|
+
vm.options.map((opt, idx) => {
|
|
151
|
+
const label = formatChoiceLabel(opt);
|
|
152
|
+
const text = label || "<empty>";
|
|
153
|
+
const cleaned = label.trim();
|
|
154
|
+
const isSelected = vm.allowMultiple && !!cleaned && selected.has(cleaned);
|
|
155
|
+
return (
|
|
156
|
+
<Button
|
|
157
|
+
key={`opt-${idx}`}
|
|
158
|
+
type="button"
|
|
159
|
+
variant={isSelected ? "secondary" : "outline"}
|
|
160
|
+
size="sm"
|
|
161
|
+
className={cn(
|
|
162
|
+
"h-auto min-h-9 justify-start gap-2 px-3 py-2 text-left text-xs",
|
|
163
|
+
"rounded-xl",
|
|
164
|
+
isSelected && "cursor-not-allowed opacity-80"
|
|
165
|
+
)}
|
|
166
|
+
disabled={disabled || !onPasteChoice || !label || isSelected}
|
|
167
|
+
onClick={() =>
|
|
168
|
+
onPasteChoice?.(label, vm.allowMultiple ? "append" : "replace")
|
|
169
|
+
}
|
|
170
|
+
title={label ? `Click to paste: ${label}` : undefined}
|
|
171
|
+
>
|
|
172
|
+
<span className="min-w-0 flex-1 truncate">{text}</span>
|
|
173
|
+
</Button>
|
|
174
|
+
);
|
|
175
|
+
})
|
|
176
|
+
) : (
|
|
177
|
+
<div className="text-muted-foreground">No options</div>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
70
183
|
|
|
71
|
-
|
|
184
|
+
function PayloadConfirmView({
|
|
185
|
+
vm,
|
|
186
|
+
disabled,
|
|
187
|
+
onPasteChoice,
|
|
188
|
+
}: {
|
|
189
|
+
vm: Extract<ParsedViewModel, { kind: "confirm" }>;
|
|
190
|
+
disabled?: boolean;
|
|
191
|
+
onPasteChoice?: OnPasteChoice;
|
|
192
|
+
}) {
|
|
193
|
+
return (
|
|
194
|
+
<div className="mt-2 rounded-xl border bg-linear-to-b from-background to-muted/20 p-2.5 text-xs shadow-sm">
|
|
195
|
+
<div className="mb-2 flex items-center justify-between gap-2">
|
|
196
|
+
<Badge variant="secondary" className="text-[11px]">
|
|
197
|
+
Confirm
|
|
198
|
+
</Badge>
|
|
199
|
+
<span className="text-[11px] text-muted-foreground">Click a button to fill the input</span>
|
|
200
|
+
</div>
|
|
201
|
+
{vm.text && <div className="mb-2 whitespace-pre-wrap leading-normal">{vm.text}</div>}
|
|
202
|
+
<div className="flex flex-col gap-2">
|
|
203
|
+
<Button
|
|
204
|
+
type="button"
|
|
205
|
+
variant="default"
|
|
206
|
+
size="sm"
|
|
207
|
+
className="h-9 w-full rounded-xl px-3 text-xs"
|
|
208
|
+
disabled={disabled || !onPasteChoice}
|
|
209
|
+
onClick={() => onPasteChoice?.(vm.confirmLabel)}
|
|
210
|
+
title={`Click to paste: ${vm.confirmLabel}`}
|
|
211
|
+
>
|
|
212
|
+
{vm.confirmLabel}
|
|
213
|
+
</Button>
|
|
214
|
+
<Button
|
|
215
|
+
type="button"
|
|
216
|
+
variant="outline"
|
|
217
|
+
size="sm"
|
|
218
|
+
className="h-9 w-full rounded-xl px-3 text-xs"
|
|
219
|
+
disabled={disabled || !onPasteChoice}
|
|
220
|
+
onClick={() => onPasteChoice?.(vm.cancelLabel)}
|
|
221
|
+
title={`Click to paste: ${vm.cancelLabel}`}
|
|
222
|
+
>
|
|
223
|
+
{vm.cancelLabel}
|
|
224
|
+
</Button>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
72
229
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
230
|
+
function PayloadFormView({
|
|
231
|
+
vm,
|
|
232
|
+
disabled,
|
|
233
|
+
onPasteChoice,
|
|
234
|
+
selectedLines,
|
|
235
|
+
}: {
|
|
236
|
+
vm: Extract<ParsedViewModel, { kind: "form" }>;
|
|
237
|
+
disabled?: boolean;
|
|
238
|
+
onPasteChoice?: OnPasteChoice;
|
|
239
|
+
selectedLines?: Set<string>;
|
|
240
|
+
}) {
|
|
241
|
+
const [activeFieldIdx, setActiveFieldIdx] = useState(0);
|
|
80
242
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
</pre>
|
|
86
|
-
);
|
|
87
|
-
}
|
|
243
|
+
const clampFieldIdx = (idx: number) => {
|
|
244
|
+
const max = Math.max(0, vm.fields.length - 1);
|
|
245
|
+
return Math.min(Math.max(0, idx), max);
|
|
246
|
+
};
|
|
88
247
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
248
|
+
const safeActiveIdx = clampFieldIdx(activeFieldIdx);
|
|
249
|
+
const selected = selectedLines ?? new Set<string>();
|
|
250
|
+
|
|
251
|
+
const advance = () => setActiveFieldIdx((prev) => clampFieldIdx(prev + 1));
|
|
252
|
+
|
|
253
|
+
const renderPanel = () => {
|
|
254
|
+
const f = vm.fields[safeActiveIdx];
|
|
255
|
+
if (f && typeof f === "object") {
|
|
256
|
+
const fo = f as Record<string, unknown>;
|
|
257
|
+
const id = typeof fo.id === "string" ? fo.id : "";
|
|
258
|
+
const label = typeof fo.label === "string" ? fo.label : "";
|
|
259
|
+
const kind = typeof fo.kind === "string" ? fo.kind : "";
|
|
260
|
+
const allowMultiple = Boolean(fo.allow_multiple);
|
|
261
|
+
const options = Array.isArray(fo.options) ? (fo.options as ParsedChoice[]) : [];
|
|
262
|
+
const name = (label || id || `Field ${safeActiveIdx + 1}`).trim();
|
|
263
|
+
const fieldKey = name.trim();
|
|
264
|
+
|
|
265
|
+
const currentLine = fieldKey ? findFieldLine(selected, fieldKey) : null;
|
|
266
|
+
const currentValues =
|
|
267
|
+
allowMultiple && currentLine ? parseMultiValues(currentLine, fieldKey) : [];
|
|
268
|
+
const currentSet = new Set(currentValues);
|
|
269
|
+
|
|
270
|
+
const selectSingle = (value: string) => {
|
|
271
|
+
const v = (value || "").trim();
|
|
272
|
+
if (!v) return;
|
|
273
|
+
onPasteChoice?.(`${fieldKey}: ${v}`, "upsert");
|
|
274
|
+
advance();
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const toggleMulti = (value: string) => {
|
|
278
|
+
const v = (value || "").trim();
|
|
279
|
+
if (!v) return;
|
|
280
|
+
const next = toggleValue(currentValues, v).sort();
|
|
281
|
+
const line = next.length > 0 ? `${fieldKey}: ${next.join(", ")}` : `${fieldKey}:`;
|
|
282
|
+
onPasteChoice?.(line, "upsert");
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const upsertOther = () => {
|
|
286
|
+
if (!fieldKey) return;
|
|
287
|
+
onPasteChoice?.(`${fieldKey}:`, "upsert");
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<div
|
|
292
|
+
key={`panel-${safeActiveIdx}`}
|
|
293
|
+
className={cn(
|
|
294
|
+
"w-full rounded-xl border bg-background/60 px-3 py-2 text-left",
|
|
295
|
+
"hover:bg-background/80 hover:shadow-sm transition",
|
|
296
|
+
"disabled:opacity-60"
|
|
297
|
+
)}
|
|
298
|
+
>
|
|
94
299
|
<div className="flex items-center gap-2">
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
</
|
|
98
|
-
{
|
|
99
|
-
<span className="text-[11px] text-muted-foreground">
|
|
300
|
+
<span className="min-w-0 flex-1 truncate text-[13px]" title={name}>
|
|
301
|
+
{name}
|
|
302
|
+
</span>
|
|
303
|
+
{kind && (
|
|
304
|
+
<span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">
|
|
305
|
+
{kind}
|
|
306
|
+
</span>
|
|
307
|
+
)}
|
|
308
|
+
{allowMultiple && (
|
|
309
|
+
<span className="shrink-0 text-[11px] text-muted-foreground">Multiple allowed</span>
|
|
100
310
|
)}
|
|
101
311
|
</div>
|
|
102
|
-
<span className="text-[11px] text-muted-foreground">Click a button to fill the input</span>
|
|
103
|
-
</div>
|
|
104
|
-
<div className="grid grid-cols-1 gap-2">
|
|
105
|
-
{vm.options.length > 0 ? (
|
|
106
|
-
vm.options.map((opt, idx) => {
|
|
107
|
-
if (opt && typeof opt === "object") {
|
|
108
|
-
const o = opt as Record<string, unknown>;
|
|
109
|
-
const id = typeof o.id === "string" ? o.id : "";
|
|
110
|
-
const label = typeof o.label === "string" ? o.label : "";
|
|
111
|
-
const text = id && label ? `${id}: ${label}` : id || label || "<empty>";
|
|
112
|
-
const pasteText = id || label || "";
|
|
113
|
-
const cleaned = pasteText.trim();
|
|
114
|
-
const isSelected = vm.allowMultiple && !!cleaned && selected.has(cleaned);
|
|
115
312
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
313
|
+
<div className="mt-2 grid grid-cols-1 gap-2">
|
|
314
|
+
{options.length > 0 ? (
|
|
315
|
+
<div className="grid grid-cols-1 gap-2">
|
|
316
|
+
{options.map((opt, oidx) => {
|
|
317
|
+
const value = formatChoiceLabel(opt);
|
|
318
|
+
const title = value ? `Click to select: ${fieldKey}: ${value}` : undefined;
|
|
319
|
+
const isSelected = allowMultiple && !!value && currentSet.has(value);
|
|
320
|
+
return (
|
|
321
|
+
<Button
|
|
322
|
+
key={`field-${safeActiveIdx}-opt-${oidx}`}
|
|
323
|
+
type="button"
|
|
324
|
+
variant={isSelected ? "secondary" : "outline"}
|
|
325
|
+
size="sm"
|
|
326
|
+
className="h-auto min-h-9 justify-start rounded-xl px-3 py-2 text-left text-xs"
|
|
327
|
+
disabled={disabled || !onPasteChoice || !fieldKey || !value}
|
|
328
|
+
onClick={() =>
|
|
329
|
+
allowMultiple ? toggleMulti(value) : selectSingle(value)
|
|
330
|
+
}
|
|
331
|
+
title={title}
|
|
332
|
+
>
|
|
333
|
+
{value || "<empty>"}
|
|
334
|
+
</Button>
|
|
335
|
+
);
|
|
336
|
+
})}
|
|
337
|
+
</div>
|
|
338
|
+
) : (
|
|
339
|
+
<div className="text-muted-foreground">No options</div>
|
|
340
|
+
)}
|
|
341
|
+
|
|
342
|
+
<div className="flex items-center gap-2">
|
|
343
|
+
<Button
|
|
344
|
+
type="button"
|
|
345
|
+
variant="secondary"
|
|
346
|
+
size="sm"
|
|
347
|
+
className="h-9 flex-1 justify-start rounded-xl px-3 text-left text-xs"
|
|
348
|
+
disabled={disabled || !onPasteChoice || !fieldKey}
|
|
349
|
+
onClick={upsertOther}
|
|
350
|
+
title={fieldKey ? `Click to enter custom value: ${fieldKey}:` : undefined}
|
|
351
|
+
>
|
|
352
|
+
Other
|
|
353
|
+
</Button>
|
|
354
|
+
<Button
|
|
355
|
+
type="button"
|
|
356
|
+
variant="ghost"
|
|
357
|
+
size="sm"
|
|
358
|
+
className="h-9 rounded-xl px-3 text-xs"
|
|
359
|
+
disabled={disabled || safeActiveIdx >= vm.fields.length - 1}
|
|
360
|
+
onClick={() => setActiveFieldIdx((prev) => clampFieldIdx(prev + 1))}
|
|
361
|
+
>
|
|
362
|
+
Next
|
|
363
|
+
</Button>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
164
366
|
</div>
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
367
|
+
);
|
|
368
|
+
}
|
|
168
369
|
|
|
169
|
-
|
|
370
|
+
const asText = String(f || "").trim();
|
|
371
|
+
const fieldKey = asText || `Field ${safeActiveIdx + 1}`;
|
|
170
372
|
return (
|
|
171
|
-
<div
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
</div>
|
|
178
|
-
{vm.text && <div className="mb-2 whitespace-pre-wrap leading-normal">{vm.text}</div>}
|
|
179
|
-
<div className="flex flex-col gap-2">
|
|
180
|
-
<Button
|
|
181
|
-
type="button"
|
|
182
|
-
variant="default"
|
|
183
|
-
size="sm"
|
|
184
|
-
className="h-9 w-full rounded-xl px-3 text-xs"
|
|
185
|
-
disabled={disabled || !onPasteChoice}
|
|
186
|
-
onClick={() => onPasteChoice?.(vm.confirmLabel)}
|
|
187
|
-
title={`Click to paste: ${vm.confirmLabel}`}
|
|
188
|
-
>
|
|
189
|
-
{vm.confirmLabel}
|
|
190
|
-
</Button>
|
|
191
|
-
<Button
|
|
192
|
-
type="button"
|
|
193
|
-
variant="outline"
|
|
194
|
-
size="sm"
|
|
195
|
-
className="h-9 w-full rounded-xl px-3 text-xs"
|
|
196
|
-
disabled={disabled || !onPasteChoice}
|
|
197
|
-
onClick={() => onPasteChoice?.(vm.cancelLabel)}
|
|
198
|
-
title={`Click to paste: ${vm.cancelLabel}`}
|
|
199
|
-
>
|
|
200
|
-
{vm.cancelLabel}
|
|
201
|
-
</Button>
|
|
373
|
+
<div
|
|
374
|
+
key={`panel-${safeActiveIdx}`}
|
|
375
|
+
className="w-full rounded-xl border bg-background/60 px-3 py-2 text-left text-[13px]"
|
|
376
|
+
>
|
|
377
|
+
<div className="mb-2 truncate" title={fieldKey}>
|
|
378
|
+
{fieldKey}
|
|
202
379
|
</div>
|
|
380
|
+
<Button
|
|
381
|
+
type="button"
|
|
382
|
+
variant="secondary"
|
|
383
|
+
size="sm"
|
|
384
|
+
className="h-9 justify-start rounded-xl px-3 text-left text-xs"
|
|
385
|
+
disabled={disabled || !onPasteChoice || !fieldKey}
|
|
386
|
+
onClick={() => onPasteChoice?.(`${fieldKey}:`, "upsert")}
|
|
387
|
+
>
|
|
388
|
+
Other
|
|
389
|
+
</Button>
|
|
203
390
|
</div>
|
|
204
391
|
);
|
|
205
|
-
}
|
|
392
|
+
};
|
|
206
393
|
|
|
207
|
-
// vm.kind === "form"
|
|
208
394
|
return (
|
|
209
395
|
<div className="mt-2 rounded-xl border bg-linear-to-b from-background to-muted/20 p-2.5 text-xs shadow-sm">
|
|
210
396
|
<div className="mb-2 flex items-center justify-between gap-2">
|
|
@@ -215,57 +401,36 @@ export function PayloadCard({
|
|
|
215
401
|
</div>
|
|
216
402
|
<div className="space-y-2">
|
|
217
403
|
{vm.fields.length > 0 ? (
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
"hover:bg-background/80 hover:shadow-sm transition",
|
|
234
|
-
"disabled:opacity-60"
|
|
235
|
-
)}
|
|
236
|
-
disabled={disabled || !onPasteChoice || !pasteText}
|
|
237
|
-
onClick={() => onPasteChoice?.(pasteText)}
|
|
238
|
-
title={pasteText ? `Click to paste: ${pasteText}` : undefined}
|
|
239
|
-
>
|
|
240
|
-
<div className="flex items-center gap-2">
|
|
241
|
-
<span className="min-w-0 flex-1 truncate text-[13px]">{name}</span>
|
|
242
|
-
{kind && (
|
|
243
|
-
<span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">
|
|
244
|
-
{kind}
|
|
245
|
-
</span>
|
|
404
|
+
<div className="space-y-2">
|
|
405
|
+
<div className="flex items-center gap-2 overflow-x-auto rounded-xl border bg-background/60 p-1">
|
|
406
|
+
{vm.fields.map((f, idx) => {
|
|
407
|
+
const name = fieldDisplayName(f, idx);
|
|
408
|
+
const active = idx === safeActiveIdx;
|
|
409
|
+
return (
|
|
410
|
+
<Button
|
|
411
|
+
key={`tab-${idx}`}
|
|
412
|
+
type="button"
|
|
413
|
+
variant={active ? "secondary" : "ghost"}
|
|
414
|
+
size="sm"
|
|
415
|
+
className={cn(
|
|
416
|
+
"h-8 shrink-0 rounded-lg px-2 text-xs",
|
|
417
|
+
"max-w-55",
|
|
418
|
+
active && "cursor-default"
|
|
246
419
|
)}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
disabled={disabled || !onPasteChoice}
|
|
262
|
-
onClick={() => onPasteChoice?.(asText)}
|
|
263
|
-
title={`Click to paste: ${asText}`}
|
|
264
|
-
>
|
|
265
|
-
{asText}
|
|
266
|
-
</button>
|
|
267
|
-
);
|
|
268
|
-
})
|
|
420
|
+
disabled={disabled || active}
|
|
421
|
+
onClick={() => setActiveFieldIdx(idx)}
|
|
422
|
+
title={name}
|
|
423
|
+
>
|
|
424
|
+
<span className="min-w-0 flex-1 truncate">
|
|
425
|
+
{idx + 1}. {name}
|
|
426
|
+
</span>
|
|
427
|
+
</Button>
|
|
428
|
+
);
|
|
429
|
+
})}
|
|
430
|
+
</div>
|
|
431
|
+
|
|
432
|
+
{renderPanel()}
|
|
433
|
+
</div>
|
|
269
434
|
) : (
|
|
270
435
|
<div className="text-muted-foreground">No fields</div>
|
|
271
436
|
)}
|
|
@@ -273,3 +438,59 @@ export function PayloadCard({
|
|
|
273
438
|
</div>
|
|
274
439
|
);
|
|
275
440
|
}
|
|
441
|
+
|
|
442
|
+
export function PayloadCard({
|
|
443
|
+
raw,
|
|
444
|
+
disabled,
|
|
445
|
+
onPasteChoice,
|
|
446
|
+
selectedLines,
|
|
447
|
+
}: {
|
|
448
|
+
raw?: string | null;
|
|
449
|
+
disabled?: boolean;
|
|
450
|
+
onPasteChoice?: OnPasteChoice;
|
|
451
|
+
selectedLines?: Set<string>;
|
|
452
|
+
}) {
|
|
453
|
+
const vm = useMemo<ParsedViewModel | null>(() => parsePayload(raw), [raw]);
|
|
454
|
+
|
|
455
|
+
if (!vm) return null;
|
|
456
|
+
|
|
457
|
+
if (vm.kind === "raw") {
|
|
458
|
+
return (
|
|
459
|
+
<pre className="mt-2 max-w-full overflow-auto rounded-lg border bg-muted/30 p-2 text-xs text-muted-foreground">
|
|
460
|
+
{vm.raw}
|
|
461
|
+
</pre>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (vm.kind === "unknown") {
|
|
466
|
+
return (
|
|
467
|
+
<pre className="mt-2 max-w-full overflow-auto rounded-lg border bg-muted/30 p-2 text-xs text-muted-foreground">
|
|
468
|
+
{vm.pretty}
|
|
469
|
+
</pre>
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (vm.kind === "choice") {
|
|
474
|
+
return (
|
|
475
|
+
<PayloadChoiceView
|
|
476
|
+
vm={vm}
|
|
477
|
+
disabled={disabled}
|
|
478
|
+
onPasteChoice={onPasteChoice}
|
|
479
|
+
selectedLines={selectedLines}
|
|
480
|
+
/>
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (vm.kind === "confirm") {
|
|
485
|
+
return <PayloadConfirmView vm={vm} disabled={disabled} onPasteChoice={onPasteChoice} />;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return (
|
|
489
|
+
<PayloadFormView
|
|
490
|
+
vm={vm}
|
|
491
|
+
disabled={disabled}
|
|
492
|
+
onPasteChoice={onPasteChoice}
|
|
493
|
+
selectedLines={selectedLines}
|
|
494
|
+
/>
|
|
495
|
+
);
|
|
496
|
+
}
|