merge(main): resolve terminal runtime.test.js ARGV0 vs DA query
Keep ARGV0/env-u assertions from this branch and the DA startup-reply expectations from main's terminal PTY-before-viewport fix. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
@@ -355,6 +355,10 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
|
||||
- Skills config CRUD and metadata under `/api/config/skills*`
|
||||
- Skills catalog listing/source pagination, scan, and install routes
|
||||
- Supporting skill file read/write/delete routes
|
||||
- Directory resolution prefers an explicit request directory, then soft-falls
|
||||
back to the active project / `lastDirectory` so repository-local
|
||||
`.agents/skills` and `.opencode/skills` remain discoverable when the client
|
||||
omits `directory`. Requests without any project still list user-scoped skills.
|
||||
|
||||
## Public exports (proxy.js)
|
||||
- `registerOpenCodeProxy(app, dependencies)`: registers OpenCode proxy routes and middleware.
|
||||
|
||||
@@ -200,9 +200,33 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Prefer an explicit request directory, then soft-fallback to the active
|
||||
// project / lastDirectory so repository-local skills stay visible when the
|
||||
// client omits `directory` (create already used resolveProjectDirectory).
|
||||
const resolveSkillsDirectory = async (req) => {
|
||||
const optional = await resolveOptionalProjectDirectory(req);
|
||||
if (optional.error) {
|
||||
return optional;
|
||||
}
|
||||
if (optional.directory) {
|
||||
return optional;
|
||||
}
|
||||
|
||||
try {
|
||||
const fallback = await resolveProjectDirectory(req);
|
||||
if (fallback.directory) {
|
||||
return { directory: fallback.directory, error: null };
|
||||
}
|
||||
} catch {
|
||||
// ignore — listing user-scoped skills without a project is valid
|
||||
}
|
||||
|
||||
return { directory: null, error: null };
|
||||
};
|
||||
|
||||
app.get('/api/config/skills', async (req, res) => {
|
||||
try {
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
const { directory, error } = await resolveSkillsDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
@@ -257,7 +281,7 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
|
||||
app.get('/api/config/skills/catalog/source', async (req, res) => {
|
||||
try {
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
const { directory, error } = await resolveSkillsDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: error } });
|
||||
}
|
||||
@@ -518,7 +542,7 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
app.get('/api/config/skills/:name', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
const { directory, error } = await resolveSkillsDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
@@ -546,7 +570,7 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
if (isUnsafeSkillRelativePath(filePath)) {
|
||||
return res.status(400).json({ error: 'Invalid file path' });
|
||||
}
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
const { directory, error } = await resolveSkillsDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
@@ -579,7 +603,7 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
const { scope, source: skillSource, ...config } = req.body;
|
||||
const { directory, error } = scope === SKILL_SCOPE.PROJECT
|
||||
? await resolveProjectDirectory(req)
|
||||
: await resolveOptionalProjectDirectory(req);
|
||||
: await resolveSkillsDirectory(req);
|
||||
if (error || (scope === SKILL_SCOPE.PROJECT && !directory)) {
|
||||
return res.status(400).json({ error: error || 'Project skill creation requires a directory' });
|
||||
}
|
||||
@@ -606,7 +630,7 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const updates = req.body;
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
const { directory, error } = await resolveSkillsDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
@@ -637,7 +661,7 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
return res.status(400).json({ error: 'Invalid file path' });
|
||||
}
|
||||
const { content } = req.body;
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
const { directory, error } = await resolveSkillsDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
@@ -671,7 +695,7 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
if (isUnsafeSkillRelativePath(filePath)) {
|
||||
return res.status(400).json({ error: 'Invalid file path' });
|
||||
}
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
const { directory, error } = await resolveSkillsDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
@@ -701,7 +725,7 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
app.delete('/api/config/skills/:name', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
const { directory, error } = await resolveSkillsDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import express from 'express';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { registerSkillRoutes } from './skill-routes.js';
|
||||
import {
|
||||
createSkill,
|
||||
deleteSkill,
|
||||
discoverSkills,
|
||||
getSkillSources,
|
||||
mergeDiscoveredSkills,
|
||||
updateSkill,
|
||||
} from './skills.js';
|
||||
import {
|
||||
SKILL_DIR,
|
||||
SKILL_SCOPE,
|
||||
deleteSkillSupportingFile,
|
||||
readSkillSupportingFile,
|
||||
writeSkillSupportingFile,
|
||||
} from './shared.js';
|
||||
|
||||
const createTempProject = () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-skill-routes-'));
|
||||
fs.mkdirSync(path.join(projectRoot, '.git'));
|
||||
return projectRoot;
|
||||
};
|
||||
|
||||
const startSkillsApp = ({ projectRoot }) => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
registerSkillRoutes(app, {
|
||||
fs,
|
||||
path,
|
||||
os,
|
||||
resolveProjectDirectory: async () => ({ directory: projectRoot, error: null }),
|
||||
resolveOptionalProjectDirectory: async (req) => {
|
||||
const queryDirectory = Array.isArray(req.query?.directory)
|
||||
? req.query.directory[0]
|
||||
: req.query?.directory;
|
||||
if (!queryDirectory) {
|
||||
return { directory: null, error: null };
|
||||
}
|
||||
return { directory: String(queryDirectory), error: null };
|
||||
},
|
||||
readSettingsFromDisk: async () => ({}),
|
||||
sanitizeSkillCatalogs: (value) => value,
|
||||
isUnsafeSkillRelativePath: () => false,
|
||||
refreshOpenCodeAfterConfigChange: async () => {},
|
||||
clientReloadDelayMs: 0,
|
||||
buildOpenCodeUrl: () => 'http://127.0.0.1:9/',
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
getOpenCodePort: () => 0,
|
||||
getSkillSources,
|
||||
discoverSkills,
|
||||
mergeDiscoveredSkills,
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
readSkillSupportingFile,
|
||||
writeSkillSupportingFile,
|
||||
deleteSkillSupportingFile,
|
||||
SKILL_SCOPE,
|
||||
SKILL_DIR,
|
||||
getCuratedSkillsSources: () => [],
|
||||
getCacheKey: () => 'k',
|
||||
getCachedScan: () => null,
|
||||
setCachedScan: () => {},
|
||||
parseSkillRepoSource: () => ({ ok: false }),
|
||||
scanSkillsRepository: async () => ({ ok: false }),
|
||||
installSkillsFromRepository: async () => ({ ok: false }),
|
||||
scanClawdHubPage: async () => ({ ok: false }),
|
||||
installSkillsFromClawdHub: async () => ({ ok: false }),
|
||||
isClawdHubSource: () => false,
|
||||
getProfiles: () => [],
|
||||
getProfile: () => null,
|
||||
});
|
||||
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
close: () => new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
describe('skill-routes directory soft fallback', () => {
|
||||
/** @type {string | null} */
|
||||
let projectRoot = null;
|
||||
/** @type {{ close: () => Promise<void> } | null} */
|
||||
let appHandle = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (appHandle) {
|
||||
await appHandle.close();
|
||||
appHandle = null;
|
||||
}
|
||||
if (projectRoot) {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true });
|
||||
projectRoot = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('lists repository-local .agents skills after create even when list omits directory', async () => {
|
||||
projectRoot = createTempProject();
|
||||
appHandle = startSkillsApp({ projectRoot });
|
||||
|
||||
const createResponse = await fetch(`${appHandle.baseUrl}/api/config/skills/repo-local-skill`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
description: 'Created without list directory',
|
||||
instructions: 'Do the thing.',
|
||||
scope: 'project',
|
||||
source: 'agents',
|
||||
}),
|
||||
});
|
||||
expect(createResponse.status).toBe(200);
|
||||
expect(fs.existsSync(path.join(projectRoot, '.agents', 'skills', 'repo-local-skill', 'SKILL.md'))).toBe(true);
|
||||
|
||||
const listResponse = await fetch(`${appHandle.baseUrl}/api/config/skills`);
|
||||
expect(listResponse.status).toBe(200);
|
||||
const payload = await listResponse.json();
|
||||
expect(payload.skills.map((skill) => skill.name)).toContain('repo-local-skill');
|
||||
const skill = payload.skills.find((entry) => entry.name === 'repo-local-skill');
|
||||
expect(skill.scope).toBe('project');
|
||||
expect(skill.source).toBe('agents');
|
||||
});
|
||||
|
||||
it('lists manually created repository-local .agents skills via active-project fallback', async () => {
|
||||
projectRoot = createTempProject();
|
||||
const skillDir = path.join(projectRoot, '.agents', 'skills', 'manual-repo-skill');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, 'SKILL.md'),
|
||||
[
|
||||
'---',
|
||||
'name: manual-repo-skill',
|
||||
'description: Manual repository skill',
|
||||
'---',
|
||||
'',
|
||||
'Instructions',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
appHandle = startSkillsApp({ projectRoot });
|
||||
const listResponse = await fetch(`${appHandle.baseUrl}/api/config/skills`);
|
||||
expect(listResponse.status).toBe(200);
|
||||
const payload = await listResponse.json();
|
||||
expect(payload.skills.map((skill) => skill.name)).toContain('manual-repo-skill');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import fsPromises from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { getSkillSources, mergeDiscoveredSkills } from './skills.js';
|
||||
import { discoverSkills, getSkillSources, mergeDiscoveredSkills } from './skills.js';
|
||||
|
||||
describe('skills', () => {
|
||||
it('merges locally discovered skills missing from OpenCode live discovery', () => {
|
||||
@@ -24,6 +24,43 @@ describe('skills', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('discovers repository-local .agents skills for the project directory', async () => {
|
||||
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-project-agents-'));
|
||||
const skillDir = path.join(tempRoot, '.agents', 'skills', 'repo-local-skill');
|
||||
const skillPath = path.join(skillDir, 'SKILL.md');
|
||||
|
||||
try {
|
||||
await fsPromises.mkdir(skillDir, { recursive: true });
|
||||
await fsPromises.mkdir(path.join(tempRoot, '.git'));
|
||||
await fsPromises.writeFile(
|
||||
skillPath,
|
||||
[
|
||||
'---',
|
||||
'name: repo-local-skill',
|
||||
'description: Repository-local agents skill',
|
||||
'---',
|
||||
'',
|
||||
'Use this skill in this repository.',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const discovered = discoverSkills(tempRoot);
|
||||
const match = discovered.find((skill) => skill.name === 'repo-local-skill');
|
||||
|
||||
expect(match).toEqual({
|
||||
name: 'repo-local-skill',
|
||||
path: skillPath,
|
||||
scope: 'project',
|
||||
source: 'agents',
|
||||
description: 'Repository-local agents skill',
|
||||
});
|
||||
} finally {
|
||||
await fsPromises.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves built-in OpenCode skill content without parsing virtual locations as files', () => {
|
||||
const sources = getSkillSources(
|
||||
'customize-opencode',
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
|
||||
- `attach` registers a connection for one terminal. One socket may attach to many terminals.
|
||||
- Every attach and reconnect begins with an authoritative `snapshot` containing bounded history and the current sequence.
|
||||
- A current socket that closes or errors before its initial `open` invalidates its URL-scoped auth token before retrying, so retries mint a fresh token instead of backing off against a rejected upgrade. Hidden or offline clients wait 60 seconds and wake promptly on visibility/online recovery.
|
||||
- `output`, `exit`, and `restarted` carry monotonically increasing per-terminal sequences. Output carries raw live bytes plus replay-safe bytes with terminal query exchanges removed.
|
||||
- Attach registers before capturing the snapshot, buffers concurrent events, drops events represented by the snapshot sequence, then enters live delivery.
|
||||
- `write` always includes the terminal ID; sockets never have mutable single-terminal binding state.
|
||||
- `detach` removes only that attachment.
|
||||
- Creation carries the active UI appearance. The PTY sets `COLORFGBG` and answers OSC 10, OSC 11, and Mode 2031 queries immediately, including queries emitted before a WebSocket attachment exists. Subscribed TUIs receive a Mode 2031 notification when the appearance changes.
|
||||
- Creation carries the active UI appearance. The PTY sets `COLORFGBG` and answers OSC 10, OSC 11, Mode 2031, and primary-device-attribute queries immediately, including queries emitted before a WebSocket attachment exists. The DA1 fallback prevents Fish from waiting ten seconds for a renderer that cannot observe or answer its startup query. Subscribed TUIs receive a Mode 2031 notification when the appearance changes.
|
||||
|
||||
HTTP remains the authenticated command plane for create, resize, appearance updates, restart, close, and force-kill. There is no SSE output or HTTP input compatibility path.
|
||||
|
||||
@@ -23,6 +24,7 @@ HTTP remains the authenticated command plane for create, resize, appearance upda
|
||||
- IDs are client-provided or generated with `randomUUID()`.
|
||||
- Concurrent creates for one ID are single-flight only when working directory and shell preference match. Existing IDs cannot be reused for another working directory.
|
||||
- Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB.
|
||||
- A client may create before its renderer has mounted. It derives an initial size from the container and font metrics (falling back to 80x24 when unavailable), then sends a resize once Ghostty reports its final dimensions. This allows shell startup and renderer initialization to overlap.
|
||||
- PTY children explicitly clear `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup.
|
||||
- PTY children also strip AppImage `ARGV0` (and other host-private shell vars such as `ELECTRON_RUN_AS_NODE`, `BASH_ENV`, `ENV`, `BASH_XTRACEFD`). An exported `ARGV0` makes zsh rewrite argv[0] for every external command, which breaks Python venv detection and other argv[0]/$0 consumers while leaving `/proc/self/exe` correct. On Linux, PTY spawn is wrapped with `env -u ARGV0` because `bun-pty` merges the native OS environ and would otherwise reintroduce `ARGV0` after a JS-only delete.
|
||||
- `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Preference changes affect new sessions and explicit restarts, not running PTYs.
|
||||
|
||||
@@ -150,7 +150,7 @@ export function createTerminalRuntime({
|
||||
background: session.terminalBackground,
|
||||
foreground: session.terminalForeground,
|
||||
modeEnabled: session.themeModeEnabled,
|
||||
});
|
||||
}, { respondToPrimaryDeviceAttributes: true });
|
||||
session.pendingThemeControlSequence = theme.pending;
|
||||
session.themeModeEnabled = theme.modeEnabled;
|
||||
for (const response of theme.responses) session.process?.write(response);
|
||||
@@ -336,7 +336,7 @@ export function createTerminalRuntime({
|
||||
session.process = spawned.process; session.backend = spawned.backend; session.shell = spawned.shell; session.loginShell = spawned.loginShell; session.cwd = cwd; session.cols = cols; session.rows = rows;
|
||||
session.history = ''; session.pendingHistoryControlSequence = ''; session.pendingThemeControlSequence = ''; session.themeModeEnabled = false; session.status = 'running'; session.exitCode = null; session.signal = null; session.eventQueue.length = 0;
|
||||
session.themeMode = themeMode === 'light' ? 'light' : 'dark'; session.terminalBackground = terminalBackground; session.terminalForeground = terminalForeground;
|
||||
wire(session, spawned.process); void terminateProcess(oldProcess); publish(session, { t: 'restarted', history: '' });
|
||||
wire(session, spawned.process); void terminateProcess(oldProcess); publish(session, { t: 'restarted', history: '' });
|
||||
});
|
||||
pendingSessionRestarts.set(session.id, restart);
|
||||
try {
|
||||
|
||||
@@ -160,8 +160,8 @@ describe('terminal runtime', () => {
|
||||
expect(harness.processes[0].shell).toMatch(/\/env$/);
|
||||
expect(harness.processes[0].args.slice(0, 3)).toEqual(['-u', 'ARGV0', expect.any(String)]);
|
||||
}
|
||||
harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007');
|
||||
expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\']);
|
||||
harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007\u001b[0c');
|
||||
expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\', '\u001b[?1;2c']);
|
||||
|
||||
const appearance = createResponse();
|
||||
harness.routes.post.get('/api/terminal/:sessionId/appearance')({ params: { sessionId: 'term-1' }, body: { themeMode: 'dark' } }, appearance);
|
||||
|
||||
@@ -2,11 +2,21 @@ const MODE_SET = '\u001b[?2031h';
|
||||
const MODE_RESET = '\u001b[?2031l';
|
||||
const CAPABILITY_QUERY = '\u001b[?2031$p';
|
||||
const MODE_QUERIES = ['\u001b[?996n', '\u001b[?997n'];
|
||||
// Fish asks this before an unattached browser terminal can reply.
|
||||
const PRIMARY_DEVICE_ATTRIBUTE_QUERIES = ['\u001b[c', '\u001b[0c'];
|
||||
const PRIMARY_DEVICE_ATTRIBUTE_RESPONSE = '\u001b[?1;2c';
|
||||
const OSC_QUERIES = [10, 11].flatMap((code) => [
|
||||
{ sequence: `\u001b]${code};?\u0007`, code },
|
||||
{ sequence: `\u001b]${code};?\u001b\\`, code },
|
||||
]);
|
||||
const CONTROL_SEQUENCES = [MODE_SET, MODE_RESET, CAPABILITY_QUERY, ...MODE_QUERIES, ...OSC_QUERIES.map(({ sequence }) => sequence)];
|
||||
const CONTROL_SEQUENCES = [
|
||||
MODE_SET,
|
||||
MODE_RESET,
|
||||
CAPABILITY_QUERY,
|
||||
...MODE_QUERIES,
|
||||
...PRIMARY_DEVICE_ATTRIBUTE_QUERIES,
|
||||
...OSC_QUERIES.map(({ sequence }) => sequence),
|
||||
];
|
||||
|
||||
const parseColor = (value) => {
|
||||
if (typeof value !== 'string') return null;
|
||||
@@ -28,7 +38,12 @@ const colorReport = (code, color) => {
|
||||
|
||||
export const terminalThemeModeReport = (themeMode) => `\u001b[?997;${themeMode === 'light' ? 2 : 1}n`;
|
||||
|
||||
export const consumeTerminalThemeQueries = (pending, data, appearance) => {
|
||||
export const consumeTerminalThemeQueries = (
|
||||
pending,
|
||||
data,
|
||||
appearance,
|
||||
{ respondToPrimaryDeviceAttributes = false } = {},
|
||||
) => {
|
||||
if (!pending && !data.includes('\u001b')) return { pending: '', responses: [], modeEnabled: appearance.modeEnabled === true };
|
||||
const input = `${pending}${data}`;
|
||||
const responses = [];
|
||||
@@ -56,6 +71,15 @@ export const consumeTerminalThemeQueries = (pending, data, appearance) => {
|
||||
index += modeQuery.length - 1;
|
||||
continue;
|
||||
}
|
||||
const primaryDeviceAttributeQuery = PRIMARY_DEVICE_ATTRIBUTE_QUERIES.find((query) => input.startsWith(query, index));
|
||||
if (primaryDeviceAttributeQuery && respondToPrimaryDeviceAttributes) {
|
||||
// A shell can ask before any browser terminal is attached. Answer with a
|
||||
// conservative VT100 DA1 response so Fish does not block startup for its
|
||||
// ten-second query timeout while waiting for a renderer that cannot see it.
|
||||
responses.push(PRIMARY_DEVICE_ATTRIBUTE_RESPONSE);
|
||||
index += primaryDeviceAttributeQuery.length - 1;
|
||||
continue;
|
||||
}
|
||||
const oscQuery = OSC_QUERIES.find(({ sequence }) => input.startsWith(sequence, index));
|
||||
if (oscQuery) {
|
||||
const response = colorReport(oscQuery.code, oscQuery.code === 10 ? appearance.foreground : appearance.background);
|
||||
|
||||
@@ -44,4 +44,27 @@ describe('terminal theme responses', () => {
|
||||
'\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\',
|
||||
]);
|
||||
});
|
||||
|
||||
test('answers a primary device attribute query when the fallback is enabled', () => {
|
||||
const attached = consumeTerminalThemeQueries('', '\u001b[0c', lightAppearance);
|
||||
const unattached = consumeTerminalThemeQueries('', '\u001b[0c', lightAppearance, {
|
||||
respondToPrimaryDeviceAttributes: true,
|
||||
});
|
||||
|
||||
expect(attached.responses).toEqual([]);
|
||||
expect(unattached.responses).toEqual(['\u001b[?1;2c']);
|
||||
});
|
||||
|
||||
test('answers a primary device attribute query split across PTY chunks', () => {
|
||||
const first = consumeTerminalThemeQueries('', '\u001b[0', lightAppearance, {
|
||||
respondToPrimaryDeviceAttributes: true,
|
||||
});
|
||||
const second = consumeTerminalThemeQueries(first.pending, 'c', {
|
||||
...lightAppearance,
|
||||
modeEnabled: first.modeEnabled,
|
||||
}, { respondToPrimaryDeviceAttributes: true });
|
||||
|
||||
expect(first.pending).toBe('\u001b[0');
|
||||
expect(second.responses).toEqual(['\u001b[?1;2c']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user