opentui(copy): mask chrome/gutters so free-form copy is clean

Audit selectable masking (free-code noSelect model) so a free-form drag over an
agent turn yields CLEAN pasteable content — no labels, summaries, carets, or
annotations. Newly masked (selectable={false}):
- toolPart: the whole collapsed header row (name + args-preview + duration +
  "(N lines)") summary; the "args"/"output" section labels; the args overflow
  "… +N more"; the "… omitted N" / "… +N more lines" truncation notes.
- messageLine: the streaming caret (▍) — a cursor glyph, not content.
- reasoningPart: the collapsible-section header label (Thinking/Thought + title).
- composer: the completion dropdown rows + the "Tab complete · Esc dismiss" hint.
Kept selectable (real content): assistant markdown, tool args values + output
body, user/system message text.
This commit is contained in:
alt-glitch 2026-06-09 07:18:48 +00:00
parent eb4821127c
commit 60c5a82c85
4 changed files with 28 additions and 10 deletions

View file

@ -178,15 +178,20 @@ export function Composer(props: {
paddingRight: 1
}}
>
{/* the completion dropdown is transient input chrome (menu rows + the
key-hint) not transcript content so it's excluded from mouse
selection (item 4). */}
<For each={completions().slice(0, 8)}>
{(c, i) => (
<text fg={i() === 0 ? theme().color.accent : theme().color.text}>
<text selectable={false} fg={i() === 0 ? theme().color.accent : theme().color.text}>
{c.display || c.text}
{c.meta ? ` ${c.meta}` : ''}
</text>
)}
</For>
<text fg={theme().color.muted}>Tab complete · Esc dismiss</text>
<text selectable={false} fg={theme().color.muted}>
Tab complete · Esc dismiss
</text>
</box>
</Show>
{/* prompt glyph + textarea the glyph (item 3) marks the input line so the

View file

@ -61,7 +61,8 @@ export function MessageLine(props: { message: Message }) {
</text>
}
>
<text>
<text selectable={false}>
{/* streaming caret — a cursor glyph, not content (item 4) */}
<span style={{ fg: theme().color.muted }}></span>
</text>
</Show>

View file

@ -50,7 +50,10 @@ export function ReasoningPart(props: { text: string; streaming?: boolean }) {
<span style={{ fg: theme().color.accent }}>{expanded() ? '▼' : '▶'}</span>
</text>
</box>
<text>
{/* the header is a collapsible-section LABEL (Thinking/Thought + title)
chrome, not the reasoning body so a free-form drag yields only
the markdown body below, not the section label (item 4). */}
<text selectable={false}>
{/* accent chevron marks it; muted label keeps reasoning in the dim,
secondary tier alongside tool calls (Ink hierarchy). */}
<span style={{ fg: theme().color.muted }}>{label()}</span>

View file

@ -103,7 +103,11 @@ export function ToolPart(props: { part: ToolPartState }) {
</text>
</box>
<box style={{ flexDirection: 'row', flexGrow: 1, minWidth: 0 }}>
<text>
{/* the whole header row is a collapsed SUMMARY (tool name + args-preview
+ duration + "(N lines)") chrome, not the copyable body so a
free-form drag over a tool yields only the expanded output/args
content, never the header label (item 4). */}
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{props.part.name}</span>
<Show when={running()}>
<span style={{ fg: theme().color.muted }}> </span>
@ -134,7 +138,8 @@ export function ToolPart(props: { part: ToolPartState }) {
>
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
<Show when={showArgs()}>
<text>
{/* section label — chrome, not content (item 4) */}
<text selectable={false}>
<span style={{ fg: theme().color.label }}>args</span>
</text>
{/* parsed key: value lines (tidy), or raw argsText when unparseable */}
@ -158,14 +163,16 @@ export function ToolPart(props: { part: ToolPartState }) {
)}
</For>
<Show when={argEntries().length > ARGS_MAX}>
<text>
{/* overflow annotation — chrome, not content (item 4) */}
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{`… +${argEntries().length - ARGS_MAX} more`}</span>
</text>
</Show>
</Show>
</Show>
<Show when={showArgs() && hasOutput()}>
<text>
{/* section label — chrome, not content (item 4) */}
<text selectable={false}>
<span style={{ fg: theme().color.label }}>output</span>
</text>
</Show>
@ -176,13 +183,15 @@ export function ToolPart(props: { part: ToolPartState }) {
</text>
)}
</For>
{/* truncation annotations — chrome (the "… omitted N" / " +N more
lines" notes are not part of the real output body) (item 4). */}
<Show when={props.part.omittedNote}>
<text>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{`… omitted ${props.part.omittedNote}`}</span>
</text>
</Show>
<Show when={body().hiddenLines > 0 && !props.part.omittedNote}>
<text>
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{`… +${body().hiddenLines} more lines`}</span>
</text>
</Show>