diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f5157df..b3d67b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,10 @@ Issues and pull requests are welcome. Before opening one: machine, and nothing it presses reaches the server. Screenshots go to `/tmp/t3-console-screens`; `--routes`, `--sizes`, `--themes` and `--scenarios` narrow a run. Attach the pages you changed to the pull request. + `node scripts/readme-media.js` rebuilds the README's picture + (`docs/media/setup-{light,dark}.png`) and the Ports guide's + (`docs/media/ports-panel.png`) from the same data; run it when a change + shows on Overview, Agents or Ports. `node scripts/ui-audit.js http://127.0.0.1:23780/__setup/ dev` measures the same pages for clipped text, uneven buttons and misaligned glyphs. `node scripts/storage-ui-audit.js http://127.0.0.1:23780/__setup/ dev` diff --git a/docker/setup/app.js b/docker/setup/app.js index 1531be2..ea8dcfd 100644 --- a/docker/setup/app.js +++ b/docker/setup/app.js @@ -92,6 +92,8 @@ portsAt: 0, providers: null, route: 'overview', + // Instructions & skills' open tab, from the address: #instructions/skills. + contextTab: 'instructions', toolFilter: '', }; // What the page knows that /status cannot: see the header comment. @@ -204,7 +206,7 @@ loadProvidersOnce(); tellAttention(); checkHere(); - if (state.route === 'instructions') skillsUI.activate(); + if (state.route === 'instructions' && state.contextTab === 'skills') skillsUI.activate(); } else if (!locked) { // Name the URL and the reason. "Could not read status" sent someone // hunting a migration bug when the page was calling the wrong path. @@ -532,7 +534,8 @@ // ------------------------------------------------------------- markup -- // An agent shows its own mark; anything without one, its monogram. - const tile = (row, size) => row.mark && T3C.agentMarks[row.mark] + const tile = (row, size) => row.icon && !row.mark ? html`` + : row.mark && T3C.agentMarks[row.mark] ? html`` : html``; const dot = (tone, extra) => html``; @@ -1559,28 +1562,55 @@ // Metadata is polled; the editor loads instruction text on demand. const contextEditor = { loading: false, layer: null, content: '', saving: false, error: null, provider: null, revision: null, path: '', readError: false }; const contextSwitching = new Set(); - const contextProviderRow = (row) => html` -
+ /** + * What one agent reads at the start of a session, and the choice of it: + * the shared AGENTS.md, a file of its own, or nothing. Each choice says + * itself, so the row needs no explaining. + */ + const contextProviderRow = (row) => { + const busy = contextSwitching.has(row.id); + const shared = M.contextView(state.status).present; + const options = [ + ['shared', 'Shared AGENTS.md', !shared && row.mode !== 'shared'], + ['separate', 'Its own ' + row.ownFile, false], + ['none', 'No instructions', false], + ]; + return html` +
${tile(row)}
${row.name}${badge(row.badge)}
- ${statusLine(row.status)} -
- ${[['shared', 'Shared'], ['separate', 'Separate'], ['none', 'None']].map(([mode, label]) => html``)} -
+ ${row.error ? statusLine(row.status) : row.note ? html`${row.note}` : ''} +
+
+ ${row.mode && !row.error ? html`` : ''} + ${menuSlot()}
-
`; + }; - const instructionsPage = (s) => html` - ${pageHead('instructions')} - ${pageNotices('instructions')} - ${section('cx-agentsmd', 'Instructions', html`
-
${resourceRow(M.contextAgentsMdRow(s))}${M.contextProviderRows(s).map(contextProviderRow)}
-
${icon('info', 'tc-icon--sm')}Shared uses the global file. Separate restores the provider's previous file, or starts from the global file. Start a new agent session after saving.
-
`)} - ${M.contextView(s).error ? notice('danger', 'circle-alert', 'Could not read instructions', M.contextView(s).error) : ''} - ${section('cx-skills', 'Skills', skillsUI.content(), { actions: skillsUI.actions() })}`; + const CONTEXT_TABS = [['instructions', 'Instructions'], ['skills', 'Skills']]; + /** Instructions and skills: two tabs on one page, each at an address of its own. */ + const instructionsPage = (s) => { + const tab = state.contextTab; + const rows = M.contextProviderRows(s); + const view = M.contextView(s); + const panel = tab === 'skills' + ? skillsUI.content() + : html` + ${view.error ? notice('danger', 'circle-alert', 'Couldn’t read the instructions', view.error) : ''} + ${section('cx-agentsmd', 'Shared instructions', html`
${resourceRow(M.contextAgentsMdRow(s))}
`)} + ${section('cx-agents', 'What each agent reads', html`
+ ${rows.length ? html`
${rows.map(contextProviderRow)}
` + : html`
No agent that reads global instructions is installed.
`} +
${icon('info', 'tc-icon--sm')}Agents read these when a session starts. Start a new one after you save.
+
`)}`; + return html` + ${pageHead('instructions')} +
${CONTEXT_TABS.map(([value, label]) => html``)}
+ ${pageNotices('instructions')} +
${panel}
`; + }; const downloadMarkdown = (filename, content) => { const url = URL.createObjectURL(new Blob([content], { type: 'text/markdown;charset=utf-8' })); @@ -1603,8 +1633,9 @@ if (contextEditor.layer) return; Object.assign(contextEditor, { content: '', original: '', loading: true, saving: false, error: null, provider, revision: null, path: '', readError: false }); const providerName = { claude: 'Claude Code', codex: 'Codex', opencode: 'OpenCode' }[provider]; - const title = provider ? providerName + ' instructions' : 'Global AGENTS.md'; const filename = provider === 'claude' ? 'CLAUDE.md' : 'AGENTS.md'; + const title = provider ? providerName + '’s ' + filename : 'Shared AGENTS.md'; + const readers = M.contextAgentsMdRow(state.status).readers; const isDirty = () => !contextEditor.loading && !contextEditor.readError && contextEditor.content !== contextEditor.original; const editorMeta = () => contextEditor.loading ? 'Loading…' : M.formatBytes(new TextEncoder().encode(contextEditor.content).length) + ' · ' + (isDirty() ? 'Unsaved changes' : contextEditor.revision ? 'Saved' : 'New file'); @@ -1614,13 +1645,13 @@ returnTo: trigger, backdrop: 'ignore', render: () => html` -
${provider ? tile({ mark: provider }, 'lg') : html``}

${title}

${provider ? filename + ' for this provider' : 'One file for providers set to Shared'}
+
${provider ? tile({ mark: provider }, 'lg') : html``}

${title}

${provider ? 'Only ' + providerName + ' reads this file' : readers.length ? 'Read by ' + M.listOf(readers) : 'For every agent set to read it'}
${contextEditor.error ? notice('danger', 'circle-alert', 'Could not load or save instructions', contextEditor.error) : ''} ${provider && contextEditor.path ? html`${contextEditor.path}` : ''}
- -
${editorMeta()}${provider ? 'Saving updates this file wherever it is used. Symlinks stay in place.' : 'Separate provider files are kept.'} Ctrl/⌘ S to save.
+ +
${editorMeta()}${provider ? 'Saving updates this file wherever it is used. Symlinks stay in place.' : 'Agents with a file of their own keep it.'} Ctrl/⌘ S to save.
`, focus: '#cx-input', @@ -1684,7 +1715,7 @@ if (!save.ok) { contextEditor.error = save.error; contextEditor.layer.render(); return; } contextEditor.layer.close('saved'); const skipped = (save.data?.reconcile?.skipped || []).filter((target) => !target.preserved); - Kit.toast(provider ? 'Saved ' + providerName + ' instructions' : 'Saved global AGENTS.md', skipped.length ? { tone: 'warn', detail: skipped.map((t) => t.provider + ': ' + t.reason).join('; ') } : undefined); + Kit.toast(provider ? 'Saved ' + providerName + '’s ' + filename : 'Saved the shared AGENTS.md', skipped.length ? { tone: 'warn', detail: skipped.map((t) => t.provider + ': ' + t.reason).join('; ') } : undefined); loadStatus(); } }); @@ -1771,9 +1802,10 @@ // #agents, or an address a page had before it moved (#devices once it is // Access), which is rewritten in place so the address bar says where it is. const routeFromHash = () => { - const name = location.hash.replace(/^#\/?/, '').split(/[/?]/)[0]; + const [name, sub] = location.hash.replace(/^#\/?/, '').split('?')[0].split('/'); const route = T3Routes.resolve(name); - if (route && route !== name) history.replaceState(null, '', '#' + route); + if (route && route !== name) history.replaceState(null, '', '#' + route + (sub ? '/' + sub : '')); + if (route === 'instructions') state.contextTab = sub === 'skills' ? 'skills' : 'instructions'; return route && PAGES[route] ? route : 'overview'; }; const go = (route) => { @@ -1800,7 +1832,7 @@ } first = false; if (route === 'ports' && !state.ports) loadPorts(); - if (route === 'instructions' && state.status) skillsUI.activate(); + if (route === 'instructions' && state.contextTab === 'skills' && state.status) skillsUI.activate(); }; window.addEventListener('hashchange', () => show(routeFromHash(), keyboardNav)); let keyboardNav = false; @@ -3601,13 +3633,17 @@ 'device.rename': (a, el) => openDeviceRename(a.id, el), 'link.revoke': (a) => confirmRevoke('pairing', a.id), + 'context.tab': (a) => { + const hash = '#instructions' + (a.value === 'skills' ? '/skills' : ''); + if (location.hash !== hash) location.hash = hash; + }, 'context.edit': (a, el) => openContextEditor(el), 'context.delete': async () => { const view = M.contextView(state.status); if (!view.present) return; const confirmed = await Kit.confirm({ - title: 'Delete global AGENTS.md?', - body: 'Removes the shared instructions file. Providers using Shared will have no instructions until you save new shared instructions or switch them to Separate.', + title: 'Delete the shared AGENTS.md?', + body: 'Agents set to read it read nothing until you write it again, or give them a file of their own.', confirm: 'Delete', }); if (!confirmed) return; @@ -3615,7 +3651,7 @@ if (!current.ok) { Kit.toast('Could not delete instructions', { tone: 'danger', detail: current.error }); return; } const result = await api('/context/agents-md', { body: { delete: true, revision: current.data.revision } }); if (!result.ok) { Kit.toast('Could not delete instructions', { tone: 'danger', detail: result.error }); return; } - Kit.toast('Deleted global AGENTS.md'); + Kit.toast('Deleted the shared AGENTS.md'); await loadStatus(); }, 'context.provider.edit': (a, el) => { @@ -3624,36 +3660,42 @@ }, 'context.provider.mode': async (a, el) => { const row = M.contextProviderRows(state.status).find((entry) => entry.id === a.id); - if (!row || (row.mode || 'none') === a.mode || contextSwitching.has(a.id)) return; + // A choice that does not happen leaves the menu showing what is in effect. + const settle = () => { + const current = M.contextProviderRows(state.status).find((entry) => entry.id === a.id); + if (el && el.tagName === 'SELECT' && el.isConnected) el.value = (current && current.mode) || 'none'; + }; + if (!row || (row.mode || 'none') === a.mode || contextSwitching.has(a.id)) { settle(); return; } contextSwitching.add(a.id); render(); try { if (a.mode === 'shared' && row.mode === 'separate') { const confirmed = await Kit.confirm({ - title: 'Use shared instructions for ' + row.name.replace(' instructions', '') + '?', - body: 'Your separate file will be backed up and restored if you switch back to Separate. Its current contents will not be merged into the shared file.', + title: row.name + ': read the shared AGENTS.md?', + body: 'Its own ' + row.ownFile + ' is backed up, and comes back if you choose it again. Its contents aren’t added to the shared file.', confirm: 'Use shared', tone: 'primary', }); if (!confirmed) return; } if (a.mode === 'none') { const confirmed = await Kit.confirm({ - title: 'Remove instructions for ' + row.name.replace(' instructions', '') + '?', + title: row.name + ': read no instructions?', body: row.mode === 'separate' - ? 'Your separate file will be backed up and restored if you switch back to Separate.' - : 'The shared link for this provider will be removed. Its separate backup, if any, is kept for a later Separate.', - confirm: 'Remove', + ? 'Its own ' + row.ownFile + ' is backed up, and comes back if you choose it again.' + : 'It stops reading the shared AGENTS.md. A file of its own backed up earlier is kept for later.', + confirm: 'Read nothing', }); if (!confirmed) return; } const result = await api('/context/agents-md', { body: { provider: a.id, mode: a.mode, sourceRevision: row.sourceRevision } }); - if (!result.ok) { Kit.toast('Could not switch instructions', { tone: 'danger', detail: result.error }); return; } - Kit.toast(a.mode === 'shared' ? 'Using shared instructions' : a.mode === 'none' ? 'No instructions for this provider' : 'Using separate instructions'); + if (!result.ok) { Kit.toast('Couldn’t change what ' + row.name + ' reads', { tone: 'danger', detail: result.error }); return; } + Kit.toast(row.name + (a.mode === 'shared' ? ' reads the shared AGENTS.md' : a.mode === 'none' ? ' reads no instructions' : ' reads its own ' + row.ownFile)); await loadStatus(); if (a.mode === 'separate') openContextEditor(el, a.id); } finally { contextSwitching.delete(a.id); render(); + settle(); } }, @@ -3713,10 +3755,24 @@ render(); }); document.addEventListener('change', (e) => { + if (e.target.hasAttribute('data-context-mode')) run('context.provider.mode', { id: e.target.getAttribute('data-context-mode'), mode: e.target.value }, e.target); if (e.target.id === 'skill-provider') skillsUI.select(e.target.value); if (e.target.hasAttribute('data-skill-policy')) skillsUI.setState(e.target.getAttribute('data-skill-policy'), e.target.value); }); + // A tab list moves with the arrow keys, and the tab under focus opens. + document.addEventListener('keydown', (e) => { + const tab = e.target.closest && e.target.closest('[role="tab"]'); + if (!tab || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return; + const tabs = [...tab.closest('[role="tablist"]').querySelectorAll('[role="tab"]')]; + const at = tabs.indexOf(tab); + const next = e.key === 'Home' ? tabs[0] : e.key === 'End' ? tabs[tabs.length - 1] + : tabs[(at + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length]; + e.preventDefault(); + next.focus(); + next.click(); + }); + // Navigation links keep their hrefs (middle-click, copy link); keyboard // navigation from them moves focus to the page. document.addEventListener('keydown', (e) => { diff --git a/docker/setup/client/model.js b/docker/setup/client/model.js index fd31d97..3828f03 100644 --- a/docker/setup/client/model.js +++ b/docker/setup/client/model.js @@ -1670,10 +1670,12 @@ const T3Model = (() => { for (const l of linkRows(s.pairings, null, now)) add('Access', 'Revoke link ' + l.name + '…', 'trash-2', { cmd: 'link.revoke', id: l.id }, { quiet: true }); if (!contextView(s).error) { - add('Instructions & skills', 'Edit global AGENTS.md…', 'pencil', { cmd: 'context.edit' }, { keywords: 'context instructions' }); - if (contextView(s).present) add('Instructions & skills', 'Delete global AGENTS.md…', 'trash-2', { cmd: 'context.delete' }, { keywords: 'context instructions' }); + add('Instructions & skills', 'Edit the shared AGENTS.md…', 'pencil', { cmd: 'context.edit' }, { keywords: 'context instructions global' }); + if (contextView(s).present) add('Instructions & skills', 'Delete the shared AGENTS.md…', 'trash-2', { cmd: 'context.delete' }, { keywords: 'context instructions global' }); + } + for (const row of contextProviderRows(s).filter((row) => row.mode === 'separate' && !row.error)) { + add('Instructions & skills', 'Edit ' + row.name + '’s ' + row.ownFile + '…', 'pencil', { cmd: 'context.provider.edit', id: row.id }, { keywords: 'context instructions' }); } - for (const row of contextProviderRows(s).filter((row) => row.mode && !row.error)) add('Instructions & skills', 'Edit ' + row.name + '…', 'pencil', { cmd: 'context.provider.edit', id: row.id }, { keywords: 'context' }); add('Access', 'Create pairing link', 'link', { cmd: 'pair.start' }, { shortcut: 'P' }); const updates = updatesView(s, ui, now); const runtime = updates.runtime; @@ -1822,47 +1824,71 @@ const T3Model = (() => { return { present: Boolean(file.present), bytes: file.bytes || 0, error: file.error || null }; }; + const PROVIDER_NAMES = { claude: 'Claude Code', codex: 'Codex', opencode: 'OpenCode' }; + // The file each agent reads on its own when it is not using the shared one. + const OWN_FILE = { claude: 'CLAUDE.md', codex: 'AGENTS.md', opencode: 'AGENTS.md' }; + + /** The shared AGENTS.md: how big it is, and which agents read it. */ const contextAgentsMdRow = (status) => { const view = contextView(status); + const readers = contextProviderRows(status).filter((row) => row.mode === 'shared').map((row) => row.name); return { id: 'global', key: 'agentsmd:global', target: 'context-agentsmd', - name: 'Global AGENTS.md', + name: 'AGENTS.md', description: '', mono: 'AG', hue: '--id-toolchain', - version: view.present && view.bytes ? view.bytes + ' B' : null, + version: null, state: view.error ? 'unavailable' : 'ok', - status: { text: view.error || (view.present ? 'Shared instructions; linked to agent homes where supported' : 'Not set yet. Edit to add shared instructions.') }, - badge: view.error ? { tone: 'warn', text: 'Unavailable' } : view.present ? { tone: 'ok', text: 'Set' } : null, - action: view.error ? null : { cmd: 'context.edit', label: 'Edit', icon: 'pencil' }, + icon: 'file-text', + status: view.error ? { dot: 'danger', text: view.error } + : !view.present ? { text: 'Not written yet' + (readers.length ? ' · ' + listOf(readers) + (readers.length === 1 ? ' reads' : ' read') + ' it once you do' : '') } + : { text: formatBytes(view.bytes) + ' · ' + (readers.length ? 'read by ' + listOf(readers) : 'no agent reads it yet') }, + badge: view.error ? { tone: 'warn', text: 'Unavailable' } : null, + action: view.error ? null : { cmd: 'context.edit', label: view.present ? 'Edit' : 'Write', icon: 'pencil' }, menu: view.present && !view.error ? [{ cmd: 'context.delete', label: 'Delete…', icon: 'trash-2', danger: true }] : [], attention: false, + readers, }; }; - // Only show the agents in use: installed harnesses, plus any provider that - // already has instructions (so an uninstalled agent's leftover file can still - // be edited or cleared). Without harness facts, show everything as before. + /** + * Each agent's instructions, said as what it reads: the shared AGENTS.md, a + * file of its own, or nothing. Only the agents in use are listed: installed + * ones, plus any that already has instructions (so an uninstalled agent's + * leftover file can still be edited or cleared). Without harness facts, + * every provider is listed. + */ const contextProviderRows = (status) => { const providers = status?.context?.providers || []; const harnesses = status?.harnesses; const installed = Array.isArray(harnesses) ? new Set(harnesses.filter((h) => h.installed).map((h) => h.id)) : null; const list = !installed || installed.size === 0 ? providers : providers.filter((file) => installed.has(file.provider) || file.mode || file.error); - return list.map((file) => ({ - id: file.provider, - key: 'agentsmd:' + file.provider, - name: ({ claude: 'Claude Code', codex: 'Codex', opencode: 'OpenCode' }[file.provider] || file.provider) + ' instructions', - mark: file.provider, mono: 'AG', hue: '--id-toolchain', - status: file.error ? { dot: 'danger', text: file.error } - : file.mode === 'shared' ? { text: 'Uses the shared AGENTS.md' } - : file.mode === 'separate' ? { text: 'Separate file', code: file.path } - : { text: 'No instructions' }, - badge: file.error ? { tone: 'warn', text: 'Unavailable' } : null, - mode: file.mode || null, sourceRevision: file.sourceRevision, error: file.error || null, - })); + return list.map((file) => { + const name = PROVIDER_NAMES[file.provider] || file.provider; + const own = OWN_FILE[file.provider] || 'AGENTS.md'; + return { + id: file.provider, + key: 'agentsmd:' + file.provider, + name, + mark: file.provider, mono: 'AG', hue: '--id-toolchain', + ownFile: own, + status: file.error ? { dot: 'danger', text: file.error } + : file.mode === 'shared' ? { text: 'Reads the shared AGENTS.md' } + : file.mode === 'separate' ? { text: 'Reads its own ' + own } + : { text: 'Reads no global instructions' }, + // What the row says under its name, beside the choice that already + // says what it reads: where its own file is, or why it reads nothing. + note: file.error ? null : file.mode === 'separate' ? file.path || null + : file.mode === 'shared' && !(status?.context?.agentsMd || {}).present ? 'The shared file isn’t written yet' : null, + path: file.path || null, + badge: file.error ? { tone: 'warn', text: 'Unavailable' } : null, + mode: file.mode || null, sourceRevision: file.sourceRevision, error: file.error || null, + }; + }); }; // Automatic updates: the policy and last run from /status, as the Updates diff --git a/docker/setup/client/skills.js b/docker/setup/client/skills.js index c5fc509..9f9bbfe 100644 --- a/docker/setup/client/skills.js +++ b/docker/setup/client/skills.js @@ -2,6 +2,7 @@ const T3SkillsUI = (() => { 'use strict'; const { html, raw, icon } = Kit; + const M = T3Model; const providers = [['claude', 'Claude Code'], ['codex', 'Codex'], ['opencode', 'OpenCode'], ['cursor', 'Cursor'], ['grok', 'Grok Build'], ['muse', 'Muse Code'], ['antigravity', 'Antigravity']]; const stateOf = (skill) => skill.error ? { text: 'Unavailable', tone: 'warn' } : skill.stateAuthority === 'user-policy' ? { text: skill.stateLabel, tone: skill.state === 'off' || skill.state === 'deny' ? '' : 'ok' } @@ -27,32 +28,55 @@ const T3SkillsUI = (() => { else error = response.error; render(); }; - const coverageSummary = () => !inventory || loading ? 'Global skills' - : (inventory.skills?.length || 0) + (inventory.skills?.length === 1 ? ' skill' : ' skills') + ' · ' + (inventory.coverage === 'policy' ? 'User policy checked' : inventory.coverage === 'native' ? 'Provider checked' : 'Files only'); + const providerName = (id) => providers.find(([key]) => key === id)?.[1] || 'this agent'; + const coverageSummary = () => !inventory || loading ? '' + : M.plural(inventory.skills?.length || 0, 'skill'); + // What a skill's availability means for this agent, said once under the list. + const coverageText = () => !inventory ? 'Refresh after changing skills outside the console.' + : inventory.coverage === 'policy' ? 'These settings are ' + providerName(provider) + '’s own, for every project. A project’s settings can still turn a skill off. A change applies to new conversations.' + : inventory.coverage === 'native' && provider === 'codex' ? 'Codex reports which it loads. A change applies to new conversations.' + : 'Skill files found on disk. ' + providerName(provider) + ' doesn’t say here which it loads.'; + /** The one control a skill row has: a policy, a switch, or what it is. */ + const control = (skill) => { + if (skill.canSetState) { + const label = provider === 'claude' ? 'Visibility' : 'Permission'; + return html``; + } + if (skill.canToggle) { + const busy = changing?.id === skill.id && changing.provider === provider; + return html``; + } + const status = stateOf(skill); + return html`${status.text}`; + }; + /** A skill in a line: its name and what it is for, opening to the whole of it, and its control. */ + const row = (skill) => { + const flag = skill.error ? { tone: 'warn', text: 'Unavailable' } : skill.metadataError ? { tone: 'warn', text: 'Check its metadata' } : null; + return html`
+ + +
${control(skill)}
+
`; + }; const content = () => html`
-
${coverageSummary()}
-
+
${coverageSummary()}
${actions()}
+
${loading ? html`

Reading skills…

` : ''} - ${error ? html`` : ''} + ${error ? html`` : ''} ${!loading && !error && inventory ? html` -

${inventory.coverage === 'policy' ? 'Availability reflects this provider’s user policy.' : inventory.coverage === 'native' ? 'Availability checked by Codex in this environment.' : 'Files found on disk. This provider does not expose a verified availability check here.'}

- ${inventory.nativeError ? html`

${icon('triangle-alert')}Native checks unavailable: ${inventory.nativeError}

` : ''} + ${inventory.nativeError ? html`

${icon('triangle-alert')}${providerName(provider)} couldn’t be asked which skills it loads: ${inventory.nativeError}

` : ''} ${(inventory.warnings || []).map((warning) => html`

${icon('triangle-alert')}${warning}

`)} -
${(inventory.skills || []).map((skill) => { - const status = stateOf(skill); - const initials = skill.name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase() || 'SK'; - return html`
${skill.name}${status.text}
- ${skill.description ? html`

${skill.description}

` : ''} - ${skill.source}${skill.metadataError ? ' · Metadata needs attention' : ''}${skill.effectiveStateText ? ' · ' + skill.effectiveStateText : ''}
-
${skill.canSetState ? html`` : ''}${skill.canToggle ? html`` : ''}${skill.canEdit ? html`` : ''}
`; - })}
- ${!inventory.skills?.length ? html`
${inventory.warnings?.length ? 'No skills could be shown' : 'No skills found'}${inventory.warnings?.length ? 'Review the scan messages above.' : 'Nothing in the checked global folders for ' + (providers.find(([id]) => id === provider)?.[1] || 'this provider') + '. Add a skill here, or refresh after installing one elsewhere.'}${!inventory.warnings?.length && inventory.canCreate ? html`` : ''}
` : ''} - ${inventory.roots?.length ? html`
Checked ${inventory.roots.length} global ${inventory.roots.length === 1 ? 'folder' : 'folders'}${inventory.roots.map((root) => html`${root.path}`)}
` : ''} + ${inventory.skills?.length ? html`
${inventory.skills.map(row)}
` : ''} + ${!inventory.skills?.length ? html`
${inventory.warnings?.length ? 'No skills could be shown' : 'No skills yet'}${inventory.warnings?.length ? 'The messages above say why.' : 'Nothing in ' + providerName(provider) + '’s global skill folders. Add one here, or install one from a source.'}
` : ''} + ${inventory.roots?.length ? html`
Looked in ${M.plural(inventory.roots.length, 'folder')}${inventory.roots.map((root) => html`${root.path}`)}
` : ''} ` : ''}
-
${icon('info', 'tc-icon--sm')}${inventory?.skills?.length && inventory.coverage === 'policy' ? 'User policies apply to every installation with the same ' + (provider === 'claude' ? 'name' : 'native skill ID') + '. Project or managed settings can further restrict use; start a new conversation after changing a policy.' : inventory?.skills?.length && provider === 'codex' && inventory.coverage === 'native' ? 'Availability applies to Codex only. Start a new conversation after changing it; existing conversations may keep loaded instructions.' : 'Refresh after changing skills outside the console. Project/session settings and custom provider environments can change locations or availability.'}
+
${icon('info', 'tc-icon--sm')}${coverageText()}
`; - const actions = () => html`${inventory?.canCreate ? html`` : ''}`; + const actions = () => html`${inventory?.canCreate ? html`` : ''}`; const setState = async (id, state) => { const row = inventory?.skills?.find((skill) => skill.id === id); if ((!row?.canToggle && !row?.canSetState) || loading || changing) return; diff --git a/docker/setup/console.css b/docker/setup/console.css index 31eef41..9579950 100644 --- a/docker/setup/console.css +++ b/docker/setup/console.css @@ -657,16 +657,18 @@ html.tc-locked { overflow: hidden; } } .tc-skill-message { display: flex; gap: var(--space-2); margin: 0; padding: var(--space-3) var(--space-4); overflow-wrap: anywhere; } .tc-skill-message > .tc-icon { flex: none; margin-top: 1px; } -.tc-skill-description { margin: var(--space-1) 0; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } -.tc-skill-row .tc-row-name { overflow-wrap: anywhere; } -.tc-skill-row { grid-template-columns: auto minmax(0, 1fr) auto; } -.tc-skill-row .tc-row-actions { flex-wrap: wrap; max-width: 230px; justify-content: flex-end; } -.tc-skill-policy { display: flex; align-items: center; gap: var(--space-2); flex-basis: 100%; } -.tc-skill-policy .tc-input, .tc-skill-policy .tc-select { min-width: 0; } -.tc-skill-empty-action { grid-column: 2; justify-self: start; } -@media (max-width: 479.98px) { - .tc-skill-row { grid-template-columns: auto minmax(0, 1fr); } - .tc-skill-row .tc-row-actions { grid-column: 2 / -1; max-width: none; justify-content: flex-start; } +/* A skill in a line: its name and what it is for are one button that opens + the whole of it; its one control sits at the end. */ +.tc-skill-open { appearance: none; border: 0; background: none; padding: 0; margin: 0; font: inherit; color: inherit; text-align: left; cursor: pointer; } +.tc-skill-open:hover .tc-row-name { text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; } +.tc-skill-open:focus-visible { outline: 2px solid var(--focus); outline-offset: 4px; border-radius: var(--radius-sm); } +.tc-skill-open .tc-row-name { overflow-wrap: anywhere; } +.tc-skill-description { display: block; min-width: 0; font-size: 12px; line-height: 18px; color: var(--fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tc-skill-policy { width: auto; min-width: 116px; } +@media (max-width: 639.98px) { + .tc-skill-policy { min-width: 0; max-width: 46%; } + /* A phone's line holds a few words, so what a skill is for gets two. */ + .tc-skill-description { white-space: normal; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; } } .tc-skill-meta { display: grid; grid-template-columns: 104px minmax(0, 1fr); column-gap: var(--space-3); row-gap: var(--space-1); align-items: start; } .tc-skill-meta code { overflow-wrap: anywhere; white-space: pre-wrap; } @@ -678,10 +680,29 @@ html.tc-locked { overflow: hidden; } .tc-skill-meta { grid-template-columns: 84px minmax(0, 1fr); } } .tc-skills .tc-warning, .tc-skill-sheet .tc-warning { color: var(--warn-text); } -.tc-context-provider .tc-seg { margin-top: var(--space-2); width: fit-content; max-width: 100%; } -@media (max-width: 479.98px) { - .tc-context-provider .tc-seg button { padding: 0 6px; } +/* What each agent reads: the choices line up in a column at the end of the rows. */ +.tc-context-mode { width: 180px; } +@media (max-width: 639.98px) { + .tc-context-mode { width: auto; flex: 1 1 10rem; } } + +/* Tabs under a page's head: the page in two halves, each at its own address. */ +.tc-tabs { display: flex; gap: var(--space-6); padding: 0 var(--space-4); margin-top: calc(var(--space-3) - var(--_gap)); + border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); } +.tc-tab-button { position: relative; height: 36px; padding: 0; border: 0; background: none; color: var(--fg-muted); font: inherit; font-size: 14px; font-weight: 500; cursor: pointer; } +.tc-tab-button:hover { color: var(--fg); } +.tc-tab-button[aria-selected="true"] { color: var(--fg); } +.tc-tab-button[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; border-radius: 1px; background: var(--fg); } +.tc-tab-button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: var(--radius-sm); } +.tc-tabpanel { display: flex; flex-direction: column; gap: var(--_gap); min-width: 0; } +@media (max-width: 1023.98px) { + .tc-tabs { padding: 0 4px; } +} + +/* A select as small as the buttons beside it. On a phone it keeps 16px text, + which stops iOS zooming in when it opens. */ +.tc-select--sm { height: var(--control-sm); font-size: 13px; padding-left: 9px; } +.tc-root[data-density="touch"] .tc-select--sm { height: 32px; font-size: 16px; } @media (max-width: 1023.98px) { .tc-layer--center > .tc-context-sheet { width: 100%; max-height: 88dvh; animation: tc-rise-sheet 300ms cubic-bezier(0.32, 0.72, 0, 1); } .tc-context-sheet .tc-input--area { min-height: 32dvh; } @@ -706,5 +727,3 @@ html.tc-locked { overflow: hidden; } .tc-import-providers { grid-template-columns: repeat(3, minmax(0, 1fr)); } } .tc-skill-import .tc-sheet-foot { flex-wrap: wrap; } -.tc-section-head:has(> #cx-skills) { flex-wrap: wrap; } -.tc-section-head:has(> #cx-skills) .tc-section-actions { flex-wrap: wrap; max-width: 100%; } diff --git a/docs/context-management.md b/docs/context-management.md index ca12643..cbd6835 100644 --- a/docs/context-management.md +++ b/docs/context-management.md @@ -1,7 +1,8 @@ # Global agent instructions -**Setup → Context** edits shared global instructions and existing provider -instruction files. The editor is centered +**Setup → Instructions & skills** edits the shared AGENTS.md and each agent's +own instruction file, on its **Instructions** tab; skills are on its **Skills** +tab (`#instructions/skills`). The editor is centered on desktop and opens as a bottom sheet on phones. Uploading a Markdown file replaces the draft; only **Save** applies it. Downloads include unsaved edits. @@ -15,19 +16,22 @@ missing native files: | Codex | `~/.codex/AGENTS.md` | `CODEX_HOME` | | OpenCode | `~/.config/opencode/AGENTS.md` | `XDG_CONFIG_HOME` (config root) | -Each provider has a **Shared / Separate / None** selector: +The shared file's row says which agents read it. Under **What each agent +reads**, each agent has one choice of three: -- **Shared** uses the global file, so subsequent global edits reach that provider. - **Edit** opens the shared editor. Switching an existing separate file to Shared +- **Shared AGENTS.md** uses the global file, so subsequent global edits reach + that agent. **Edit** opens the shared editor. Switching an existing own file to it asks for confirmation and backs up its directory entry next to the native file, with the suffix `.t3-context-backup`. Existing backups are never overwritten. -- **Separate** uses the provider's own file. Switching back restores its previous +- **Its own** `CLAUDE.md` or `AGENTS.md` uses the agent's own file, with its path + under the agent's name. Switching back restores its previous file, including the original permissions or relative symlink. If there is no previous file, it starts with a copy of the shared instructions; before shared - instructions exist, it starts with a Markdown heading. The separate editor - opens after switching. Later shared edits leave the separate file alone. -- **None** removes the native link or parks a separate file in its backup slot. - Later global edits leave it disconnected; Separate restores the backup. + instructions exist, it starts with a Markdown heading. Its editor opens after + switching. Later shared edits leave its own file alone. +- **Nothing** removes the native link or parks its own file in its backup slot, + after asking. Later global edits leave it disconnected; choosing its own file + again restores the backup. Deleting the global file disconnects shared providers and keeps backups. Creating the global file again bootstraps missing native links. @@ -35,7 +39,8 @@ Source choices are inferred from the native files and links; there is no new manifest format. Provider IDs select fixed paths, so callers cannot supply an arbitrary file path. A stale source selection is refused. A failed switch rolls back the original entry when possible, and a preserved backup can be restored -with Separate after an interrupted switch. +by choosing the agent's own file after an interrupted switch. A cancelled or +failed change leaves the choice showing what is in effect. Provider edits load the file's current contents and follow symlinks to the real file, preserving the chain and file permissions. Other agents using the same @@ -74,7 +79,9 @@ links remain distinct installations. OpenCode's flat global Markdown files are included too. Environment overrides for Claude, Codex, XDG and Grok roots are honored. -**View** opens raw Markdown in a desktop dialog or phone sheet. Copy and +Each skill is one row: its name and description, and one control. Pressing the +name opens the skill, with its raw Markdown, where it lives, **Edit** and +**Remove**, in a desktop dialog or phone sheet. Copy and download preserve its text; skill Markdown is not rendered as HTML or executed. Metadata uses a pinned vendored YAML parser, including quoted names and folded descriptions. Parse problems, missing targets and oversized files stay visible. @@ -95,7 +102,7 @@ and runtime metadata, as described below. Cursor, Grok and Muse still show **Unverified** availability. Plugin inventories and configured extra locations are outside the basic known-folder inventory. -Inventory loads when Context opens or its provider changes. **Refresh skills** +Inventory loads when the Skills tab opens or its agent changes. **Refresh skills** rescans after external changes. Concurrent requests share a lookup and results are cached for 15 seconds; regular `/status` polling does not scan skills. Bodies load separately through authenticated, discovered-installation IDs. @@ -279,7 +286,7 @@ Custom paths require their own mounts. The rest of `.gemini` is left in place. ### Native Codex availability -**Enable** and **Disable** appear for user skills verified by the managed Codex +A switch in the row turns on or off user skills verified by the managed Codex inventory, including shared and package-tracked user skills. The console sends the selected installation's path to Codex's native `skills/config/write` method. It changes Codex availability only, leaves Markdown and other providers alone, @@ -303,7 +310,7 @@ can remain in existing conversations, and project/session settings may differ. ### Claude visibility and OpenCode permissions -Claude's **Visibility** menu preserves all four native `skillOverrides` states: +Claude's **Visibility** menu, in each skill's row, preserves all four native `skillOverrides` states: On, Name only, Manual only and Off. The console edits only the selected name in the resolved user `settings.json`, requires strict JSON, and preserves unrelated settings, file mode and symlinks. Claude silently ignores commented settings @@ -318,7 +325,7 @@ is actually listed; frontmatter and managed settings can still restrict use. Duplicate names share the same native override. Plugin policies stay with the plugin manager. -OpenCode's **Permission** menu offers Allowed, Ask before use and Denied. V1 uses +OpenCode's **Permission** menu, in the same place, offers Allowed, Ask before use and Denied. V1 uses `permission.skill` by skill name; V2 uses ordered `permissions` rules and native path-derived skill IDs. The editor preserves JSONC comments, unrelated policies and wildcard rules, and changes an exact rule or appends one after existing diff --git a/docs/media/ports-panel.png b/docs/media/ports-panel.png index 73fd767..9014d60 100644 Binary files a/docs/media/ports-panel.png and b/docs/media/ports-panel.png differ diff --git a/docs/media/setup-dark.png b/docs/media/setup-dark.png index a70ebe9..f68d9af 100644 Binary files a/docs/media/setup-dark.png and b/docs/media/setup-dark.png differ diff --git a/docs/media/setup-light.png b/docs/media/setup-light.png index 8b82a83..84997b6 100644 Binary files a/docs/media/setup-light.png and b/docs/media/setup-light.png differ diff --git a/scripts/console-screens.js b/scripts/console-screens.js index db8e86f..158a95c 100644 --- a/scripts/console-screens.js +++ b/scripts/console-screens.js @@ -43,17 +43,10 @@ const OUT = flag("out", process.env.T3_CONSOLE_SCREENS || "/tmp/t3-console-scree const list = (name, all) => (flag(name) ? flag(name).split(",").filter(Boolean) : all); const CHROME = process.env.CHROME_PATH || "/usr/bin/chromium"; -const FIXTURES = path.join(__dirname, "fixtures", "console"); -const fixture = (name) => JSON.parse(fs.readFileSync(path.join(FIXTURES, name), "utf8")); - -// Routes as the console names them, read from its own table. -const T3Routes = (() => { - const vm = require("node:vm"); - const context = vm.createContext({ URL }); - vm.runInContext(fs.readFileSync(path.join(__dirname, "..", "docker/setup/client/routes.js"), "utf8"), context); - return vm.runInContext("T3Routes", context); -})(); -const ROUTES = list("routes", [...T3Routes.PAGES.map((p) => p.route), "more"]); +const { routesTable, statusFor, portsFor, routeConsole, framed, unlock, visit } = require("./lib/console-fixtures.cjs"); +const T3Routes = routesTable(); +// Pages, and the second tab of the one that has tabs. +const ROUTES = list("routes", [...T3Routes.PAGES.map((p) => p.route), "instructions/skills", "more"]); const SIZES = { phone: { width: 390, height: 844, scale: 2 }, tablet: { width: 820, height: 1180, scale: 1 }, @@ -88,69 +81,6 @@ const OVERLAYS = [ }], ]; -// ------------------------------------------------------------ the data -- -/** Move every timestamp in a fixture by the same amount, so "2 hours ago" stays true today. */ -const shiftTimes = (value, by) => { - if (Array.isArray(value)) return value.map((v) => shiftTimes(v, by)); - if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, shiftTimes(v, by)])); - if (typeof value === "number" && value > 1.6e12 && value < 2.2e12) return value + by; - if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) return new Date(Date.parse(value) + by).toISOString(); - return value; -}; - -const harness = (s, id) => s.harnesses.find((h) => h.id === id); -const SCENARIOS = { - ready: (s) => { - s.paths.volumeKind = "durable"; - s.paths.volumeName = "t3-home"; - return s; - }, - setup: (s, now) => { - s.publicUrl = null; - s.publicUrlSource = null; - s.sessions = []; - s.events = []; - s.paths.volumeKind = "durable"; - s.connect = { ...s.connect, state: "off" }; - const installing = ["opencode", "go", "rust", "bun", "deno", "uv"]; - s.setup = { - state: "running", startedAt: now - 90_000, finishedAt: null, current: "opencode", - items: s.setup.items.map((item) => ({ ...item, state: item.id === "opencode" ? "installing" : installing.includes(item.id) ? "pending" : "done", - progress: item.id === "opencode" ? { phase: "downloading", done: 41_000_000, total: 96_000_000 } : undefined })), - }; - for (const h of s.harnesses) { - if (h.managedBy) continue; - if (h.id === "claude" || h.id === "codex") { h.signedIn = false; h.credentialsPresent = false; h.latestVersion = h.installedVersion; } - else Object.assign(h, { installed: false, runnable: false, version: null, installedVersion: null, signedIn: null, credentialsPresent: false }); - } - for (const t of s.toolchains) if (installing.includes(t.id)) Object.assign(t, { installed: false, version: null }); - s.packages = []; - return s; - }, - attention: (s) => { - Object.assign(harness(s, "codex"), { signedIn: false }); - Object.assign(s.toolchains.find((t) => t.id === "go"), { failed: true, failure: "Download timed out after 120 s", operation: "update" }); - s.storage.disk = { ...s.storage.disk, usedPercent: 86, availableBytes: 29_000_000_000 }; - s.legacyEnv = ["GOROOT", "PATH"]; - s.paths.volumeKind = "anonymous"; - s.paths.volumeName = "6c1f0e9a4b2d8c7e5f3a1b9d0e8c6a4f2b7d5e3c1a9f8e6d4c2b0a7f5e3d1c9b"; - return s; - }, -}; -const PORTS = { - ready: (p, now) => ({ ...p, tunnels: [{ port: 5173, state: "open", url: "https://quiet-river-1234.trycloudflare.com", startedAt: now - 12 * 60_000 }] }), - setup: (p) => ({ ...p, listening: [], details: [], tunnels: [] }), - attention: (p) => ({ ...p, tunnels: [{ port: 8787, state: "failed", error: "The tunnel could not reach Cloudflare’s edge" }] }), -}; - -const statusFor = (scenario) => { - const base = fixture("status.json"); - const now = Date.now(); - const shifted = shiftTimes(base, now - base.storage.at); - return SCENARIOS[scenario](shifted, now); -}; -const portsFor = (scenario) => PORTS[scenario](fixture("ports.json"), Date.now()); - // -------------------------------------------------------------- driving -- const overflow = () => { const out = []; @@ -158,17 +88,6 @@ const overflow = () => { return out; }; -/** The console in a frame on its own origin, as T3 Code's settings show it. */ -const framed = async (page, url) => { - const host = new URL("/__console-screens-host", url).href; - await page.route(host, (route) => route.fulfill({ - contentType: "text/html", - body: ``, - })); - await page.goto(host, { waitUntil: "domcontentloaded" }); - return (await page.waitForSelector("iframe")).contentFrame(); -}; - (async () => { const browser = await chromium.launch({ executablePath: CHROME, args: ["--no-sandbox"] }); const problems = []; @@ -180,37 +99,15 @@ const framed = async (page, url) => { const size = SIZES[sizeName]; const context = await browser.newContext({ viewport: { width: size.width, height: size.height }, deviceScaleFactor: size.scale, colorScheme: theme, reducedMotion: "reduce" }); const page = await context.newPage(); - await page.route("**/*", (route) => { - const request = route.request(); - const url = new URL(request.url()); - const at = (suffix) => url.pathname.endsWith(suffix); - if (request.method() === "POST" && !at("/login")) return route.fulfill({ json: { ok: true } }); - if (at("/status")) return route.fulfill({ json: statusFor(scenario) }); - if (at("/ports")) return route.fulfill({ json: portsFor(scenario) }); - if (at("/providers")) return route.fulfill({ json: fixture("providers.json") }); - if (at("/context/skills")) { - const provider = url.searchParams.get("provider") || "claude"; - const name = `skills-${provider}.json`; - return fs.existsSync(path.join(FIXTURES, name)) ? route.fulfill({ json: fixture(name) }) - : route.fulfill({ json: { ok: true, provider, coverage: "files", skills: [], roots: [] } }); - } - return route.continue(); - }); + await routeConsole(page, { status: () => statusFor(scenario), ports: () => portsFor(scenario) }); const url = new URL(BASE); if (size.embed) { url.searchParams.set("embed", "t3"); url.searchParams.set("theme", theme); } const target = size.embed ? await framed(page, url.href) : page; if (!size.embed) await page.goto(url.href, { waitUntil: "domcontentloaded" }); - await target.waitForSelector("input[type=password]"); - await target.fill("input[type=password]", KEY); - await target.click("button[type=submit]"); - await target.waitForSelector("#page-overview .tc-page-head", { timeout: 30000 }); + await unlock(target, KEY); for (const route of ROUTES) { - await target.evaluate((r) => { location.hash = r; window.scrollTo(0, 0); }, route); - await target.waitForSelector(`#page-${route}:not([hidden]) .tc-page-head`, { timeout: 30000 }); - await target.evaluate(() => document.fonts.ready); - // Inventories and the ports list arrive after the page; let them land. - await page.waitForTimeout(route === "context" ? 1200 : 400); - const file = path.join(dir, `${sizeName}-${theme}-${route}.png`); + await visit(target, page, route); + const file = path.join(dir, `${sizeName}-${theme}-${route.replace("/", "-")}.png`); if (size.embed) await page.screenshot({ path: file }); else await page.screenshot({ path: file, fullPage: true }); for (const issue of await target.evaluate(overflow)) problems.push(`${scenario} ${sizeName} ${theme} ${route}: ${issue}`); diff --git a/scripts/context-ui-audit.js b/scripts/context-ui-audit.js index 079e19b..bcba784 100644 --- a/scripts/context-ui-audit.js +++ b/scripts/context-ui-audit.js @@ -40,6 +40,7 @@ const assert = require('node:assert/strict'); await page.locator('[data-cmd="context.edit"]').waitFor(); assert.equal(await page.locator('html').getAttribute('data-theme'),theme); assert.equal(await page.locator('#page-instructions .tc-section').count(),2); + assert.equal(await page.getByRole('tab',{name:'Instructions',exact:true}).getAttribute('aria-selected'),'true'); assert.equal(await page.locator('[data-cmd="context.edit"]').innerText(),'Edit'); const checkCentered = async () => { const box = await page.getByRole('dialog').boundingBox(); @@ -148,30 +149,31 @@ const assert = require('node:assert/strict'); } assert.equal(providerSaves,4); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth),false); - // Per-provider source selection is explicit and updates after a successful save. - const sharedButton = page.locator('[data-cmd="context.provider.mode"][data-id="codex"][data-mode="shared"]'); - const separateButton = page.locator('[data-cmd="context.provider.mode"][data-id="codex"][data-mode="separate"]'); - await sharedButton.click(); + // What each agent reads is one choice, and it says what is in effect: a + // cancelled or failed change shows the old choice again. + const mode = page.locator('[data-context-mode="codex"]'); + assert.equal(await mode.inputValue(),'separate'); + await mode.selectOption('shared'); await page.getByRole('alertdialog').waitFor(); await page.getByRole('button',{name:'Cancel',exact:true}).click(); assert.equal(modeSaves,0); - assert.equal(await separateButton.getAttribute('aria-pressed'),'true'); - await sharedButton.click(); + assert.equal(await mode.inputValue(),'separate','a cancelled change shows the choice in effect'); + await mode.selectOption('shared'); await page.getByRole('button',{name:'Use shared',exact:true}).click(); - await page.waitForFunction(() => document.querySelector('[data-cmd="context.provider.mode"][data-id="codex"][data-mode="shared"]')?.getAttribute('aria-pressed') === 'true'); + await page.waitForFunction(() => document.querySelector('[data-context-mode="codex"]')?.value === 'shared' && !document.querySelector('[data-context-mode="codex"]').disabled); await page.locator('[data-cmd="context.provider.edit"][data-id="codex"]').click(); - await page.getByRole('heading',{name:'Global AGENTS.md',exact:true}).waitFor(); + await page.getByRole('heading',{name:'Shared AGENTS.md',exact:true}).waitFor(); await page.waitForFunction(() => document.querySelector('#cx-input')?.value === '# Shared fixture'); await page.getByRole('button',{name:'Cancel',exact:true}).click(); - await separateButton.click(); + await mode.selectOption('separate'); await page.waitForFunction(() => document.querySelector('#cx-input')?.value === '# Existing codex'); - assert.equal(await separateButton.getAttribute('aria-pressed'),'true'); + assert.equal(await mode.inputValue(),'separate'); await page.getByRole('button',{name:'Cancel',exact:true}).click(); failMode = true; - await sharedButton.click(); + await mode.selectOption('shared'); await page.getByRole('button',{name:'Use shared',exact:true}).click(); await page.getByText('Source changed while switching',{exact:true}).waitFor(); - assert.equal(await separateButton.getAttribute('aria-pressed'),'true'); + await page.waitForFunction(() => document.querySelector('[data-context-mode="codex"]')?.value === 'separate'); assert.equal(modeSaves,3); // Failed reads cannot turn into a save of blank content. await page.route('**/context/agents-md?provider=*', route => route.fulfill({status:400,json:{ok:false,error:'Instruction file unavailable'}})); @@ -191,8 +193,9 @@ const assert = require('node:assert/strict'); await page.reload(); await page.getByText(oversizedError,{exact:true}).first().waitFor(); assert.equal(await page.locator('[data-cmd="context.edit"]').count(),0); - assert.equal(await page.locator('[data-cmd="context.provider.edit"][data-id="codex"]').isDisabled(),true); - assert.equal(await separateButton.getAttribute('aria-pressed'),'true'); + assert.equal(await page.locator('[data-cmd="context.provider.edit"][data-id="codex"]').count(),0,'an unreadable file offers no editor'); + assert.equal(await mode.isDisabled(),true); + assert.equal(await mode.inputValue(),'separate'); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth),false); assert.deepEqual(errors,[]); console.log('PASS Context interactions:',theme,width+'x'+height,embedded?'embedded':'standalone'); diff --git a/scripts/lib/console-fixtures.cjs b/scripts/lib/console-fixtures.cjs new file mode 100644 index 0000000..75b774c --- /dev/null +++ b/scripts/lib/console-fixtures.cjs @@ -0,0 +1,139 @@ +// Sample data for photographing the setup console, shared by +// scripts/console-screens.js and scripts/readme-media.js. +// +// The console's own code renders every page; only its data is replaced: +// /status, /ports, /providers and the skills inventories come from +// scripts/fixtures/console, shifted to the present and changed per scenario, +// and every POST is answered here without reaching the server. +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const FIXTURES = path.join(__dirname, "..", "fixtures", "console"); +const fixture = (name) => JSON.parse(fs.readFileSync(path.join(FIXTURES, name), "utf8")); + +/** The console's own table of pages. */ +const routesTable = () => { + const context = vm.createContext({ URL }); + vm.runInContext(fs.readFileSync(path.join(__dirname, "..", "..", "docker/setup/client/routes.js"), "utf8"), context); + return vm.runInContext("T3Routes", context); +}; + +// ------------------------------------------------------------ the data -- +/** Move every timestamp in a fixture by the same amount, so "2 hours ago" stays true today. */ +const shiftTimes = (value, by) => { + if (Array.isArray(value)) return value.map((v) => shiftTimes(v, by)); + if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, shiftTimes(v, by)])); + if (typeof value === "number" && value > 1.6e12 && value < 2.2e12) return value + by; + if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) return new Date(Date.parse(value) + by).toISOString(); + return value; +}; + +const harness = (s, id) => s.harnesses.find((h) => h.id === id); +const SCENARIOS = { + ready: (s) => { + s.paths.volumeKind = "durable"; + s.paths.volumeName = "t3-home"; + return s; + }, + setup: (s, now) => { + s.publicUrl = null; + s.publicUrlSource = null; + s.sessions = []; + s.events = []; + s.paths.volumeKind = "durable"; + s.connect = { ...s.connect, state: "off" }; + const installing = ["opencode", "go", "rust", "bun", "deno", "uv"]; + s.setup = { + state: "running", startedAt: now - 90_000, finishedAt: null, current: "opencode", + items: s.setup.items.map((item) => ({ ...item, state: item.id === "opencode" ? "installing" : installing.includes(item.id) ? "pending" : "done", + progress: item.id === "opencode" ? { phase: "downloading", done: 41_000_000, total: 96_000_000 } : undefined })), + }; + for (const h of s.harnesses) { + if (h.managedBy) continue; + if (h.id === "claude" || h.id === "codex") { h.signedIn = false; h.credentialsPresent = false; h.latestVersion = h.installedVersion; } + else Object.assign(h, { installed: false, runnable: false, version: null, installedVersion: null, signedIn: null, credentialsPresent: false }); + } + for (const t of s.toolchains) if (installing.includes(t.id)) Object.assign(t, { installed: false, version: null }); + s.packages = []; + return s; + }, + attention: (s) => { + Object.assign(harness(s, "codex"), { signedIn: false }); + Object.assign(s.toolchains.find((t) => t.id === "go"), { failed: true, failure: "Download timed out after 120 s", operation: "update" }); + s.storage.disk = { ...s.storage.disk, usedPercent: 86, availableBytes: 29_000_000_000 }; + s.legacyEnv = ["GOROOT", "PATH"]; + s.paths.volumeKind = "anonymous"; + s.paths.volumeName = "6c1f0e9a4b2d8c7e5f3a1b9d0e8c6a4f2b7d5e3c1a9f8e6d4c2b0a7f5e3d1c9b"; + return s; + }, +}; +const PORTS = { + ready: (p, now) => ({ ...p, tunnels: [{ port: 5173, state: "open", url: "https://quiet-river-1234.trycloudflare.com", startedAt: now - 12 * 60_000 }] }), + setup: (p) => ({ ...p, listening: [], details: [], tunnels: [] }), + attention: (p) => ({ ...p, tunnels: [{ port: 8787, state: "failed", error: "The tunnel could not reach Cloudflare’s edge" }] }), +}; + +/** /status for a scenario, as of now; `change` adjusts it further. */ +const statusFor = (scenario, change) => { + const base = fixture("status.json"); + const now = Date.now(); + const status = SCENARIOS[scenario](shiftTimes(base, now - base.storage.at), now); + return change ? change(status, now) || status : status; +}; +const portsFor = (scenario, change) => { + const ports = PORTS[scenario](fixture("ports.json"), Date.now()); + return change ? change(ports, Date.now()) || ports : ports; +}; + +/** + * Answer the console's data requests on `page` from the fixtures: every POST + * is accepted here and none reaches the server. + */ +const routeConsole = async (page, { status, ports }) => { + await page.route("**/*", (route) => { + const request = route.request(); + const url = new URL(request.url()); + const at = (suffix) => url.pathname.endsWith(suffix); + if (request.method() === "POST" && !at("/login")) return route.fulfill({ json: { ok: true } }); + if (at("/status")) return route.fulfill({ json: status() }); + if (at("/ports")) return route.fulfill({ json: ports() }); + if (at("/providers")) return route.fulfill({ json: fixture("providers.json") }); + if (at("/context/skills")) { + const provider = url.searchParams.get("provider") || "claude"; + const name = `skills-${provider}.json`; + return fs.existsSync(path.join(FIXTURES, name)) ? route.fulfill({ json: fixture(name) }) + : route.fulfill({ json: { ok: true, provider, coverage: "files", skills: [], roots: [] } }); + } + return route.continue(); + }); +}; + +/** The console in a frame on its own origin, as T3 Code's settings show it. */ +const framed = async (page, url) => { + const host = new URL("/__console-screens-host", url).href; + await page.route(host, (route) => route.fulfill({ + contentType: "text/html", + body: ``, + })); + await page.goto(host, { waitUntil: "domcontentloaded" }); + return (await page.waitForSelector("iframe")).contentFrame(); +}; + +/** Unlock the console with its key and wait for the first page. */ +const unlock = async (target, key) => { + await target.waitForSelector("input[type=password]"); + await target.fill("input[type=password]", key); + await target.click("button[type=submit]"); + await target.waitForSelector("#page-overview .tc-page-head", { timeout: 30000 }); +}; + +/** Go to a page (or a page's tab, "instructions/skills") and let its data land. */ +const visit = async (target, page, route) => { + await target.evaluate((r) => { location.hash = r; window.scrollTo(0, 0); }, route); + await target.waitForSelector(`#page-${route.split("/")[0]}:not([hidden]) .tc-page-head`, { timeout: 30000 }); + await target.evaluate(() => document.fonts.ready); + await page.waitForTimeout(route === "instructions/skills" ? 1200 : 400); +}; + +module.exports = { FIXTURES, fixture, routesTable, shiftTimes, SCENARIOS, PORTS, statusFor, portsFor, routeConsole, framed, unlock, visit }; diff --git a/scripts/readme-media.js b/scripts/readme-media.js new file mode 100644 index 0000000..582e9c4 --- /dev/null +++ b/scripts/readme-media.js @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/* + * The README's and the guides' pictures of the setup console, from the + * console itself. + * + * node scripts/readme-media.js [url] [key] [--out docs/media] + * + * Point it at a running console (scripts/dev-console.mjs serves one at the + * default address with the key "dev"). Like console-screens.js it shows the + * console's own pages with sample data from scripts/fixtures/console, and + * nothing it presses reaches the server. It writes: + * + * setup-light.png, setup-dark.png the README's picture: Overview at a + * desk and Agents on a phone, mid-setup + * ports-panel.png the Ports guide's list of ports + */ +const fs = require("node:fs"); +const path = require("node:path"); +let chromium; +try { ({ chromium } = require("playwright")); } catch { ({ chromium } = require("playwright-core")); } +const { statusFor, portsFor, routeConsole, unlock, visit } = require("./lib/console-fixtures.cjs"); + +const args = process.argv.slice(2); +const positional = args.filter((a, i) => !a.startsWith("--") && !(i > 0 && args[i - 1].startsWith("--"))); +const BASE = positional[0] || "http://127.0.0.1:23780/__setup/"; +const KEY = positional[1] || "dev"; +const OUT = args.includes("--out") ? args[args.indexOf("--out") + 1] : path.join(__dirname, "..", "docs", "media"); +const CHROME = process.env.CHROME_PATH || "/usr/bin/chromium"; + +// Mid-setup, as someone first sees it: an address set, no device yet, the +// last toolchains installing, two agents waiting to sign in and one update. +const heroStatus = () => statusFor("ready", (s, now) => { + s.sessions = []; + s.events = []; + for (const id of ["grok", "cursor"]) { + const h = s.harnesses.find((x) => x.id === id); + Object.assign(h, { installed: true, runnable: true, signedIn: false, credentialsPresent: false, version: h.latestVersion, installedVersion: h.latestVersion }); + } + Object.assign(s.harnesses.find((x) => x.id === "codex"), { latestVersion: "0.164.0" }); + s.setup = { state: "running", startedAt: now - 240_000, finishedAt: null, current: "rust", + items: s.setup.items.map((item) => ({ ...item, state: item.id === "rust" ? "installing" : item.id === "uv" ? "pending" : "done", + progress: item.id === "rust" ? { phase: "downloading", done: 151_000_000, total: 244_000_000 } : undefined })) }; + for (const t of s.toolchains) if (t.id === "rust" || t.id === "uv") Object.assign(t, { installed: false, version: null }); +}); + +const heroPorts = () => portsFor("ready"); + +// Every state a port can be in, at once. +const panelPorts = () => portsFor("ready", (p, now) => ({ + available: true, + listening: [3000, 5173, 5432, 8080], + details: [ + { port: 3000, process: "next-server", address: "127.0.0.1", looksLikeDatabase: false }, + { port: 5173, process: "vite", address: "::1", looksLikeDatabase: false }, + { port: 5432, process: "postgres", address: "127.0.0.1", looksLikeDatabase: true }, + { port: 8080, process: "node", address: "0.0.0.0", looksLikeDatabase: false }, + ], + tunnels: [ + { port: 3000, state: "open", url: "https://quiet-river-1234.trycloudflare.com", startedAt: now - 8 * 60_000 }, + { port: 5173, state: "starting" }, + { port: 8080, state: "failed", error: "Couldn’t reach Cloudflare. Publishing needs outbound UDP 7844 or HTTP/2 to argotunnel.com." }, + ], +})); + +/** One page of the console, as a PNG in memory. */ +const shoot = async (browser, { width, height, scale, theme, route, status, ports, clip }) => { + const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: scale, colorScheme: theme, reducedMotion: "reduce" }); + const page = await context.newPage(); + await routeConsole(page, { status, ports }); + await page.goto(BASE, { waitUntil: "domcontentloaded" }); + await unlock(page, KEY); + await visit(page, page, route); + // A clipped section keeps a little of the page around it, so its card's edges show. + const box = clip && await (await page.waitForSelector(clip)).boundingBox(); + const shot = await page.screenshot(box ? { clip: { x: box.x - 16, y: box.y - 12, width: box.width + 32, height: box.height + 28 } } : {}); + await context.close(); + return shot; +}; + +// The README's frame: a window and a phone on a soft field, in each theme. +const frame = (theme, desk, phone) => ` +
+
+`; + +(async () => { + fs.mkdirSync(OUT, { recursive: true }); + const browser = await chromium.launch({ executablePath: CHROME, args: ["--no-sandbox"] }); + try { + for (const theme of ["light", "dark"]) { + const desk = await shoot(browser, { width: 1280, height: 780, scale: 2, theme, route: "overview", status: heroStatus, ports: heroPorts }); + const phone = await shoot(browser, { width: 390, height: 844, scale: 3, theme, route: "agents", status: heroStatus, ports: heroPorts }); + const context = await browser.newContext({ viewport: { width: 1136, height: 704 }, deviceScaleFactor: 2 }); + const page = await context.newPage(); + await page.setContent(frame(theme, desk, phone), { waitUntil: "load" }); + await page.screenshot({ path: path.join(OUT, `setup-${theme}.png`) }); + await context.close(); + console.log(path.join(OUT, `setup-${theme}.png`)); + } + const panel = await shoot(browser, { width: 1100, height: 900, scale: 2, theme: "light", route: "ports", status: heroStatus, ports: panelPorts, clip: "#page-ports [data-key='pl-title']" }); + fs.writeFileSync(path.join(OUT, "ports-panel.png"), panel); + console.log(path.join(OUT, "ports-panel.png")); + } finally { + await browser.close(); + } +})().catch((error) => { console.error(error); process.exit(1); }); diff --git a/scripts/skills-ui-audit.js b/scripts/skills-ui-audit.js index ff69130..fc7b1c2 100644 --- a/scripts/skills-ui-audit.js +++ b/scripts/skills-ui-audit.js @@ -141,7 +141,7 @@ const checkSelect = async (page, select, theme) => { await page.getByRole('button', { name: /unlock/i }).click(); await page.locator('#app').waitFor(); } - await page.goto(base + (embedded ? '?embed=t3&theme=' + theme : '') + '#instructions'); + await page.goto(base + (embedded ? '?embed=t3&theme=' + theme : '') + '#instructions/skills'); await page.locator('[data-cmd="skills.view"]').first().waitFor(); await page.locator('#skill-provider').selectOption('claude'); await page.locator('.tc-skills[data-provider="claude"]').waitFor(); @@ -168,19 +168,22 @@ const checkSelect = async (page, select, theme) => { assert.equal(await page.locator('#skill-provider').inputValue(), 'opencode'); await page.locator('#skill-provider').selectOption('codex'); await page.locator('.tc-skills[data-provider="codex"]').waitFor(); - assert.deepEqual(await page.locator('.tc-skill-row .tc-badge').allTextContents(), ['Enabled', 'Disabled']); + // Codex's own on/off for each skill is a switch in its row. + const codexStates = () => page.locator('.tc-skill-row [role="switch"]').evaluateAll((all) => all.map((el) => el.getAttribute('aria-checked'))); + const reviewSwitch = page.getByRole('switch', { name: 'review for Codex', exact: true }); + assert.deepEqual(await codexStates(), ['true', 'false']); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); - await page.getByRole('button', { name: 'Disable review for Codex', exact: true }).click(); + await reviewSwitch.click(); await page.getByText('Codex settings changed. Refresh required.', { exact: true }).waitFor(); await page.locator('.tc-toast[data-tone="danger"] [data-toast-close]').click(); - await page.getByRole('button', { name: 'Disable review for Codex', exact: true }).waitFor(); - assert.deepEqual(await page.locator('.tc-skill-row .tc-badge').allTextContents(), ['Enabled', 'Disabled']); + await page.waitForFunction(() => !document.querySelector('.tc-skill-row [role="switch"]:disabled')); + assert.deepEqual(await codexStates(), ['true', 'false'], 'a refused change leaves the switch as it was'); stateError = false; gateState = true; const stateSaving = new Promise((resolve) => { stateStarted = resolve; }); - await page.getByRole('button', { name: 'Disable review for Codex', exact: true }).click(); await stateSaving; + await reviewSwitch.click(); await stateSaving; assert.equal(await page.locator('[data-cmd="skills.state"]:disabled').count(), 2); assert.equal(await page.getByRole('button', { name: 'Refresh skills', exact: true }).isDisabled(), true); - assert.deepEqual(await page.locator('.tc-skill-row .tc-badge').allTextContents(), ['Enabled', 'Disabled'], 'no optimistic availability'); + assert.deepEqual(await codexStates(), ['true', 'false'], 'no optimistic availability'); await page.locator('#skill-provider').selectOption('opencode'); await page.locator('.tc-skills[data-provider="opencode"]').waitFor(); const stateSaved = page.waitForResponse((response) => response.url().endsWith('/context/skills/state')); @@ -188,10 +191,10 @@ const checkSelect = async (page, select, theme) => { assert.equal(await page.locator('#skill-provider').inputValue(), 'opencode'); assert.equal(await page.locator('[data-cmd="skills.state"]').count(), 0, 'late native result must not replace another provider'); await page.locator('#skill-provider').selectOption('codex'); - await page.getByRole('button', { name: 'Enable review for Codex', exact: true }).waitFor(); + await page.waitForFunction(() => document.querySelector('.tc-skill-row [role="switch"]')?.getAttribute('aria-checked') === 'false'); const enabled = page.waitForResponse((response) => response.url().endsWith('/context/skills/state')); - await page.getByRole('button', { name: 'Enable review for Codex', exact: true }).click(); await enabled; - await page.getByRole('button', { name: 'Disable review for Codex', exact: true }).waitFor(); + await reviewSwitch.click(); await enabled; + await page.waitForFunction(() => document.querySelector('.tc-skill-row [role="switch"]')?.getAttribute('aria-checked') === 'true'); gateRead = true; const readStarted = new Promise((resolve) => { oldReadStarted = resolve; }); @@ -274,12 +277,12 @@ const checkSelect = async (page, select, theme) => { const savedPolicy = page.waitForResponse((response) => response.url().endsWith('/context/skills/state')); releaseState(); await savedPolicy; await page.waitForFunction((expected) => document.querySelector('[data-skill-policy]')?.value === expected && !document.querySelector('[data-skill-policy]').disabled, chosen); - assert.ok((await page.locator('.tc-skill-row .tc-badge').first().innerText()).includes(chosen)); + assert.equal(await page.locator('.tc-skill-row .tc-badge').count(), 0, 'the policy menu says the state; no badge repeats it'); } inventoryError = true; const refreshed = page.waitForRequest((request) => request.url().includes('refresh=1')); await page.getByRole('button', { name: 'Refresh skills', exact: true }).click(); await refreshed; - await page.getByText('Could not read skills: Fixture inventory unavailable', { exact: true }).waitFor(); + await page.getByText('Couldn’t read skills: Fixture inventory unavailable', { exact: true }).waitFor(); assert.equal(await page.locator('.tc-skill-row').count(), 0, 'stale states must be hidden after a failed refresh'); inventoryError = false; await page.getByRole('button', { name: 'Refresh skills', exact: true }).click(); diff --git a/tests/context.test.mjs b/tests/context.test.mjs index 06af180..c53891d 100644 --- a/tests/context.test.mjs +++ b/tests/context.test.mjs @@ -234,7 +234,9 @@ test("Context offers only global instructions and never adds attention", () => { assert.equal(row.action.cmd, "context.edit"); assert.equal(row.attention, false); const items = M.paletteItems(status, null, ui, Date.now()).filter((i) => i.group === "Instructions & skills"); - assert.ok(M.paletteItems(status, null, ui, Date.now()).some((i) => i.label === "Edit global AGENTS.md…")); + assert.ok(M.paletteItems(status, null, ui, Date.now()).some((i) => i.label === "Edit the shared AGENTS.md…")); + assert.equal(row.action.label, "Write", "a file not written yet is written, not edited"); + assert.match(row.status.text, /^Not written yet/); assert.ok(items.every((i) => !/skill|setup|project/i.test(i.label))); }); @@ -249,7 +251,7 @@ test("oversized instructions remain visible without editor or delete actions", ( assert.equal(row.menu.length, 0); assert.equal(M.contextProviderRows(status)[0].mode, 'separate'); assert.equal(M.contextProviderRows(status)[0].error, error); - assert.equal(M.paletteItems(status, null, ui, Date.now()).filter((i) => i.group === 'Context').length, 0); + assert.equal(M.paletteItems(status, null, ui, Date.now()).filter((i) => i.group === 'Instructions & skills').length, 0); }); test("existing provider files appear in Context and edits preserve their permissions and separation", async (t) => { @@ -342,11 +344,13 @@ test("unknown, absent, dangling and directory provider targets cannot be overwri test("provider view rows and palette expose each discovered file", () => { const status = { context: { providers: [{ provider: 'claude', path: '/custom/CLAUDE.md', mode: 'separate' }] } }; const row = M.contextProviderRows(status)[0]; - assert.equal(row.name, 'Claude Code instructions'); + assert.equal(row.name, 'Claude Code'); assert.equal(row.mark, 'claude'); assert.equal(row.badge, null); - assert.equal(row.status.text, 'Separate file'); - assert.equal(row.status.code, '/custom/CLAUDE.md'); + assert.equal(row.status.text, 'Reads its own CLAUDE.md'); + assert.equal(row.path, '/custom/CLAUDE.md'); + assert.equal(M.contextProviderRows({ context: { providers: [{ provider: 'codex', mode: 'shared' }] } })[0].status.text, 'Reads the shared AGENTS.md'); + assert.equal(M.contextProviderRows({ context: { providers: [{ provider: 'codex', mode: null }] } })[0].status.text, 'Reads no global instructions'); assert.ok(M.paletteItems(status, null, ui, Date.now()).some((i) => i.cmd.cmd === 'context.provider.edit' && i.cmd.id === 'claude')); }); @@ -505,3 +509,14 @@ test("global deletion checks revisions, removes shared links and preserves separ assert.equal(await fs.readFile(targets[0].path + '.t3-context-backup', 'utf8'), '# Original'); for (const target of [targets[0], targets[2]]) await assert.rejects(fs.lstat(target.path), { code: 'ENOENT' }); }); + +test("the shared file says which agents read it", () => { + const status = { context: { agentsMd: { present: true, bytes: 1200 }, providers: [ + { provider: 'claude', mode: 'shared' }, { provider: 'codex', mode: 'shared' }, { provider: 'opencode', mode: 'separate' }] } }; + const row = M.contextAgentsMdRow(status); + assert.equal(row.status.text, '1 kB · read by Claude Code and Codex'); + assert.equal(row.action.label, 'Edit'); + assert.equal(row.menu.map((m) => m.cmd).join(), 'context.delete'); + const unread = M.contextAgentsMdRow({ context: { agentsMd: { present: true, bytes: 10 }, providers: [{ provider: 'codex', mode: 'separate' }] } }); + assert.equal(unread.status.text, '10 B · no agent reads it yet'); +});