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 CHANGED
@@ -1,6 +1,8 @@
1
1
  # cue-console
2
2
 
3
- Cue Hub console (Next.js UI).
3
+ | Mobile | Desktop |
4
+ | --- | --- |
5
+ | ![Mobile screenshot](../iphone.png) | ![Desktop screenshot](../desktop.png) |
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: Start `cuemcp`
25
+ ### Step 1: Install `cue-console`
24
26
 
25
- Add and run the MCP server in your agent/runtime (see [`cue-mcp`](https://github.com/nmhjklnm/cue-mcp) for client-specific MCP configuration).
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/dev/types/routes.d.ts";
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.2",
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",
@@ -14,7 +14,7 @@ const geistMono = Recursive({
14
14
  });
15
15
 
16
16
  export const metadata: Metadata = {
17
- title: "Cue Hub",
17
+ title: "cue-console",
18
18
  description: "AI agent group chat console",
19
19
  };
20
20
 
@@ -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 = (text: string, mode: "replace" | "append" = "replace") => {
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: respond to all pending requests for this agent
815
- const pendingIds = pendingRequests.filter(isPending).map((r) => r.request_id);
816
- if (pendingIds.length > 0) {
817
- const result = await batchRespond(pendingIds, input, currentImages, draftMentions);
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
- <h1 className="text-lg font-semibold">Cue Hub</h1>
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
- export function PayloadCard({
9
- raw,
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
- raw?: string | null;
125
+ vm: Extract<ParsedViewModel, { kind: "choice" }>;
15
126
  disabled?: boolean;
16
- onPasteChoice?: (text: string, mode?: "replace" | "append") => void;
127
+ onPasteChoice?: OnPasteChoice;
17
128
  selectedLines?: Set<string>;
18
129
  }) {
19
- type ParsedChoice = { id?: string; label?: string } | string;
20
- type ParsedField = { id?: string; label?: string; kind?: string } | string;
21
- type ParsedViewModel =
22
- | { kind: "raw"; raw: string }
23
- | { kind: "unknown"; pretty: string }
24
- | { kind: "choice"; allowMultiple: boolean; options: ParsedChoice[] }
25
- | { kind: "confirm"; text: string; confirmLabel: string; cancelLabel: string }
26
- | { kind: "form"; fields: ParsedField[] };
27
-
28
- const vm = useMemo<ParsedViewModel | null>(() => {
29
- if (!raw) return null;
30
- try {
31
- const parsed = JSON.parse(raw) as unknown;
32
- if (!parsed || typeof parsed !== "object") {
33
- return { kind: "raw", raw: String(raw) };
34
- }
35
-
36
- const obj = parsed as Record<string, unknown>;
37
- const type = typeof obj.type === "string" ? obj.type : "unknown";
38
-
39
- if (type === "choice") {
40
- return {
41
- kind: "choice",
42
- allowMultiple: Boolean(obj.allow_multiple),
43
- options: Array.isArray(obj.options) ? (obj.options as ParsedChoice[]) : [],
44
- };
45
- }
46
-
47
- if (type === "confirm") {
48
- return {
49
- kind: "confirm",
50
- text: typeof obj.text === "string" ? obj.text : "",
51
- confirmLabel:
52
- typeof obj.confirm_label === "string" ? obj.confirm_label : "Confirm",
53
- cancelLabel:
54
- typeof obj.cancel_label === "string" ? obj.cancel_label : "Cancel",
55
- };
56
- }
57
-
58
- if (type === "form") {
59
- return {
60
- kind: "form",
61
- fields: Array.isArray(obj.fields) ? (obj.fields as ParsedField[]) : [],
62
- };
63
- }
64
-
65
- return { kind: "unknown", pretty: JSON.stringify(parsed, null, 2) };
66
- } catch {
67
- return { kind: "raw", raw };
68
- }
69
- }, [raw]);
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
- if (!vm) return null;
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
- if (vm.kind === "raw") {
74
- return (
75
- <pre className="mt-2 max-w-full overflow-auto rounded-lg border bg-muted/30 p-2 text-xs text-muted-foreground">
76
- {vm.raw}
77
- </pre>
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
- if (vm.kind === "unknown") {
82
- return (
83
- <pre className="mt-2 max-w-full overflow-auto rounded-lg border bg-muted/30 p-2 text-xs text-muted-foreground">
84
- {vm.pretty}
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
- if (vm.kind === "choice") {
90
- const selected = selectedLines ?? new Set<string>();
91
- return (
92
- <div className="mt-2 rounded-xl border bg-linear-to-b from-background to-muted/20 p-2.5 text-xs shadow-sm">
93
- <div className="mb-2 flex items-center justify-between gap-2">
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
- <Badge variant="secondary" className="text-[11px]">
96
- Choice
97
- </Badge>
98
- {vm.allowMultiple && (
99
- <span className="text-[11px] text-muted-foreground">Multiple allowed</span>
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
- return (
117
- <Button
118
- key={`${id || "opt"}-${idx}`}
119
- type="button"
120
- variant={isSelected ? "secondary" : "outline"}
121
- size="sm"
122
- className={cn(
123
- "h-auto min-h-9 justify-start gap-2 px-3 py-2 text-left text-xs",
124
- "rounded-xl",
125
- isSelected && "cursor-not-allowed opacity-80"
126
- )}
127
- disabled={disabled || !onPasteChoice || !pasteText || isSelected}
128
- onClick={() =>
129
- onPasteChoice?.(pasteText, vm.allowMultiple ? "append" : "replace")
130
- }
131
- title={pasteText ? `Click to paste: ${pasteText}` : undefined}
132
- >
133
- {id && (
134
- <span className="inline-flex h-5 items-center rounded-md bg-muted px-1.5 font-mono text-[11px] text-muted-foreground">
135
- {id}
136
- </span>
137
- )}
138
- <span className="min-w-0 flex-1 truncate">{id && label ? label : text}</span>
139
- </Button>
140
- );
141
- }
142
-
143
- const asText = String(opt);
144
- return (
145
- <Button
146
- key={`opt-${idx}`}
147
- type="button"
148
- variant="outline"
149
- size="sm"
150
- className="h-auto min-h-9 justify-start rounded-xl px-3 py-2 text-left text-xs"
151
- disabled={disabled || !onPasteChoice}
152
- onClick={() =>
153
- onPasteChoice?.(asText, vm.allowMultiple ? "append" : "replace")
154
- }
155
- title={`Click to paste: ${asText}`}
156
- >
157
- {asText}
158
- </Button>
159
- );
160
- })
161
- ) : (
162
- <div className="text-muted-foreground">No options</div>
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
- </div>
166
- );
167
- }
367
+ );
368
+ }
168
369
 
169
- if (vm.kind === "confirm") {
370
+ const asText = String(f || "").trim();
371
+ const fieldKey = asText || `Field ${safeActiveIdx + 1}`;
170
372
  return (
171
- <div className="mt-2 rounded-xl border bg-linear-to-b from-background to-muted/20 p-2.5 text-xs shadow-sm">
172
- <div className="mb-2 flex items-center justify-between gap-2">
173
- <Badge variant="secondary" className="text-[11px]">
174
- Confirm
175
- </Badge>
176
- <span className="text-[11px] text-muted-foreground">Click a button to fill the input</span>
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
- vm.fields.map((f, idx) => {
219
- if (f && typeof f === "object") {
220
- const fo = f as Record<string, unknown>;
221
- const id = typeof fo.id === "string" ? fo.id : "";
222
- const label = typeof fo.label === "string" ? fo.label : "";
223
- const kind = typeof fo.kind === "string" ? fo.kind : "";
224
- const name = label || id || "Field";
225
- const pasteText = id || label || "";
226
-
227
- return (
228
- <button
229
- key={`${id || "field"}-${idx}`}
230
- type="button"
231
- className={cn(
232
- "w-full rounded-xl border bg-background/60 px-3 py-2 text-left",
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
- </div>
248
- {id && label && (
249
- <div className="mt-1 text-[11px] text-muted-foreground">{id}</div>
250
- )}
251
- </button>
252
- );
253
- }
254
-
255
- const asText = String(f);
256
- return (
257
- <button
258
- key={`field-${idx}`}
259
- type="button"
260
- className="w-full rounded-xl border bg-background/60 px-3 py-2 text-left text-[13px] hover:bg-background/80 hover:shadow-sm transition disabled:opacity-60"
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
+ }