@workerdeck/ui 2.8.0 → 2.9.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/build/{SessionPanel-Dg4xf-ql.mjs → SessionPanel-BClL7zUS.mjs} +649 -368
- package/build/SessionPanel-BClL7zUS.mjs.map +1 -0
- package/build/{SessionPanel-_lQLhWOw.d.mts → SessionPanel-BVbau2eR.d.mts} +68 -4
- package/build/index.d.mts +11 -4
- package/build/index.mjs +3 -3
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +24 -0
- package/build/workspace.d.mts +3 -2
- package/build/workspace.mjs +4 -5
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/Composer.tsx +84 -82
- package/src/components/agent/SessionItem.tsx +3 -1
- package/src/components/agent/SessionPanel.tsx +304 -224
- package/src/components/agent/SessionWorkspace.tsx +3 -0
- package/src/components/agent/UsageMeters.tsx +17 -4
- package/src/components/agent/composer-commands.ts +142 -0
- package/src/components/agent/use-path-links.ts +2 -7
- package/src/components/terminal/file-link.tsx +20 -0
- package/src/components/terminal/markdown.tsx +31 -5
- package/src/index.ts +11 -1
- package/src/lib/file-link.ts +70 -0
- package/src/styles/terminal.css +15 -0
- package/build/SessionPanel-Dg4xf-ql.mjs.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
|
-
import type
|
|
2
|
+
import { usageIsStale, type UsageWindowRow } from '@workerdeck/protocol'
|
|
3
3
|
import { RotateCcw } from 'lucide-react'
|
|
4
4
|
import { cn } from '../../lib/utils.ts'
|
|
5
5
|
import { formatAgoPrecise, formatCountdown, formatRateLimitWindowLong, rateLimitWindowSeconds } from '../../lib/format.ts'
|
|
@@ -33,6 +33,9 @@ export function UsageMeters({ windows, now, className }: { windows: UsageWindowR
|
|
|
33
33
|
function UsageMeter({ window, now }: { window: UsageWindowRow; now: number }) {
|
|
34
34
|
const { key: windowKey, info, updatedAt, inferredReset } = window
|
|
35
35
|
const utilization = info.utilization ?? 0
|
|
36
|
+
// Drawn differently rather than hidden: this is still the best answer anyone has, it is just not a claim about
|
|
37
|
+
// right now, and a stale reading asserted as current is how a number from two days ago passes for the truth.
|
|
38
|
+
const stale = usageIsStale(window, now)
|
|
36
39
|
const resetsAtMs = info.resetsAt !== undefined ? info.resetsAt * 1000 : undefined
|
|
37
40
|
const duration = rateLimitWindowSeconds(windowKey)
|
|
38
41
|
const remaining = resetsAtMs !== undefined ? (resetsAtMs - now) / 1000 : undefined
|
|
@@ -44,13 +47,18 @@ function UsageMeter({ window, now }: { window: UsageWindowRow; now: number }) {
|
|
|
44
47
|
<div>
|
|
45
48
|
<div className="flex items-baseline justify-between gap-3">
|
|
46
49
|
<span className="truncate text-body-sm text-fg-1">{formatRateLimitWindowLong(windowKey)}</span>
|
|
47
|
-
<span
|
|
50
|
+
<span
|
|
51
|
+
className={cn(
|
|
52
|
+
'shrink-0 font-mono text-body-sm font-medium',
|
|
53
|
+
info.status === 'rejected' ? 'text-danger' : stale ? 'text-fg-4' : 'text-fg-1',
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
48
56
|
{utilization.toFixed(0)}% used
|
|
49
57
|
</span>
|
|
50
58
|
</div>
|
|
51
59
|
<div className="relative mt-2 h-2 rounded-full bg-border">
|
|
52
60
|
<div
|
|
53
|
-
className={cn('h-full rounded-full', meterTintClass(utilization))}
|
|
61
|
+
className={cn('h-full rounded-full', meterTintClass(utilization), stale && 'opacity-40')}
|
|
54
62
|
style={{ width: `${Math.min(100, Math.max(2, utilization))}%` }}
|
|
55
63
|
/>
|
|
56
64
|
{pace !== undefined ? (
|
|
@@ -71,7 +79,12 @@ function UsageMeter({ window, now }: { window: UsageWindowRow; now: number }) {
|
|
|
71
79
|
{info.isUsingOverage ? <span className="text-warning">overage</span> : null}
|
|
72
80
|
{info.status === 'rejected' ? <span className="text-danger">limit reached</span> : null}
|
|
73
81
|
{inferredReset ? <span>window reset · nothing reported since</span> : null}
|
|
74
|
-
{updatedAt && !inferredReset ?
|
|
82
|
+
{updatedAt && !inferredReset ? (
|
|
83
|
+
<span className={cn(stale && 'text-warning')}>
|
|
84
|
+
{stale ? 'last reported ' : ''}
|
|
85
|
+
{formatAgoPrecise(updatedAt, now)}
|
|
86
|
+
</span>
|
|
87
|
+
) : null}
|
|
75
88
|
</div>
|
|
76
89
|
</div>
|
|
77
90
|
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { SkillInfo, SlashCommandInfo } from '@workerdeck/protocol'
|
|
2
|
+
|
|
3
|
+
export type ClientCommand = {
|
|
4
|
+
name: string
|
|
5
|
+
description: string
|
|
6
|
+
argumentHint?: string
|
|
7
|
+
// Selecting the row populates the composer with `/name ` instead of resolving to a chip, so the
|
|
8
|
+
// argument is typed before anything is submitted.
|
|
9
|
+
requiresArgs?: boolean
|
|
10
|
+
// True when the command consumed the input. False hands the text back to the engine, which is how
|
|
11
|
+
// `/model` with no argument still reaches a CLI that implements it natively.
|
|
12
|
+
run: (args: string) => boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ComposerRow =
|
|
16
|
+
| { kind: 'command'; name: string; description?: string; argumentHint?: string; aliases?: string[] }
|
|
17
|
+
| { kind: 'client'; name: string; description?: string; argumentHint?: string; command: ClientCommand }
|
|
18
|
+
| { kind: 'skill'; name: string; label: string; description?: string; enabled: boolean; scope?: string; skill: SkillInfo }
|
|
19
|
+
|
|
20
|
+
const KIND_ORDER: Record<ComposerRow['kind'], number> = { command: 0, client: 1, skill: 2 }
|
|
21
|
+
|
|
22
|
+
export function cleanCommandName(name: string): string {
|
|
23
|
+
return name.replace(/\s*\(MCP\)$/i, '')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function skillPrompt(skill: SkillInfo): string {
|
|
27
|
+
const base = skill.defaultPrompt?.trim() || `Use the ${skill.displayName ?? skill.name} skill:`
|
|
28
|
+
return /\s$/.test(base) ? base : base + ' '
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function mergeComposerRows(sources: {
|
|
32
|
+
commands?: readonly SlashCommandInfo[]
|
|
33
|
+
clientCommands?: readonly ClientCommand[]
|
|
34
|
+
skills?: readonly SkillInfo[]
|
|
35
|
+
}): ComposerRow[] {
|
|
36
|
+
const rows: ComposerRow[] = []
|
|
37
|
+
const engineNames = new Set<string>()
|
|
38
|
+
for (const command of sources.commands ?? []) {
|
|
39
|
+
const name = cleanCommandName(command.name)
|
|
40
|
+
if (engineNames.has(name)) {
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
engineNames.add(name)
|
|
44
|
+
for (const alias of command.aliases ?? []) {
|
|
45
|
+
engineNames.add(cleanCommandName(alias))
|
|
46
|
+
}
|
|
47
|
+
rows.push({ kind: 'command', name, description: command.description, argumentHint: command.argumentHint, aliases: command.aliases })
|
|
48
|
+
}
|
|
49
|
+
for (const command of sources.clientCommands ?? []) {
|
|
50
|
+
if (engineNames.has(command.name)) {
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
rows.push({ kind: 'client', name: command.name, description: command.description, argumentHint: command.argumentHint, command })
|
|
54
|
+
}
|
|
55
|
+
for (const skill of sources.skills ?? []) {
|
|
56
|
+
rows.push({
|
|
57
|
+
kind: 'skill',
|
|
58
|
+
name: skill.name,
|
|
59
|
+
label: skill.displayName ?? skill.name,
|
|
60
|
+
description: skill.shortDescription ?? skill.description,
|
|
61
|
+
enabled: skill.enabled,
|
|
62
|
+
scope: skill.scope,
|
|
63
|
+
skill,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
return rows
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function haystacks(row: ComposerRow): string[] {
|
|
70
|
+
if (row.kind === 'skill') {
|
|
71
|
+
return [row.name, row.label, ...row.name.split(/[-:_]/)]
|
|
72
|
+
}
|
|
73
|
+
return [row.name, ...(row.kind === 'command' ? (row.aliases ?? []) : []), ...row.name.split(':')]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function matchScore(query: string, candidates: readonly string[]): number {
|
|
77
|
+
const needle = query.toLowerCase()
|
|
78
|
+
const lowered = candidates.map((s) => s.toLowerCase())
|
|
79
|
+
if (lowered.some((h) => h.startsWith(needle))) {
|
|
80
|
+
return 2
|
|
81
|
+
}
|
|
82
|
+
return lowered.some((h) => h.includes(needle)) ? 1 : 0
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function rankComposerRows(query: string, rows: readonly ComposerRow[]): ComposerRow[] {
|
|
86
|
+
const scored: Array<{ score: number; index: number; row: ComposerRow }> = []
|
|
87
|
+
rows.forEach((row, index) => {
|
|
88
|
+
if (row.kind === 'skill' && !row.enabled) {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
const score = matchScore(query, haystacks(row))
|
|
92
|
+
if (score > 0) {
|
|
93
|
+
scored.push({ score, index, row })
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
scored.sort((a, b) => b.score - a.score || KIND_ORDER[a.row.kind] - KIND_ORDER[b.row.kind] || a.index - b.index)
|
|
97
|
+
return scored.map(({ row }) => row)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function matchClientCommand(text: string, commands: readonly ClientCommand[]): { command: ClientCommand; args: string } | undefined {
|
|
101
|
+
const match = /^\/([\w:-]+)[ \t]*([\s\S]*)$/.exec(text.trim())
|
|
102
|
+
if (!match) {
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
const command = commands.find((c) => c.name === match[1])
|
|
106
|
+
return command ? { command, args: match[2]!.trim() } : undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The serializable projection a native host (the VS Code QuickPick) renders. `insertText` is resolved here
|
|
110
|
+
// so no second surface reimplements skillPrompt or the leading slash.
|
|
111
|
+
export type ComposerCommandRow = {
|
|
112
|
+
kind: ComposerRow['kind']
|
|
113
|
+
name: string
|
|
114
|
+
label: string
|
|
115
|
+
description?: string
|
|
116
|
+
scope?: string
|
|
117
|
+
enabled: boolean
|
|
118
|
+
insertText: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function composerCommandRows(rows: readonly ComposerRow[]): ComposerCommandRow[] {
|
|
122
|
+
return rows.map((row) =>
|
|
123
|
+
row.kind === 'skill'
|
|
124
|
+
? {
|
|
125
|
+
kind: row.kind,
|
|
126
|
+
name: row.name,
|
|
127
|
+
label: row.label,
|
|
128
|
+
description: row.description,
|
|
129
|
+
scope: row.scope,
|
|
130
|
+
enabled: row.enabled,
|
|
131
|
+
insertText: skillPrompt(row.skill),
|
|
132
|
+
}
|
|
133
|
+
: {
|
|
134
|
+
kind: row.kind,
|
|
135
|
+
name: row.name,
|
|
136
|
+
label: `/${row.name}`,
|
|
137
|
+
description: row.description,
|
|
138
|
+
enabled: true,
|
|
139
|
+
insertText: `/${row.name} `,
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, type RefObject } from 'react'
|
|
2
|
+
import { parseFileLink } from '../../lib/file-link.ts'
|
|
2
3
|
|
|
3
4
|
export type PathHit = { path: string; line?: number }
|
|
4
5
|
|
|
@@ -119,11 +120,5 @@ export function usePathLinks({
|
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
export function resolveAgainstCwd(path: string, cwd: string | undefined): string {
|
|
122
|
-
|
|
123
|
-
return path
|
|
124
|
-
}
|
|
125
|
-
if (!cwd) {
|
|
126
|
-
return path
|
|
127
|
-
}
|
|
128
|
-
return `${cwd.replace(/\/$/, '')}/${path.replace(/^\.\//, '')}`
|
|
123
|
+
return parseFileLink(path, cwd)?.path ?? path
|
|
129
124
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export type FileLinkOpener = (path: string, line?: number) => void
|
|
4
|
+
|
|
5
|
+
export type FileLinkHandle = {
|
|
6
|
+
cwd?: string
|
|
7
|
+
open: FileLinkOpener
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const FileLinkContext = createContext<FileLinkHandle | undefined>(undefined)
|
|
11
|
+
|
|
12
|
+
// A missing provider leaves every link an ordinary anchor, so embeddings that have nowhere to
|
|
13
|
+
// open a file pay nothing - the same contract BookmarkContext has.
|
|
14
|
+
export function FileLinkProvider({ value, children }: { value: FileLinkHandle | undefined; children: ReactNode }) {
|
|
15
|
+
return <FileLinkContext.Provider value={value}>{children}</FileLinkContext.Provider>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useFileLinks(): FileLinkHandle | undefined {
|
|
19
|
+
return useContext(FileLinkContext)
|
|
20
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { memo, type ReactNode } from 'react'
|
|
2
2
|
import { Streamdown, type Components } from 'streamdown'
|
|
3
3
|
import { cn } from '../../lib/utils.ts'
|
|
4
|
+
import { parseFileLink } from '../../lib/file-link.ts'
|
|
4
5
|
import { CopyAction, WithActions } from './affordances.tsx'
|
|
6
|
+
import { useFileLinks } from './file-link.tsx'
|
|
5
7
|
import { Band } from './row.tsx'
|
|
6
8
|
|
|
7
9
|
function codeText(node: ReactNode): string {
|
|
@@ -45,6 +47,34 @@ function heading(tone: 'bright' | 'fg'): Components['h1'] {
|
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
// An agent writes a file as a markdown link far more often than it writes a URL, and the link text is
|
|
51
|
+
// its name, not its path: an anchor to a scheme-less href resolves against the host page and leads nowhere.
|
|
52
|
+
function Link({ href, children }: { href?: string; children: ReactNode }) {
|
|
53
|
+
const links = useFileLinks()
|
|
54
|
+
const file = links ? parseFileLink(href, links.cwd) : undefined
|
|
55
|
+
if (file) {
|
|
56
|
+
return (
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
className="term-link"
|
|
60
|
+
data-tone="blue"
|
|
61
|
+
title={file.line === undefined ? file.path : `${file.path}:${file.line}`}
|
|
62
|
+
onClick={(event) => {
|
|
63
|
+
event.stopPropagation()
|
|
64
|
+
links?.open(file.path, file.line)
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{children}
|
|
68
|
+
</button>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
return (
|
|
72
|
+
<a className="term-link" data-tone="blue" href={href} target="_blank" rel="noreferrer">
|
|
73
|
+
{children}
|
|
74
|
+
</a>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
48
78
|
const TERMINAL_COMPONENTS: Components = {
|
|
49
79
|
p: ({ children }) => <div className="term-block">{children}</div>,
|
|
50
80
|
|
|
@@ -85,11 +115,7 @@ const TERMINAL_COMPONENTS: Components = {
|
|
|
85
115
|
</strong>
|
|
86
116
|
),
|
|
87
117
|
em: ({ children }) => <em className="term-em">{children}</em>,
|
|
88
|
-
a: ({ children, href }) =>
|
|
89
|
-
<a className="term-link" data-tone="blue" href={href} target="_blank" rel="noreferrer">
|
|
90
|
-
{children}
|
|
91
|
-
</a>
|
|
92
|
-
),
|
|
118
|
+
a: ({ children, href }) => <Link href={href}>{children}</Link>,
|
|
93
119
|
|
|
94
120
|
table: ({ children }) => (
|
|
95
121
|
<div className="term-block term-table-wrap">
|
package/src/index.ts
CHANGED
|
@@ -61,6 +61,8 @@ export { TerminalDiff, previewPatch } from './components/terminal/diff.tsx'
|
|
|
61
61
|
export { TerminalMarkdown, type TerminalMarkdownProps } from './components/terminal/markdown.tsx'
|
|
62
62
|
export { Band, Blank, Ink, Row, type RowProps, type Tone } from './components/terminal/row.tsx'
|
|
63
63
|
export { CopyAction, WithActions, useAffordances, type TerminalAffordances } from './components/terminal/affordances.tsx'
|
|
64
|
+
export { FileLinkProvider, useFileLinks, type FileLinkHandle, type FileLinkOpener } from './components/terminal/file-link.tsx'
|
|
65
|
+
export { parseFileLink, type FileLink } from './lib/file-link.ts'
|
|
64
66
|
export { Conversation, ConversationContent, ConversationScrollButton, type ConversationProps } from './components/agent/Conversation.tsx'
|
|
65
67
|
export { Message, MessageContent, type MessageProps } from './components/agent/Message.tsx'
|
|
66
68
|
export {
|
|
@@ -86,7 +88,15 @@ export {
|
|
|
86
88
|
type QuestionPromptProps,
|
|
87
89
|
type QuestionBehaviorMeta,
|
|
88
90
|
} from './components/agent/QuestionPrompt.tsx'
|
|
89
|
-
export { Composer,
|
|
91
|
+
export { Composer, type ComposerFileMatch, type ComposerHandle, type ComposerProps } from './components/agent/Composer.tsx'
|
|
92
|
+
export {
|
|
93
|
+
mergeComposerRows,
|
|
94
|
+
rankComposerRows,
|
|
95
|
+
matchClientCommand,
|
|
96
|
+
skillPrompt,
|
|
97
|
+
type ClientCommand,
|
|
98
|
+
type ComposerRow,
|
|
99
|
+
} from './components/agent/composer-commands.ts'
|
|
90
100
|
export { ModelSelect, type ModelSelectProps } from './components/agent/ModelSelect.tsx'
|
|
91
101
|
export {
|
|
92
102
|
PERMISSION_MODES,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type FileLink = {
|
|
2
|
+
path: string
|
|
3
|
+
line?: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/
|
|
7
|
+
const TRAILING_LINE = /^(.*?):(\d+)(?::\d+)?$/
|
|
8
|
+
const FRAGMENT_LINE = /^L(\d+)/
|
|
9
|
+
|
|
10
|
+
// What an agent writes as a markdown link target when it means a file on the host: `./SPEC.md`,
|
|
11
|
+
// `docs/AUTH.md#anchor`, `/Users/me/project/src/main.ts:42`, `file:///tmp/out.txt`. Anything with a
|
|
12
|
+
// real scheme, and anything relative that arrives before the session's cwd is known, stays a web link.
|
|
13
|
+
export function parseFileLink(href: string | undefined, cwd: string | undefined): FileLink | undefined {
|
|
14
|
+
const raw = href?.trim()
|
|
15
|
+
if (!raw || raw.startsWith('#') || raw.startsWith('//')) {
|
|
16
|
+
return undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let target = raw
|
|
20
|
+
if (SCHEME.test(target)) {
|
|
21
|
+
if (!target.startsWith('file:')) {
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
target = decodeURIComponent(new URL(target).pathname)
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let line: number | undefined
|
|
32
|
+
const hash = target.indexOf('#')
|
|
33
|
+
if (hash !== -1) {
|
|
34
|
+
line = readLine(FRAGMENT_LINE.exec(target.slice(hash + 1))?.[1])
|
|
35
|
+
target = target.slice(0, hash)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const suffix = TRAILING_LINE.exec(target)
|
|
39
|
+
if (suffix) {
|
|
40
|
+
target = suffix[1]!
|
|
41
|
+
line ??= readLine(suffix[2])
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!target) {
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const path = target.startsWith('/') ? normalize(target) : cwd ? normalize(`${cwd}/${target}`) : undefined
|
|
49
|
+
return path ? { path, ...(line === undefined ? {} : { line }) } : undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readLine(value: string | undefined): number | undefined {
|
|
53
|
+
const parsed = value === undefined ? Number.NaN : Number(value)
|
|
54
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalize(path: string): string {
|
|
58
|
+
const out: string[] = []
|
|
59
|
+
for (const part of path.split('/')) {
|
|
60
|
+
if (part === '' || part === '.') {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (part === '..') {
|
|
64
|
+
out.pop()
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
out.push(part)
|
|
68
|
+
}
|
|
69
|
+
return `/${out.join('/')}`
|
|
70
|
+
}
|
package/src/styles/terminal.css
CHANGED
|
@@ -416,6 +416,21 @@
|
|
|
416
416
|
text-underline-offset: 2px;
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
/* A link to a file opens through the host rather than the address bar, so it is a button.
|
|
420
|
+
* Outside `[data-terminal]` too: a cards transcript renders the same markdown. */
|
|
421
|
+
button.term-link {
|
|
422
|
+
appearance: none;
|
|
423
|
+
background: none;
|
|
424
|
+
border: 0;
|
|
425
|
+
padding: 0;
|
|
426
|
+
font: inherit;
|
|
427
|
+
color: inherit;
|
|
428
|
+
cursor: pointer;
|
|
429
|
+
text-align: inherit;
|
|
430
|
+
text-decoration: underline;
|
|
431
|
+
text-underline-offset: 2px;
|
|
432
|
+
}
|
|
433
|
+
|
|
419
434
|
/* A file path, while the modifier that would open it is held.
|
|
420
435
|
*
|
|
421
436
|
* Only then, and that is the point: a path *is* clickable but only under
|