@workerdeck/ui 0.15.0 → 0.16.0
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 +53 -0
- package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-B9CHoq8x.d.mts} +158 -21
- package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DII9MmQ8.mjs} +4254 -1661
- package/build/SessionPanel-DII9MmQ8.mjs.map +1 -0
- package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
- package/build/format.d.mts +39 -4
- package/build/format.mjs +2 -118
- package/build/index.d.mts +442 -45
- package/build/index.mjs +105 -2
- package/build/index.mjs.map +1 -1
- package/build/status-Ydzi7n6j.mjs +143 -0
- package/build/status-Ydzi7n6j.mjs.map +1 -0
- package/build/workspace.d.mts +9 -1
- package/build/workspace.mjs +108 -5
- package/build/workspace.mjs.map +1 -1
- package/package.json +14 -7
- package/src/components/agent/Composer.tsx +189 -89
- package/src/components/agent/Conversation.tsx +12 -12
- package/src/components/agent/FileCard.tsx +0 -26
- package/src/components/agent/FileTree.tsx +9 -8
- package/src/components/agent/Loader.tsx +22 -72
- package/src/components/agent/Message.tsx +11 -46
- package/src/components/agent/PermissionPrompt.tsx +0 -92
- package/src/components/agent/QuestionPrompt.tsx +0 -122
- package/src/components/agent/Reasoning.tsx +5 -19
- package/src/components/agent/Response.tsx +1 -132
- package/src/components/agent/SessionPanel.tsx +224 -28
- package/src/components/agent/SessionWorkspace.tsx +29 -0
- package/src/components/agent/StatusBar.tsx +20 -4
- package/src/components/agent/ToolCallCard.tsx +17 -111
- package/src/components/agent/Transcript.tsx +710 -203
- package/src/components/agent/UsageDialog.tsx +20 -106
- package/src/components/agent/UsageMeters.tsx +133 -0
- package/src/components/agent/pulse.tsx +3 -2
- package/src/components/agent/transcript-rows.ts +82 -0
- package/src/components/agent/transcript-variant.tsx +29 -51
- package/src/components/agent/use-height-epoch.ts +60 -0
- package/src/components/agent/use-path-links.ts +147 -0
- package/src/components/agent/use-transcript-jumps.ts +190 -0
- package/src/components/prompt-area/cursor-helpers.ts +65 -0
- package/src/components/prompt-area/use-prompt-area.ts +16 -10
- package/src/components/terminal/PermissionPrompt.tsx +119 -0
- package/src/components/terminal/QuestionPrompt.tsx +322 -0
- package/src/components/terminal/StatusLine.tsx +159 -0
- package/src/components/terminal/TerminalTranscript.tsx +147 -0
- package/src/components/terminal/affordances.tsx +118 -0
- package/src/components/terminal/diff.tsx +130 -0
- package/src/components/terminal/height.ts +727 -0
- package/src/components/terminal/items.tsx +449 -0
- package/src/components/terminal/markdown.tsx +191 -0
- package/src/components/terminal/press.tsx +120 -0
- package/src/components/terminal/prompt.tsx +343 -0
- package/src/components/terminal/result-preview.ts +72 -0
- package/src/components/terminal/row.tsx +132 -0
- package/src/components/terminal/scrubber.tsx +663 -0
- package/src/components/terminal/surface.tsx +80 -0
- package/src/components/terminal/tool-run.ts +91 -0
- package/src/index.ts +34 -0
- package/src/lib/status.ts +59 -3
- package/src/lib/tool-icon.ts +14 -0
- package/src/styles/terminal.css +1011 -0
- package/src/styles/theme.css +41 -0
- package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
- package/build/format.mjs.map +0 -1
- package/src/components/agent/line-prompt.tsx +0 -249
package/build/format.mjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"format.mjs","names":[],"sources":["../src/lib/status.ts"],"sourcesContent":["import type { ContextUsage, ModelOption, RateLimitInfo, SessionStatus } from '@workerdeck/protocol'\n\n/**\n * How a session's live readings become a status line — the pure half, so every\n * host spells \"Needs approval\", \"80% is a warning\" and \"which window is the\n * binding one\" the same way.\n *\n * Structurally typed against `SessionVitals` rather than importing it: this file\n * ships from the React-free `@workerdeck/ui/format` entry, and a host drawing\n * the readings outside React (the VS Code extension host in the window status\n * bar) must not pull a component graph in to do it. A real `SessionVitals`\n * satisfies every shape here.\n */\nexport type StatusSeverity = 'none' | 'warning' | 'error'\n\n/** What a status slot shows, before any host's icon vocabulary gets involved.\n * `icon` is a VS Code codicon name — the one host-shaped thing left, because the\n * alternative is a second mapping table in the only consumer. */\nexport type StatusPresentation = {\n icon: string\n label: string\n severity: StatusSeverity\n}\n\nexport type StatusReadings = {\n status: SessionStatus\n /** `@workerdeck/react`'s `ConnectionState`, structurally — the link state wins\n * the slot, so it has to be part of the reading. */\n connection?: 'live' | 'reconnecting' | 'offline'\n}\n\nconst STATUS_META: Record<SessionStatus, StatusPresentation> = {\n starting: { icon: 'loading~spin', label: 'Starting', severity: 'none' },\n running: { icon: 'loading~spin', label: 'Running', severity: 'none' },\n awaiting_approval: { icon: 'warning', label: 'Needs approval', severity: 'warning' },\n idle: { icon: 'check', label: 'Idle', severity: 'none' },\n parked: { icon: 'debug-pause', label: 'Parked', severity: 'none' },\n failed: { icon: 'error', label: 'Failed', severity: 'error' },\n closed: { icon: 'circle-slash', label: 'Closed', severity: 'none' },\n}\n\n/**\n * The status slot, connection first. A session status held over a dead socket is\n * the last thing we heard, not the current state — so a lost link takes the slot\n * rather than letting \"Running\" imply a turn is still streaming.\n */\nexport function statusPresentation(vitals: StatusReadings | undefined): StatusPresentation {\n if (!vitals) return { icon: 'hubot', label: 'Connecting…', severity: 'none' }\n if (vitals.connection === 'offline') {\n return { icon: 'debug-disconnect', label: 'Offline', severity: 'error' }\n }\n if (vitals.connection === 'reconnecting') {\n return { icon: 'sync~spin', label: 'Reconnecting…', severity: 'warning' }\n }\n return STATUS_META[vitals.status] ?? { icon: 'hubot', label: vitals.status, severity: 'none' }\n}\n\n/** 0–100 → the colour a meter wears. One pair of thresholds for every surface. */\nexport function meterSeverity(pct: number | undefined): StatusSeverity {\n if (pct === undefined) return 'none'\n if (pct >= 95) return 'error'\n if (pct >= 80) return 'warning'\n return 'none'\n}\n\n/** The rate-limit window that gets the one visible slot: whichever is fullest,\n * since the binding constraint is the one worth glancing at. */\nexport function tightestWindow(\n rateLimits: Record<string, RateLimitInfo> | undefined,\n): { key: string; info: RateLimitInfo } | undefined {\n const entries = Object.entries(rateLimits ?? {})\n if (entries.length === 0) return undefined\n let best: { key: string; info: RateLimitInfo } | undefined\n for (const [key, info] of entries) {\n // A rejected window outranks any utilization: it is the one actually blocking.\n const rank = info.status === 'rejected' ? Number.POSITIVE_INFINITY : (info.utilization ?? -1)\n const bestRank =\n best === undefined\n ? Number.NEGATIVE_INFINITY\n : best.info.status === 'rejected'\n ? Number.POSITIVE_INFINITY\n : (best.info.utilization ?? -1)\n if (rank > bestRank) best = { key, info }\n }\n return best\n}\n\n/** A rate-limit window's key, named for a human. */\nexport function windowLabel(key: string): string {\n if (key === 'five_hour') return 'Session'\n if (key === 'seven_day') return 'Weekly'\n return key.replaceAll('_', ' ')\n}\n\nexport type ModelReadings = { model?: string; models: readonly ModelOption[] }\n\n/**\n * The catalog row a session is actually running, or `undefined` for a model the\n * list doesn't name. Matched leniently: a session reports the *resolved* id\n * (`claude-sonnet-5`) where the row may be keyed on the alias (`sonnet`), and\n * either can carry a `[1m]` context-window suffix.\n */\nexport function currentModel(vitals: ModelReadings | undefined): ModelOption | undefined {\n const id = vitals?.model\n if (!id) return undefined\n const bare = (value: string) => value.replace(/\\[.*\\]$/, '')\n const wanted = bare(id)\n return vitals.models.find(\n (m) => bare(m.value) === wanted || (m.resolvedModel && bare(m.resolvedModel) === wanted),\n )\n}\n\n/** A session's model, named the way the picker names it. Falls back to the raw\n * id, and to \"Default\" while the session is on the CLI's own pick. */\nexport function modelLabel(vitals: ModelReadings | undefined): string {\n if (!vitals?.model) return 'Default'\n return currentModel(vitals)?.displayName ?? vitals.model\n}\n\n/** Context percentage as its meter severity — the reading and the colour come\n * from one place so a panel and a status bar never disagree. */\nexport function contextSeverity(usage: ContextUsage | undefined): StatusSeverity {\n return meterSeverity(usage?.percentage)\n}\n"],"mappings":";;AA+BA,MAAM,cAAyD;CAC7D,UAAU;EAAE,MAAM;EAAgB,OAAO;EAAY,UAAU;EAAQ;CACvE,SAAS;EAAE,MAAM;EAAgB,OAAO;EAAW,UAAU;EAAQ;CACrE,mBAAmB;EAAE,MAAM;EAAW,OAAO;EAAkB,UAAU;EAAW;CACpF,MAAM;EAAE,MAAM;EAAS,OAAO;EAAQ,UAAU;EAAQ;CACxD,QAAQ;EAAE,MAAM;EAAe,OAAO;EAAU,UAAU;EAAQ;CAClE,QAAQ;EAAE,MAAM;EAAS,OAAO;EAAU,UAAU;EAAS;CAC7D,QAAQ;EAAE,MAAM;EAAgB,OAAO;EAAU,UAAU;EAAQ;CACpE;;;;;;AAOD,SAAgB,mBAAmB,QAAwD;AACzF,KAAI,CAAC,OAAQ,QAAO;EAAE,MAAM;EAAS,OAAO;EAAe,UAAU;EAAQ;AAC7E,KAAI,OAAO,eAAe,UACxB,QAAO;EAAE,MAAM;EAAoB,OAAO;EAAW,UAAU;EAAS;AAE1E,KAAI,OAAO,eAAe,eACxB,QAAO;EAAE,MAAM;EAAa,OAAO;EAAiB,UAAU;EAAW;AAE3E,QAAO,YAAY,OAAO,WAAW;EAAE,MAAM;EAAS,OAAO,OAAO;EAAQ,UAAU;EAAQ;;;AAIhG,SAAgB,cAAc,KAAyC;AACrE,KAAI,QAAQ,KAAA,EAAW,QAAO;AAC9B,KAAI,OAAO,GAAI,QAAO;AACtB,KAAI,OAAO,GAAI,QAAO;AACtB,QAAO;;;;AAKT,SAAgB,eACd,YACkD;CAClD,MAAM,UAAU,OAAO,QAAQ,cAAc,EAAE,CAAC;AAChD,KAAI,QAAQ,WAAW,EAAG,QAAO,KAAA;CACjC,IAAI;AACJ,MAAK,MAAM,CAAC,KAAK,SAAS,QASxB,MAPa,KAAK,WAAW,aAAa,OAAO,oBAAqB,KAAK,eAAe,OAExF,SAAS,KAAA,IACL,OAAO,oBACP,KAAK,KAAK,WAAW,aACnB,OAAO,oBACN,KAAK,KAAK,eAAe,IACb,QAAO;EAAE;EAAK;EAAM;AAE3C,QAAO;;;AAIT,SAAgB,YAAY,KAAqB;AAC/C,KAAI,QAAQ,YAAa,QAAO;AAChC,KAAI,QAAQ,YAAa,QAAO;AAChC,QAAO,IAAI,WAAW,KAAK,IAAI;;;;;;;;AAWjC,SAAgB,aAAa,QAA4D;CACvF,MAAM,KAAK,QAAQ;AACnB,KAAI,CAAC,GAAI,QAAO,KAAA;CAChB,MAAM,QAAQ,UAAkB,MAAM,QAAQ,WAAW,GAAG;CAC5D,MAAM,SAAS,KAAK,GAAG;AACvB,QAAO,OAAO,OAAO,MAClB,MAAM,KAAK,EAAE,MAAM,KAAK,UAAW,EAAE,iBAAiB,KAAK,EAAE,cAAc,KAAK,OAClF;;;;AAKH,SAAgB,WAAW,QAA2C;AACpE,KAAI,CAAC,QAAQ,MAAO,QAAO;AAC3B,QAAO,aAAa,OAAO,EAAE,eAAe,OAAO;;;;AAKrD,SAAgB,gBAAgB,OAAiD;AAC/E,QAAO,cAAc,OAAO,WAAW"}
|
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, type ReactNode } from 'react'
|
|
2
|
-
import { cn } from '../../lib/utils.ts'
|
|
3
|
-
import { Response } from './Response.tsx'
|
|
4
|
-
import { LineGlyph } from './transcript-variant.tsx'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The parts a terminal-shaped prompt is built from.
|
|
8
|
-
*
|
|
9
|
-
* The `lines` transcript answers a question the `cards` one does not have to:
|
|
10
|
-
* an approval and a question are *interactive*, so "no boxes" has to be paid for
|
|
11
|
-
* by something else carrying the affordance. That something is the keyboard —
|
|
12
|
-
* a roving `❯` marker, numbered rows, and a hint line — which is what a terminal
|
|
13
|
-
* would do anyway, and what makes the prompt answerable without reaching for the
|
|
14
|
-
* mouse in a dock where the rows are two lines tall.
|
|
15
|
-
*
|
|
16
|
-
* Everything here indents to the same gutter as every other line item, so an
|
|
17
|
-
* approval reads as one more row in the run rather than a dialog dropped on top
|
|
18
|
-
* of it.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
/** The left inset that lines up body text with a `LineGlyph`'d row above it. */
|
|
22
|
-
export const LINE_INDENT = 'pl-[calc(0.875rem+0.5rem)]'
|
|
23
|
-
|
|
24
|
-
export type LineChoice = {
|
|
25
|
-
key: string
|
|
26
|
-
label: string
|
|
27
|
-
description?: string
|
|
28
|
-
/**
|
|
29
|
-
* Rendered under the row, outside the button — a preview, an input. The caller
|
|
30
|
-
* decides when it exists (focused, checked); a button may not contain one.
|
|
31
|
-
*/
|
|
32
|
-
detail?: ReactNode
|
|
33
|
-
/**
|
|
34
|
-
* Present → the row carries a selection state and draws it. `marker` says in
|
|
35
|
-
* which idiom: a checkbox for a multi-select, a radio for a one-of. Absent →
|
|
36
|
-
* the row is an action (allow, deny), which has no state to show.
|
|
37
|
-
*/
|
|
38
|
-
checked?: boolean
|
|
39
|
-
marker?: 'check' | 'radio'
|
|
40
|
-
danger?: boolean
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** The two-state glyph pairs, in the character forms a terminal would use. */
|
|
44
|
-
const MARKERS = {
|
|
45
|
-
check: ['[ ]', '[x]'],
|
|
46
|
-
radio: ['( )', '(•)'],
|
|
47
|
-
} as const
|
|
48
|
-
|
|
49
|
-
export interface LineOptionListProps {
|
|
50
|
-
options: LineChoice[]
|
|
51
|
-
/** Roving index: the one row that is tab-reachable and wears the marker. */
|
|
52
|
-
focused: number
|
|
53
|
-
onFocus: (index: number) => void
|
|
54
|
-
onChoose: (index: number) => void
|
|
55
|
-
/**
|
|
56
|
-
* Own the DOM focus, moving it with the roving index. False while something
|
|
57
|
-
* else inside the prompt holds it (a reason input, another question's list) —
|
|
58
|
-
* two lists both chasing `focused` would tear the caret back and forth.
|
|
59
|
-
*/
|
|
60
|
-
active?: boolean
|
|
61
|
-
label: string
|
|
62
|
-
className?: string
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* A keyboard-first list of choices as line items: `↑`/`↓` move, `1`–`9` pick
|
|
67
|
-
* directly, `Enter`/`Space` take the focused one (the button does that itself).
|
|
68
|
-
*/
|
|
69
|
-
export function LineOptionList({
|
|
70
|
-
options,
|
|
71
|
-
focused,
|
|
72
|
-
onFocus,
|
|
73
|
-
onChoose,
|
|
74
|
-
active = true,
|
|
75
|
-
label,
|
|
76
|
-
className,
|
|
77
|
-
}: LineOptionListProps) {
|
|
78
|
-
const refs = useRef<Array<HTMLButtonElement | null>>([])
|
|
79
|
-
|
|
80
|
-
useEffect(() => {
|
|
81
|
-
if (active) refs.current[focused]?.focus()
|
|
82
|
-
}, [active, focused])
|
|
83
|
-
|
|
84
|
-
const move = (delta: number) => {
|
|
85
|
-
if (options.length > 0) onFocus((focused + delta + options.length) % options.length)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return (
|
|
89
|
-
<div
|
|
90
|
-
role='group'
|
|
91
|
-
aria-label={label}
|
|
92
|
-
className={cn('flex flex-col', className)}
|
|
93
|
-
onKeyDown={(event) => {
|
|
94
|
-
if (event.key === 'ArrowDown') {
|
|
95
|
-
move(1)
|
|
96
|
-
event.preventDefault()
|
|
97
|
-
return
|
|
98
|
-
}
|
|
99
|
-
if (event.key === 'ArrowUp') {
|
|
100
|
-
move(-1)
|
|
101
|
-
event.preventDefault()
|
|
102
|
-
return
|
|
103
|
-
}
|
|
104
|
-
// Digits are the whole point of numbering the rows — but only up to the
|
|
105
|
-
// rows that exist, so `9` on a three-option prompt stays a no-op rather
|
|
106
|
-
// than a silent miss.
|
|
107
|
-
const digit = Number(event.key)
|
|
108
|
-
if (Number.isInteger(digit) && digit >= 1 && digit <= Math.min(options.length, 9)) {
|
|
109
|
-
onFocus(digit - 1)
|
|
110
|
-
onChoose(digit - 1)
|
|
111
|
-
event.preventDefault()
|
|
112
|
-
}
|
|
113
|
-
}}>
|
|
114
|
-
{options.map((option, index) => {
|
|
115
|
-
const isFocused = index === focused
|
|
116
|
-
return (
|
|
117
|
-
<div key={option.key} className='flex flex-col'>
|
|
118
|
-
<button
|
|
119
|
-
ref={(element) => {
|
|
120
|
-
refs.current[index] = element
|
|
121
|
-
}}
|
|
122
|
-
type='button'
|
|
123
|
-
tabIndex={isFocused ? 0 : -1}
|
|
124
|
-
aria-pressed={option.checked}
|
|
125
|
-
onFocus={() => onFocus(index)}
|
|
126
|
-
onClick={() => onChoose(index)}
|
|
127
|
-
className={cn(
|
|
128
|
-
'flex w-full items-baseline gap-2 text-left outline-none',
|
|
129
|
-
isFocused ? 'bg-surface-hover' : 'hover:bg-surface-hover/60',
|
|
130
|
-
)}>
|
|
131
|
-
<LineGlyph className={isFocused ? 'text-fg-1' : undefined}>
|
|
132
|
-
{isFocused ? '❯' : ' '}
|
|
133
|
-
</LineGlyph>
|
|
134
|
-
<span className='shrink-0 font-mono text-label leading-5 text-fg-4'>{index + 1}</span>
|
|
135
|
-
{option.checked !== undefined ? (
|
|
136
|
-
<span
|
|
137
|
-
className={cn(
|
|
138
|
-
'shrink-0 font-mono text-label leading-5',
|
|
139
|
-
option.checked ? 'text-accent' : 'text-fg-4',
|
|
140
|
-
)}>
|
|
141
|
-
{MARKERS[option.marker ?? 'check'][option.checked ? 1 : 0]}
|
|
142
|
-
</span>
|
|
143
|
-
) : null}
|
|
144
|
-
<span
|
|
145
|
-
className={cn(
|
|
146
|
-
'min-w-0 flex-1 text-body-sm leading-5',
|
|
147
|
-
option.danger ? 'text-danger' : 'text-fg-1',
|
|
148
|
-
)}>
|
|
149
|
-
{option.label}
|
|
150
|
-
{option.description ? (
|
|
151
|
-
<span className='text-fg-4'> — {option.description}</span>
|
|
152
|
-
) : null}
|
|
153
|
-
</span>
|
|
154
|
-
</button>
|
|
155
|
-
{option.detail ? <div className={LINE_INDENT}>{option.detail}</div> : null}
|
|
156
|
-
</div>
|
|
157
|
-
)
|
|
158
|
-
})}
|
|
159
|
-
</div>
|
|
160
|
-
)
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** The dim key legend under a prompt. Separated by `·`, like a status line. */
|
|
164
|
-
export function LineHint({ children }: { children: ReactNode }) {
|
|
165
|
-
return (
|
|
166
|
-
<div className={cn(LINE_INDENT, 'text-label leading-5 text-fg-4')}>{children}</div>
|
|
167
|
-
)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** A single-line text field in the terminal idiom: a caret glyph and a rule,
|
|
171
|
-
* no box. `Enter` commits, `Escape` backs out — the caller says what those mean. */
|
|
172
|
-
export function LineInput({
|
|
173
|
-
value,
|
|
174
|
-
onChange,
|
|
175
|
-
onSubmit,
|
|
176
|
-
onCancel,
|
|
177
|
-
placeholder,
|
|
178
|
-
}: {
|
|
179
|
-
value: string
|
|
180
|
-
onChange: (value: string) => void
|
|
181
|
-
onSubmit: () => void
|
|
182
|
-
onCancel: () => void
|
|
183
|
-
placeholder?: string
|
|
184
|
-
}) {
|
|
185
|
-
return (
|
|
186
|
-
<div className='flex items-baseline gap-2'>
|
|
187
|
-
<LineGlyph className='text-fg-3'>›</LineGlyph>
|
|
188
|
-
<input
|
|
189
|
-
autoFocus
|
|
190
|
-
value={value}
|
|
191
|
-
placeholder={placeholder}
|
|
192
|
-
onChange={(event) => onChange(event.target.value)}
|
|
193
|
-
onKeyDown={(event) => {
|
|
194
|
-
if (event.key === 'Enter') {
|
|
195
|
-
event.preventDefault()
|
|
196
|
-
onSubmit()
|
|
197
|
-
}
|
|
198
|
-
if (event.key === 'Escape') {
|
|
199
|
-
// The prompt's own Escape means "deny"/"dismiss"; inside the field it
|
|
200
|
-
// only closes the field, so it must not travel further.
|
|
201
|
-
event.stopPropagation()
|
|
202
|
-
onCancel()
|
|
203
|
-
}
|
|
204
|
-
}}
|
|
205
|
-
className='min-w-0 flex-1 border-b border-border bg-transparent text-body-sm leading-5 text-fg-1 outline-none placeholder:text-fg-4 focus:border-accent'
|
|
206
|
-
/>
|
|
207
|
-
</div>
|
|
208
|
-
)
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* A payload in the terminal idiom: a dim label line over a **highlighted** band.
|
|
213
|
-
*
|
|
214
|
-
* Highlighting goes through `Response` — the markdown renderer already carries
|
|
215
|
-
* shiki, so wrapping the text in a fence costs no new dependency, no second
|
|
216
|
-
* highlighter and no second theme to keep in sync, and it inherits the terminal
|
|
217
|
-
* flattening plus the hover copy/download the renderer draws for every code
|
|
218
|
-
* block. Text with no language still goes through it, as an unlabelled fence:
|
|
219
|
-
* same band, same grid, no grammar guessed.
|
|
220
|
-
*/
|
|
221
|
-
export function LinePayload({
|
|
222
|
-
code,
|
|
223
|
-
label,
|
|
224
|
-
language,
|
|
225
|
-
className,
|
|
226
|
-
}: {
|
|
227
|
-
code: string
|
|
228
|
-
label: string
|
|
229
|
-
language?: string
|
|
230
|
-
className?: string
|
|
231
|
-
}) {
|
|
232
|
-
return (
|
|
233
|
-
<div className={cn('min-w-0', className)}>
|
|
234
|
-
<span className='block truncate text-label leading-5 text-fg-4'>{label}</span>
|
|
235
|
-
<Response>{fence(code, language)}</Response>
|
|
236
|
-
</div>
|
|
237
|
-
)
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Wrap text as a markdown code fence, with a fence long enough to survive the
|
|
242
|
-
* content: a payload containing ``` would otherwise close the block early and
|
|
243
|
-
* spill the rest of it into the transcript as markdown.
|
|
244
|
-
*/
|
|
245
|
-
export function fence(code: string, language?: string): string {
|
|
246
|
-
const longest = Math.max(0, ...[...code.matchAll(/`+/g)].map((match) => match[0].length))
|
|
247
|
-
const ticks = '`'.repeat(Math.max(3, longest + 1))
|
|
248
|
-
return `${ticks}${language ?? ''}\n${code}\n${ticks}`
|
|
249
|
-
}
|