mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(terminal): take the transcript gutter off a copy, at the width the CLI declares
Copying a paragraph out of a Claude Code or Codex pane puts that pane's own two-column transcript gutter on the clipboard, so every pasted line arrives indented. #451 shipped the trailing half of the copy clean and left the leading half out, because deriving the width from the selection fires on 73% of ordinary indented text and cannot tell a margin from content. The width is DECLARED rather than derived. `capabilities.transcriptGutter` on the CLI registry is a bounded integer; claude and codex each declare 2, measured on live panes, and no other stock entry declares any, so a CLI whose transcript layout nobody has measured is never touched. The server publishes the map as `window.__codemanTranscriptGutter`, built by filtering `enabledClis()` on the capability rather than by listing ids, and `_activeCliGutterColumns()` looks the active session's mode up in it. The copy path reads no terminal buffer at all. The declared width is a CEILING, not the answer: `clean()` strips the lesser of it and the run every selected line shares. A block can therefore only shift as a unit, the structure inside a selection survives by construction, and a selection reaching column 0 loses nothing. That is what keeps a `git log` body at its own four-space indent inside an agent's two-column gutter. Codex was measured separately, because it renders nothing like Claude: it draws boxes narrower than the pane and pushes its transcript into ordinary scrollback. On a live 0.154.0 answer its `•`/`›`/`⚠` markers sit in the gutter, prose continuations sit at 2, and a nested YAML block the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120, 160, 198, 235 and 282 columns its indents were 0, 2, 4, 6 and 8 at every one, never 1. Copying that YAML out of a live Codex pane now yields 0/2/4/6: gutter gone, nesting intact. Two derived versions were built and measured first, and both are recorded in the code because both looked correct: - Painted trailing padding — a full-screen TUI writes real spaces across the unused part of a row, a shell leaves them never-written for xterm to trim — has no false positives and never over-stripped. It is also a function of pane WIDTH: the padding exists only while a rendered line stops short of the CLI's own layout width, and Claude's prose wraps to fill it. Dragging the same two prose rows of one live transcript at five window sizes, the share of padded rows ran 44%, 6%, 6%, 7% and 87% at 123, 160, 198, 235 and 298 columns, so the strip silently did nothing at every ordinary size while a corpus captured entirely at 282 columns said it worked. - Taking the narrowest indent on the rows around the selection fires at every width and over-strips about 1% of selections, because a file listing inside the transcript can be the narrowest thing on screen. Measured over 1,392,281 selections — every 1, 2, 3, 5, 10 and 20-row window of real Claude screens replayed from live PTY streams at 100, 120, 160, 198, 235 and 282 columns — the declared width over-strips none, breaks no relative indent and alters no text, and serves 100% of the selections whose own indent covers the gutter. Verified end to end in a browser with a real mouse drag and a real Ctrl+C: Claude and Codex panes paste flush at 123, 198 and 298 columns, a shell pane is untouched at every one. The strip sits behind `copyStripMargin` (App Settings, Selection & clipboard), per-device and default ON: a display key, absent from the .strict() SettingsUpdateSchema, read as `!== false` because the desktop branch of getDefaultSettings() returns {}. The toggle is checked before the map. Two review findings from #451, handled: - The mid-row flag governs ONE line now. `range.start.x > 0` excludes only the first selected line, the one whose margin the mousedown genuinely cut off, so the same three rows no longer produce three different clipboard results. - The reversed-drag finding does not reproduce on the pinned xterm. `getSelectionPosition()` reads `_selectionService.selectionStart`, whose getter returns `SelectionModel.finalSelectionStart`, and that swaps the pair when `areSelectionValuesReversed()` says so. A real upward mouse drag through chromium against xterm 6.0 reports the same range as the downward drag. `_normalisedSelectionRange()` keeps the ordering as a guard, because the model one layer down exposes the unnormalised fields under the same two names. Tests: test/terminal-copy-clean.test.ts (64, up from 31), plus the injected script stripped in test/server-index-title.test.ts. Every guard is pinned: removing any one of seven reds at least one test, including declaring the wrong gutter width. Full suite green, 7,861 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9466acfc1a
commit
ce80b7a212
@@ -294,6 +294,23 @@ const capabilitiesSchema = z
|
||||
effort: z.boolean(),
|
||||
agentSkillInjection: z.boolean(),
|
||||
statusLineTelemetry: z.boolean(),
|
||||
// How many columns this CLI indents its transcript body by, so a copy can take
|
||||
// that much off the clipboard. Bounded, because it is the whole strip: a copy
|
||||
// never removes more than this, nor more than every selected line shares.
|
||||
//
|
||||
// ⚠ DECLARED, not measured off the pane, and two measured attempts are why.
|
||||
// Asking whether the pane painted spaces across the unused part of each row
|
||||
// separates a TUI from a shell perfectly where it fires and never
|
||||
// over-stripped, but it is a function of pane WIDTH: that padding exists
|
||||
// only while a rendered line stops short of the CLI's own layout width, and
|
||||
// Claude Code's prose wraps to fill it — the share of padded rows on one
|
||||
// live transcript ran 44%, 6%, 6%, 7% and 87% at 123, 160, 198, 235 and 298
|
||||
// columns, so the strip did nothing at any ordinary size. Taking the
|
||||
// narrowest indent on screen instead fires everywhere and over-strips, since
|
||||
// a file listing inside the transcript can be the narrowest thing on it.
|
||||
// A declared width cannot do either. Absent means no strip, so a CLI whose
|
||||
// transcript layout nobody has measured is never touched.
|
||||
transcriptGutter: z.number().int().min(1).max(8).optional(),
|
||||
workDetect: z
|
||||
.object({
|
||||
promptGlyph: z.string().min(1).max(8),
|
||||
|
||||
@@ -200,6 +200,10 @@ const CLAUDE: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
external: false,
|
||||
// Claude indents its transcript body two columns and puts its own ●/✻/❯ markers
|
||||
// in them, so a copy can drop two and paste flush. The only entry that declares
|
||||
// this, because it is the only one whose gutter has been measured.
|
||||
transcriptGutter: 2,
|
||||
// The historical hard-coded pair, now stated as data. `workingLine` matches both the
|
||||
// `✻ Actualizing… (39s · ↓ 2.0k tokens)` status line and the bare `esc to interrupt`
|
||||
// footer, because tmux repaints partially and only one of the two may land in a chunk.
|
||||
@@ -519,6 +523,12 @@ const CODEX: CliEntry = {
|
||||
// braille spinner, and it never prints `esc to interrupt` at rest, so that phrase
|
||||
// alone separates a running turn from an idle one.
|
||||
workDetect: { promptGlyph: '›', workingLine: '[Ee]sc to interrupt' },
|
||||
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
|
||||
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
|
||||
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
|
||||
// 160, 198, 235 and 282 columns the indents were 0, 2, 4, 6 and 8 at every one,
|
||||
// never 1, so the width is not a function of the pane.
|
||||
transcriptGutter: 2,
|
||||
transcript: 'codex-rollout',
|
||||
altScreen: 'strip-full',
|
||||
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
|
||||
|
||||
@@ -345,6 +345,29 @@ export interface CliCapabilities {
|
||||
/** Source of a regex matching the status line this CLI draws while a turn runs. */
|
||||
workingLine: string;
|
||||
};
|
||||
/**
|
||||
* How many columns this CLI indents its transcript body by, so a copy taken from its
|
||||
* pane can drop that much and paste flush. Claude Code indents two and puts its own
|
||||
* markers in those columns.
|
||||
*
|
||||
* ⚠ DECLARED rather than measured off the pane, and two measured attempts are why.
|
||||
* Asking whether the pane painted real spaces across the unused part of each row
|
||||
* separates a TUI from a shell perfectly where it fires and never over-stripped; it
|
||||
* is also a function of pane WIDTH, because that padding exists only while a
|
||||
* rendered line stops short of the CLI's own layout width and Claude Code's prose
|
||||
* wraps to fill it. On one live transcript the share of padded rows ran 44%, 6%, 6%,
|
||||
* 7% and 87% at 123, 160, 198, 235 and 298 columns, so at any ordinary window size
|
||||
* the strip silently did nothing. Taking the narrowest indent on the surrounding
|
||||
* rows instead fires at every width and over-strips on roughly 1% of selections,
|
||||
* because a file listing inside the transcript can be the narrowest thing on screen.
|
||||
*
|
||||
* A declared width can do neither. The strip is the lesser of this and what every
|
||||
* selected line shares, so a block can only ever shift as a unit, and it can never
|
||||
* shift further than the CLI itself says its gutter is.
|
||||
*
|
||||
* Absent means no strip at all, the same fail-safe direction `workDetect` takes.
|
||||
*/
|
||||
transcriptGutter?: number;
|
||||
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
|
||||
requiresMux: boolean;
|
||||
/**
|
||||
|
||||
+51
-24
@@ -774,6 +774,8 @@ function resolveTerminalFontWeights(settings) {
|
||||
*/
|
||||
const AUTO_COPY_MAX_CHARS = 1_000_000;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* What an auto-copy attempt should do at the end of a selection gesture.
|
||||
*
|
||||
@@ -819,36 +821,32 @@ function decideAutoCopy({ enabled, text, lastCopied, pending } = {}) {
|
||||
// _selectTouchSelectionLine already treats those cells as padding. This is that
|
||||
// same rule for the mouse and keyboard paths, which never had it.
|
||||
//
|
||||
// ⚠ Trailing padding ONLY. A shared LEADING indent is deliberately left alone,
|
||||
// and this note is here so the idea is not re-derived: it was built, measured
|
||||
// and dropped before merge. Removing the longest leading run every selected row
|
||||
// shares looks like the mirror image of the trailing trim and is not, because
|
||||
// no native terminal does it and the transform cannot tell a TUI's margin from
|
||||
// content that is genuinely indented. Measured over 401 445 three-row windows
|
||||
// across 1 010 tracked files in this repo, it fired on 73% of them: 92% inside
|
||||
// a YAML workflow, 76% over `git log` output, 48% in a TypeScript source file.
|
||||
// No width threshold separates the two, because they are the same widths: a
|
||||
// live Claude Code pane's own margins measure 2 and 5 columns while the most
|
||||
// common non-TUI shared run is 4, sitting between them.
|
||||
// A LEADING margin is stripped too, but only the one the CLI in the pane
|
||||
// DECLARES as its transcript gutter, passed in as `options.margin`. Called with
|
||||
// no options this trims trailing padding and nothing else, which is what keeps
|
||||
// every caller that has no declared gutter on the old behaviour.
|
||||
//
|
||||
// The asymmetry that settles it is in the failure modes. A wrong trailing trim
|
||||
// ⚠ The failure modes are not symmetrical, and that asymmetry sets how much
|
||||
// evidence a leading strip has to show before it fires. A wrong trailing trim
|
||||
// costs nothing. A wrong dedent silently deletes information that was on the
|
||||
// screen, with no signal to the user and nothing in the clipboard to hint at
|
||||
// it, and it is wrong on `git log` bodies, on indented code read out of `cat`
|
||||
// (semantic in Python), on `git diff` context rows where the leading space is
|
||||
// the marker, and on stack traces.
|
||||
//
|
||||
// ⚠ It also cannot be made consistent cheaply. Whether the first row joins the
|
||||
// measurement depended on the mousedown COLUMN, which the user never sees, so
|
||||
// one block of three rows produced three different clipboard results; and the
|
||||
// flag read `getSelectionPosition().start`, which is the mousedown anchor that
|
||||
// xterm never normalises, so dragging UP through a block read it off the bottom
|
||||
// row. If it is ever revisited, the one qualification that measured clean is
|
||||
// painted trailing padding (a full-screen TUI writes real spaces across every
|
||||
// row; a shell pane leaves those cells never-written, so xterm trims them):
|
||||
// zero false positives over all 401 445 windows. It still mangles a `git log`
|
||||
// body sitting inside an agent's own gutter, which is why it was not taken now.
|
||||
function cleanCopiedSelection(text) {
|
||||
// ⚠ The declared gutter is a CEILING, not the answer. The strip is the lesser
|
||||
// of it and the run every selected line shares, so a block can only ever shift
|
||||
// as a unit: the relative structure inside a selection survives by
|
||||
// construction, and a selection reaching column 0 loses nothing at all.
|
||||
//
|
||||
// ⚠ Deriving the width from the text instead is what fails, twice over. The
|
||||
// selection's own shared indent cannot tell a margin from content, because a
|
||||
// three-row window of nested YAML shares an indent for the same reason a margin
|
||||
// does — it fired on 73% of ordinary indented text. Taking the narrowest indent
|
||||
// on the surrounding rows fails more quietly: a file listing inside the
|
||||
// transcript can be the narrowest thing on screen, which over-stripped about 1%
|
||||
// of selections across six pane widths.
|
||||
function cleanCopiedSelection(text, options) {
|
||||
if (typeof text !== 'string' || !text) return '';
|
||||
// Split on \n and leave any \r in place: xterm joins rows with \r\n on
|
||||
// Windows, and the clipboard should keep the endings xterm chose.
|
||||
@@ -868,7 +866,36 @@ function cleanCopiedSelection(text) {
|
||||
while (cut > 0 && (line[cut - 1] === ' ' || line[cut - 1] === '\t')) cut--;
|
||||
return cut === end ? line : line.slice(0, cut) + line.slice(end);
|
||||
};
|
||||
return text.split('\n').map(trimEnd).join('\n');
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) lines[i] = trimEnd(lines[i]);
|
||||
|
||||
const margin = Math.max(0, Math.trunc(Number(options?.margin) || 0));
|
||||
if (!margin) return lines.join('\n');
|
||||
|
||||
// The first line of a selection that began mid-row carries no margin — the
|
||||
// mousedown cut it off — so it neither votes on the shared indent nor gets
|
||||
// stripped. This is the ONE thing the mousedown column still decides, and it
|
||||
// decides it for that line alone. Whether the rest of the block is dedented
|
||||
// no longer depends on where the click landed, which is what made the same
|
||||
// three rows produce three different clipboard results before.
|
||||
const from = options?.firstLinePartial === true ? 1 : 0;
|
||||
|
||||
// The pane's margin is a ceiling, not the answer. Strip the narrower of it
|
||||
// and what every selected line shares, so the block shifts as a unit and no
|
||||
// line can lose indentation another line keeps.
|
||||
let shared = margin;
|
||||
for (let i = from; i < lines.length && shared > 0; i++) {
|
||||
const line = lines[i];
|
||||
if (!line || line === '\r') continue; // a padding-only row, already trimmed away
|
||||
let run = 0;
|
||||
while (run < line.length && line[run] === ' ') run++;
|
||||
if (run < shared) shared = run;
|
||||
}
|
||||
if (!shared) return lines.join('\n');
|
||||
for (let i = from; i < lines.length; i++) {
|
||||
if (lines[i] && lines[i] !== '\r') lines[i] = lines[i].slice(shared);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
@@ -1791,6 +1791,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoCopySelection"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="copy indent margin gutter dedent trim leading whitespace paste">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Trim the pane margin on copy</span>
|
||||
<span class="set-row-desc">Drop the left margin a full-screen agent CLI paints down its own edge, so copied text pastes flush instead of indented. Measured off rows you did not select, and never wider than the indent every selected line shares, so nesting inside the selection is kept. Panes that paint no margin, such as a shell or Codex, are left alone.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCopyStripMargin"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -446,6 +446,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// overwrites the system clipboard on a gesture the user may have meant only as
|
||||
// a way to read, so it is opt-in rather than a default anyone has to discover.
|
||||
document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true;
|
||||
// Default ON, so an absent key reads as enabled rather than as off.
|
||||
document.getElementById('appSettingsCopyStripMargin').checked = settings.copyStripMargin !== false;
|
||||
document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || '';
|
||||
this.populateTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight'), settings.terminalFontWeight);
|
||||
this.populateTerminalFontWeight(
|
||||
@@ -2137,6 +2139,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
|
||||
copyStripMargin: document.getElementById('appSettingsCopyStripMargin').checked,
|
||||
terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(),
|
||||
terminalFontWeight: this.readTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight')),
|
||||
terminalFontWeightBold: this.readTerminalFontWeight(
|
||||
@@ -2363,6 +2366,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// and absent from SettingsUpdateSchema (.strict()), so sending it would
|
||||
// 400 the whole settings PUT.
|
||||
autoCopySelection: _acs,
|
||||
// What the clipboard gets is a property of what this device is looking
|
||||
// at, and the key is absent from SettingsUpdateSchema (.strict()), so
|
||||
// sending it would 400 the whole settings PUT.
|
||||
copyStripMargin: _csm,
|
||||
// Per-device by nature (the font must exist on the device) and absent
|
||||
// from SettingsUpdateSchema (.strict()) — sending it would 400 the PUT.
|
||||
terminalFontFamily: _tff,
|
||||
@@ -3372,7 +3379,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'autoCopySelection',
|
||||
'autoCopySelection', 'copyStripMargin',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
'showTabDetachButton',
|
||||
'mobileOverviewEnabled',
|
||||
|
||||
@@ -4191,7 +4191,67 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this.terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
|
||||
const clean = window.CodemanCopySelection?.clean;
|
||||
if (!clean) return raw;
|
||||
return clean(raw);
|
||||
const range = this._normalisedSelectionRange();
|
||||
return clean(raw, {
|
||||
margin: this._activeCliGutterColumns(),
|
||||
firstLinePartial: !!range && range.start.x > 0,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* xterm's selection range with its two ends in reading order.
|
||||
*
|
||||
* `getSelectionPosition()` reports `start` and `end` as the two ends of the
|
||||
* drag, and on xterm 6.0 it already hands back the earlier one first: it
|
||||
* reads `_selectionService.selectionStart`, whose getter returns the model's
|
||||
* `finalSelectionStart`, and that swaps the pair for a reversed selection.
|
||||
* A real upward mouse drag through chromium confirms it. The ordering here
|
||||
* is a guard rather than a fix. One layer down the same model exposes the
|
||||
* UNNORMALISED fields under the same two names, and a reversed pair would
|
||||
* make the row window below run backwards and collapse, which would report
|
||||
* no margin at all for every upward drag in a deep buffer.
|
||||
*/
|
||||
_normalisedSelectionRange() {
|
||||
const range = this.terminal?.getSelectionPosition?.();
|
||||
if (!range?.start || !range?.end) return null;
|
||||
const { start, end } = range;
|
||||
const reversed = end.y < start.y || (end.y === start.y && end.x < start.x);
|
||||
return reversed ? { start: end, end: start } : { start, end };
|
||||
},
|
||||
|
||||
/**
|
||||
* How many columns to take off a copy from the active session's pane: the
|
||||
* transcript gutter its CLI declares, or 0 when it declares none.
|
||||
*
|
||||
* ⚠️ Read from `window.__codemanTranscriptGutter`, the map the server derives
|
||||
* from the `transcriptGutter` CAPABILITY at render time — never an id literal
|
||||
* here, which is the registry's standing rule and is also what lets a CLI that
|
||||
* declares a gutter later work with no change to this file.
|
||||
*
|
||||
* ⚠️ DECLARED rather than measured off the buffer, and two measured versions
|
||||
* are why. Asking whether the pane painted spaces across the unused part of
|
||||
* each row separates a TUI from a shell perfectly where it fires and never
|
||||
* over-stripped, but it is a function of pane WIDTH, since that padding exists
|
||||
* only while a rendered line stops short of the CLI's own layout width and
|
||||
* Claude Code's prose wraps to fill it: the share of padded rows on one live
|
||||
* transcript ran 44%, 6%, 6%, 7% and 87% at 123, 160, 198, 235 and 298
|
||||
* columns, so the strip did nothing at any ordinary window size. Taking the
|
||||
* narrowest indent on the rows around the selection instead fires at every
|
||||
* width and over-strips on about 1% of them, because a file listing inside the
|
||||
* transcript can be the narrowest thing on screen. A declared width does
|
||||
* neither, and it reads no buffer rows at all on a path that runs on every
|
||||
* Ctrl+C.
|
||||
*
|
||||
* A missing map means no session gets a strip, the same direction an
|
||||
* unmeasured CLI takes by declaring nothing.
|
||||
*/
|
||||
_activeCliGutterColumns() {
|
||||
if (!this._copyStripMarginEnabled()) return 0;
|
||||
const byMode = window.__codemanTranscriptGutter;
|
||||
if (!byMode || typeof byMode !== 'object') return 0;
|
||||
const mode = this.sessions?.get(this.activeSessionId)?.mode;
|
||||
const columns = mode ? byMode[mode] : 0;
|
||||
return Number.isInteger(columns) && columns > 0 ? columns : 0;
|
||||
},
|
||||
|
||||
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
|
||||
@@ -4226,6 +4286,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
return ok;
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether this device wants the pane's left margin off the clipboard
|
||||
* (`copyStripMargin`, per-device, default ON).
|
||||
*
|
||||
* Read here rather than mirrored into a field, for the same reason
|
||||
* `_autoCopySelectionEnabled` is: there is then no apply-path a future
|
||||
* settings save can forget to call, and the toggle takes effect on the next
|
||||
* selection instead of the next reload. ⚠️ The test is `!== false`, not
|
||||
* `=== true`: this one defaults ON, and the desktop branch of
|
||||
* getDefaultSettings returns {} and leans on the read sites for defaults, so
|
||||
* a device that has never opened App Settings has no stored value at all.
|
||||
*/
|
||||
_copyStripMarginEnabled() {
|
||||
try {
|
||||
return this.loadAppSettingsFromStorage?.()?.copyStripMargin !== false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Auto Copy's ON/OFF, read at flush time from the CACHED settings object
|
||||
* (loadAppSettingsFromStorage memoizes, so this is not a localStorage hit).
|
||||
|
||||
@@ -1682,6 +1682,20 @@ export class WebServer extends EventEmitter {
|
||||
'</head>',
|
||||
() => `<script>window.__codemanCustomModelClis=${customModelClisJson};</script>\n</head>`
|
||||
);
|
||||
// How many columns each run mode indents its transcript by, so a copy can drop
|
||||
// that much. Read off `capabilities` like the payload above and never as an id
|
||||
// list here, so a CLI that declares a gutter later needs no frontend change.
|
||||
// Ids and small integers only, no user-settable strings, so JSON.stringify
|
||||
// alone is enough (same reasoning as __codemanCliAvailable's booleans).
|
||||
const gutterClis: Record<string, number> = {};
|
||||
for (const entry of enabledClis()) {
|
||||
const columns = entry.capabilities.transcriptGutter;
|
||||
if (typeof columns === 'number') gutterClis[entry.id] = columns;
|
||||
}
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
() => `<script>window.__codemanTranscriptGutter=${JSON.stringify(gutterClis)};</script>\n</head>`
|
||||
);
|
||||
}
|
||||
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
|
||||
html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
|
||||
|
||||
Reference in New Issue
Block a user