@milpa/comal 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@ comal --help
28
28
 
29
29
  Keep the PATH line in your shell startup file if you use that prefix. If your Node manager already provides a user prefix, you can omit `--prefix "$HOME/.local"`. The package registers the `comal` executable; you do not need to create a shell alias.
30
30
 
31
- You can pin a version with `npx @milpa/comal@0.1.3` or `npm install --global @milpa/comal@0.1.3`. The local release manifest preserves SHA-256 and SHA-512 integrity is checked against npm at publish time. No independent publisher signature is claimed.
31
+ You can pin a version with `npx @milpa/comal@0.1.5` or `npm install --global @milpa/comal@0.1.5`. The local release manifest preserves SHA-256 and SHA-512 integrity is checked against npm at publish time. No independent publisher signature is claimed.
32
32
 
33
33
  ## Test without account or model
34
34
 
@@ -56,6 +56,8 @@ comal --auth-provider codex --name resident
56
56
 
57
57
  For APIs, set the key via the environment or your secrets manager. Do not paste it in the composer or as an argument. See https://comal.milpahq.com/academy/docs/modelos/ and https://comal.milpahq.com/academy/docs/login/ for profiles and providers.
58
58
 
59
+ In the TUI, `/provider` opens a searchable list of workspace profiles and login providers. `/models` lists models from the active provider; `/effort` offers the active model’s supported levels. Use ↑/↓ and Enter to select, or Esc to keep your draft. A pending selection stays distinct from the active connection. Comal checks model/provider candidates before replacing a working connection; failed checks offer explicit recovery without retrying automatically. A catalog check does not prove inference entitlement.
60
+
59
61
  ## Connect your Artifacts account
60
62
 
61
63
  ```sh
@@ -77,7 +79,7 @@ This removes the client; it keeps project sessions and user credentials. To disc
77
79
 
78
80
  ## Scope of this first distribution
79
81
 
80
- Installation is verified on Linux, from an empty prefix and HOME. macOS and Windows via WSL2 are paths pending verification in those environments; native Windows support is not claimed. Chrome Lab requires configuring your own SSH/CDP host. The package does not grant access to the author's lab.
82
+ Release acceptance runs the exact archive in empty user state on Linux, macOS arm64 and native Windows. Reports identify the archive hash, OS, Node runtime and checks; successful installation alone does not establish feature parity. On native Windows, cross-session messaging is explicitly unavailable, provider/cloud credential stores still need a Windows ACL implementation, and Bash/Grep require Unix tools. Use WSL for the complete Unix-oriented workflow; WSL and Intel macOS require separate runtime verification. Chrome Lab requires configuring your own SSH/CDP host. The package does not grant access to the author's lab.
81
83
 
82
84
  The package belongs to the npm organization `@milpa`. `npx comal` corresponds to another project; use the full name `npx @milpa/comal`. A public license, native installers, and publisher signature will be resolved separately with the maintainer. Full documentation: https://comal.milpahq.com/academy/ .
83
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milpa/comal",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Comal: governed operations, execution receipts, and programming assistance in any language",
5
5
  "type": "module",
6
6
  "private": false,
package/src/cli.mjs CHANGED
@@ -33,6 +33,8 @@ import { trustedContext } from './trusted-context.mjs';
33
33
  import { contextSettings, resolveContextBudget, estimateRequest } from './compaction.mjs';
34
34
  import { modelTimingSettings } from './model-deadline.mjs';
35
35
  import { House, houseSnapshot } from './house.mjs';
36
+ import { Operation } from './operations.mjs';
37
+ import { validateConnectionCandidate } from './connection-validation.mjs';
36
38
  import { PluginRuntime, parsePluginCommand } from './plugins.mjs';
37
39
  import { registerWorkOperations, workProjection } from './work-plan.mjs';
38
40
  import { evidenceOperations } from './work-state.mjs';
@@ -352,7 +354,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
352
354
  peerBus=new SessionBus({store,getSessionId:()=>session.id,inbound:v.inbound,getMetadata:()=>({name:session.name,model:provider.model,effort:provider.effort || 'auto',authProvider:provider.authProvider,provider:v.demo?'demo':settings.provider,profile:provider.profileName || null,capabilities:sessionCapabilities()}),getAuthority:()=>({cwd,mode:policy.mode,allowed:[...policy.allowed],denied:[...policy.denied],ceilings:peerGuard.ceilings()}),getChain:()=>peerGuard.chain,onEvent});
353
355
  const beforeModel=async({signal,source,turn,prompt}={})=>{
354
356
  const context={provider,tools:tools.executor,record:event=>store.append(session.id,event),signal};
355
- if(turn===1 && ['user','goal'].includes(source))await preparePeerRouting(prompt,{...context,bus:peerBus});
357
+ if(peerBus.availability?.available!==false && turn===1 && ['user','goal'].includes(source))await preparePeerRouting(prompt,{...context,bus:peerBus});
356
358
  const delivered=await peerBus.drain();peerGuard.receive(delivered);
357
359
  await preparePeerCapabilities(delivered,context);
358
360
  for(const message of delivered)onEvent({type:'peer_message',from:message.from,message:message.content,request_id:message.request_id});return delivered.length+await subagents.incorporate({sessionId:session.id,signal});
@@ -361,11 +363,11 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
361
363
  if (chromeWorkflow) await chromeWorkflow.finishResult(final, context);
362
364
  await completePeerReplies(final,{...context,requests:peerGuard.messages,store,sessionId:session.id,tools:tools.executor,record:event=>store.append(session.id,event),onEvent});
363
365
  };
364
- for(const definition of peerToolDefinitions)registry.add({...definition,native:true,effects:{workspace:'none',external:definition.name==='SendMessage'?'write':'none',process:'none',authority:'none'},run:async(input,options)=>JSON.stringify(definition.name==='ListAgents'?await peerBus.list(input):await peerBus.send(input,options))});
366
+ for(const definition of (peerBus.availability?.available===false?[]:peerToolDefinitions))registry.add({...definition,native:true,effects:{workspace:'none',external:definition.name==='SendMessage'?'write':'none',process:'none',authority:'none'},run:async(input,options)=>JSON.stringify(definition.name==='ListAgents'?await peerBus.list(input):await peerBus.send(input,options))});
365
367
  let providerOperations=[];
366
- const publishProviderFeatures=()=>{
367
- const definitions=providerFeatureDefinitions(provider).map(definition=>({...definition,effects:{workspace:definition.name==='AnalyzeMedia'?'read':'none',external:definition.name==='ProviderCapabilities'?'none':'read',process:'none',authority:'none'},run:(input,options)=>executeProviderFeature(definition.name,input,{provider,workspace:tools,record:event=>store.append(session.id,event),signal:options.signal,observe:options.observe,invoke:(request,execute)=>activeGoalBudget?activeGoalBudget.complete(request,execute):execute(request)})}));
368
- house.registry.replace(providerOperations,definitions);providerOperations=definitions.map(definition=>definition.name);
368
+ const providerFeaturesFor=next=>providerFeatureDefinitions(next).map(definition=>new Operation({...definition,effects:{workspace:definition.name==='AnalyzeMedia'?'read':'none',external:definition.name==='ProviderCapabilities'?'none':'read',process:'none',authority:'none'},run:(input,options)=>executeProviderFeature(definition.name,input,{provider:next,workspace:tools,record:event=>store.append(session.id,event),signal:options.signal,observe:options.observe,invoke:(request,execute)=>activeGoalBudget?activeGoalBudget.complete(request,execute):execute(request)})}));
369
+ const publishProviderFeatures=(definitions=providerFeaturesFor(provider))=>{
370
+ house.registry.replace(providerOperations,definitions);providerOperations=definitions.map(definition=>definition.contract.name);
369
371
  };
370
372
  publishProviderFeatures();
371
373
  const combinedTools = tools.executor;
@@ -641,8 +643,8 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
641
643
  { name: 'SessionMode', description: 'Human changes the permission mode.', input_schema: { type: 'object', properties: { mode: { type: 'string', enum: PERMISSION_MODES } }, required: ['mode'], additionalProperties: false }, run: async ({ mode }) => { policy.mode = mode; await store.append(session.id, { type: 'mode', mode }); return t("Permissions: {v0}", {v0: mode}); } },
642
644
  { name:'SessionModel',description:'Human selects a model without replacing session history.',input_schema:{type:'object',properties:{model:{type:'string',minLength:1,maxLength:256}},required:['model'],additionalProperties:false},run:async({model})=>{
643
645
  if(!model.trim() || /\s/.test(model))throw new Error(t("Use /model ID, without spaces."));
644
- await replaceProvider({...settings,model,effort:'auto'},{refresh:true});
645
- return t("Model: {v0} · effort {v1} · same session", {v0: provider.model, v1: provider.effort || 'auto'});
646
+ const verification=await replaceProvider({...settings,model,effort:'auto'},{refresh:true,validate:true});
647
+ return t("Model: {v0} · effort {v1} · same session", {v0: provider.model, v1: provider.effort || 'auto'}) + (verification?.compatibilityFallback? ' · '+t('Model catalog unavailable; the active connection is unverified. No inference was sent.'):'');
646
648
  }},
647
649
  {name:'SessionEffort',description:'Human selects a supported reasoning effort for the next request.',input_schema:{type:'object',properties:{effort:{type:'string',minLength:1,maxLength:16}},required:['effort'],additionalProperties:false},run:async({effort})=>{
648
650
  await replaceProvider({...settings,model:provider.model,effort:normalizeEffort(effort)});
@@ -653,8 +655,8 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
653
655
  if(v.demo)throw new Error(t("The demo does not allow changing provider."));
654
656
  const selected=profiles.profiles.find(item=>item.name===profile);
655
657
  const config=selected ? {provider:selected.provider,profile:selected,namedProfile:true,authProvider:selected.authProvider,model:selected.model,baseURL:selected.baseURL,effort:selected.effort || 'auto',maxTokens:contextConfig.maxTokens} : {...authProviderDefaults(profile),effort:'auto',maxTokens:contextConfig.maxTokens};
656
- await replaceProvider(config);
657
- return t("Provider: {v0} · {v1} · {v2}", {v0: selected?.name || provider.authProvider, v1: settings.provider, v2: provider.model});
658
+ const verification=await replaceProvider(config,{validate:true});
659
+ return t("Provider: {v0} · {v1} · {v2}", {v0: selected?.name || provider.authProvider, v1: settings.provider, v2: provider.model}) + (verification?.compatibilityFallback?' · '+t('Model catalog unavailable; the active connection is unverified. No inference was sent.'):'');
658
660
  }},
659
661
  {name:'SessionLogin',description:'Human authorizes a provider login. Only the environment variable name is recorded.',input_schema:{type:'object',properties:{provider:{type:'string',enum:AUTH_PROVIDERS.map(item=>item.id)},apiKeyEnv:{type:'string',pattern:'^[A-Za-z_][A-Za-z0-9_]*$'}},required:['provider'],additionalProperties:false},effects:{workspace:'none',external:'unknown',process:'none',authority:'credential_control'},run:async({provider:target,apiKeyEnv})=>loginControl(target+(apiKeyEnv?' --api-key-env '+apiKeyEnv:''))},
660
662
  {name:'SessionLogout',description:'Human removes Comal credentials; does not revoke the remote account.',input_schema:{type:'object',properties:{provider:{type:'string',enum:AUTH_PROVIDERS.map(item=>item.id)}},required:['provider'],additionalProperties:false},effects:{workspace:'none',external:'none',process:'none',authority:'credential_control'},run:async({provider:target})=>loginControl(target,true)},
@@ -672,10 +674,12 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
672
674
  } });
673
675
  const changeMode = mode => humanTools.execute('SessionMode', { mode });
674
676
  let discoveredModels = [], discoveredModelKey = '', discoveredSessions = [], discoveredPeers = [];
677
+ let authStatus={};
678
+ const refreshConnections=async()=>{const statuses=await Promise.allSettled(AUTH_PROVIDERS.map(item=>authManager.status(item.id)));authStatus=Object.fromEntries(AUTH_PROVIDERS.map((item,index)=>[item.id,statuses[index].status==='fulfilled'?statuses[index].value.status:'unknown']));};
675
679
  const modelDiscoveryKey = () => `${settings.provider}:${provider.baseURL || provider.url || ''}:${provider.model}`;
676
680
  const discovery = () => ({
677
681
  mode: policy.mode, effortLevels: modelCapabilities?.effort?.levels || ['auto'],
678
- capabilities: modelCapabilities, profiles: profiles.profiles.map(({name}) => ({name})),
682
+ capabilities: modelCapabilities, profiles: profiles.profiles.map(({name,provider,model,authProvider}) => ({name,provider,model,authProvider})),authStatus,
679
683
  authProviders: AUTH_PROVIDERS.map(({id,label,method,apiKeyEnv,experimental}) => ({id,label,description: method==='device_code' ? t('Browser sign-in') + (experimental ? ' · '+t('Experimental') : '') : t('Uses environment variable {name}',{name:apiKeyEnv})})),
680
684
  mcpServers: mcpRuntime.status().servers.map(({name,status}) => ({name,status})),
681
685
  plugins: plugins.status().plugins.map(({id,state,operations,builtin}) => ({id,state,operations,builtin})),
@@ -683,21 +687,29 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
683
687
  models: discoveredModelKey === modelDiscoveryKey() ? discoveredModels : [{id:provider.model}],
684
688
  sessions: discoveredSessions, peers: discoveredPeers,agents:[...(ui?.subagentBlocks.values() || [])].map(child=>({id:child.run_id || child.id,description:`${child.runner_state || child.state} / ${child.turn_state || '?'} / ${child.work_state || 'unjudged'} · ${child.description || ''}`})),
685
689
  });
686
- const metadata = () => ({cwd,sessionId:session.id,name:session.name,model:provider.model,mode:policy.mode,provider:v.demo?'demo':settings.provider,profile:provider.profileName,effort:provider.effort || 'auto',contextBudget:contextBudget(modelCapabilities),modelCapabilities,authProvider:provider.authProvider,workflow:chromeWorkflow?`chrome:${chromeWorkflow.task.project}`:null});
687
- const replaceProvider = async (config,{refresh=false}={}) => {
690
+ const metadata = () => ({cwd,sessionId:session.id,name:session.name,model:provider.model,mode:policy.mode,provider:v.demo?'demo':settings.provider,profile:provider.profileName,effort:provider.effort || 'auto',contextBudget:contextBudget(modelCapabilities),modelCapabilities,authProvider:provider.authProvider,peerAvailability:peerBus.availability,workflow:chromeWorkflow?`chrome:${chromeWorkflow.task.project}`:null});
691
+ const replaceProvider = async (config,{refresh=false,validate=false}={}) => {
688
692
  if(v.demo)throw new Error(t("Change model/effort in a session with a real provider."));
689
693
  modelController=new AbortController();const wasBusy=ui?.busy;ui?.setBusy(true);
690
694
  const signal=controller?.signal?AbortSignal.any([controller.signal,modelController.signal]):modelController.signal;
691
695
  try {
692
696
  const next=await instantiate({...config,signal,maxTokens:contextConfig.maxTokens,...(config.provider==='openai'?timing:{})});
693
697
  if(typeof next.discoverModel==='function')await next.discoverModel({signal});
698
+ const verification=validate?await validateConnectionCandidate(next,{signal,explicitModel:typeof config.model==='string' && Boolean(config.model.trim())}):null;
694
699
  await setProviderEffort(next,config.effort || 'auto',{signal});
695
700
  const report=await inspectModel(next,{signal,force:refresh});report.effort.selected=next.effort;
696
701
  effectiveContextWindow(report);
702
+ if(verification)report.connectionValidation=verification;
703
+ const features=providerFeaturesFor(next);
704
+ for(const feature of features)if(house.registry.get(feature.contract.name) && !providerOperations.includes(feature.contract.name))throw Object.assign(new Error('Provider operation conflicts with an existing contract.'),{code:'PROVIDER_OPERATION_CONFLICT'});
697
705
  const identityChanged=provider.model!==next.model || settings.provider!==config.provider || (provider.baseURL || String(provider.url))!==(next.baseURL || String(next.url));
698
706
  signal?.throwIfAborted();await persistProvider(next,config,report);
699
- provider=next;settings={...config,model:next.model};modelCapabilities=report;publishProviderFeatures();agent.providerName=settings.provider;agent.compactor.contextWindow=effectiveContextWindow(report);if(identityChanged)agent.compactor.factor=1;
700
- ui?.updateMetadata(metadata());await peerBus.setState(controller?'busy':'idle');
707
+ provider=next;settings={...config,model:next.model};modelCapabilities=report;publishProviderFeatures(features);agent.providerName=settings.provider;agent.compactor.contextWindow=effectiveContextWindow(report);if(identityChanged)agent.compactor.factor=1;
708
+ // The durable provider row is the commit boundary. Cancellation after it
709
+ // cannot truthfully report that the previous connection was retained.
710
+ ui?.updateMetadata(metadata());
711
+ try{await peerBus.setState(controller?'busy':'idle');}catch{ui?.notice(t('Connection applied; live-session presence could not be refreshed.'),true);}
712
+ return verification;
701
713
  }finally{modelController=null;if(ui)ui.setBusy(wasBusy);}
702
714
  };
703
715
  const loginControl=async(name,logout=false)=>{
@@ -711,6 +723,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
711
723
  finally{authController=null;if(ui)ui.setBusy(wasBusy);}
712
724
  };
713
725
  const backend = {
726
+ refreshConnections,
714
727
  async listModels({signal}={}) {
715
728
  const key=modelDiscoveryKey();
716
729
  const models=await listProviderModels(provider,{signal});
@@ -831,6 +844,8 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
831
844
  };
832
845
  try {
833
846
  await peerBus.start();
847
+ if(peerBus.availability?.available===false){const note=t('Cross-session messaging is unavailable on this platform. Local work remains available.');if(ui)ui.notice(note,true);else stderr.write(note+'\n');}
848
+ await refreshConnections();
834
849
  await saveProvider();
835
850
  await lifecycle.execute('PluginInitialize', { trust: Boolean(v['trust-plugins']) }, { signal: startupSignal });
836
851
  await lifecycle.execute('McpInitialize', { trust: Boolean(v['trust-mcp']) }, { signal: startupSignal });
@@ -0,0 +1,49 @@
1
+ import {listProviderModels} from './model-controls.mjs';
2
+
3
+ const NETWORK_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH', 'EAI_AGAIN', 'ECONNRESET', 'ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT']);
4
+ function rejected(code, message, status) {
5
+ return Object.assign(new Error(message), {code, ...(status ? {status} : {})});
6
+ }
7
+
8
+ // A catalog response proves only the read-only connection check. It cannot prove
9
+ // that inference, a particular model or paid/account entitlements will succeed.
10
+ // The candidate remains detached; this helper neither persists nor selects it.
11
+ export async function validateConnectionCandidate(provider, {signal, explicitModel = false, timeoutMs = 10000} = {}) {
12
+ if (!provider || typeof provider.model !== 'string' || !provider.model.trim()) throw rejected('CONNECTION_MODEL_REQUIRED', 'Choose a model before applying this connection.');
13
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000) throw new TypeError('Connection validation timeout must be between 1 and 30000 milliseconds.');
14
+ if (typeof provider.listModels !== 'function' && !(typeof provider.fetch === 'function' && (provider.baseURL || provider.url))) {
15
+ throw rejected('CONNECTION_CATALOG_UNSUPPORTED', 'This provider cannot check its model catalog.');
16
+ }
17
+ const timeout = AbortSignal.timeout(timeoutMs), bounded = signal ? AbortSignal.any([signal, timeout]) : timeout;
18
+ let onAbort;
19
+ try {
20
+ bounded.throwIfAborted();
21
+ const interrupted = new Promise((resolve, reject) => {
22
+ onAbort = () => reject(rejected(timeout.aborted && !signal?.aborted ? 'CONNECTION_TIMEOUT' : 'ABORT_ERR', timeout.aborted && !signal?.aborted ? 'Connection validation timed out.' : 'Connection validation cancelled.'));
23
+ bounded.addEventListener('abort', onAbort, {once: true});
24
+ });
25
+ const models = await Promise.race([listProviderModels(provider, {signal: bounded}), interrupted]);
26
+ bounded.throwIfAborted();
27
+ return {verified: true, catalogVerified: true, modelAdvertised: models.some(item => item.id === provider.model.replace(/^models\//, '')),
28
+ compatibilityFallback: false, source: 'model_catalog', inferenceVerified: false};
29
+ } catch (error) {
30
+ if (signal?.aborted) throw Object.assign(rejected('ABORT_ERR', 'Connection validation cancelled.'), {name: 'AbortError'});
31
+ if (timeout.aborted || error?.name === 'TimeoutError' || ['ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT'].includes(error?.code)) {
32
+ throw Object.assign(rejected('CONNECTION_TIMEOUT', 'Connection validation timed out.'), {name: 'TimeoutError'});
33
+ }
34
+ // Native adapters may keep the HTTP code in a structured diagnostic rather
35
+ // than error.status. Read the integer only, never their upstream payload.
36
+ const status = [error?.status, error?.details?.http_status].find(value => Number.isInteger(value) && value >= 100 && value <= 599);
37
+ if (explicitModel === true && [404, 405].includes(status)) {
38
+ return {verified: false, catalogVerified: false, modelAdvertised: null, compatibilityFallback: true,
39
+ source: 'catalog_unavailable', inferenceVerified: false, metadataStatus: status};
40
+ }
41
+ const networkCode = [error?.code, error?.cause?.code].find(code => NETWORK_CODES.has(code));
42
+ if (networkCode) throw rejected(networkCode, 'The model server could not be reached.');
43
+ if ([401, 403].includes(status) || ['PROVIDER_AUTH_REQUIRED', 'AUTH_REQUIRED', 'UNAUTHENTICATED'].includes(error?.code)) throw rejected('CONNECTION_AUTH_REQUIRED', 'The model server refused authentication. Sign in or choose another connection.', status);
44
+ if (status) throw rejected('CONNECTION_CATALOG_HTTP', 'The model catalog is unavailable. The active connection was not changed.', status);
45
+ throw rejected('CONNECTION_VALIDATION_FAILED', 'The model catalog could not be checked. The active connection was not changed.');
46
+ } finally {
47
+ if (onAbort) bounded.removeEventListener('abort', onAbort);
48
+ }
49
+ }
package/src/i18n/es.mjs CHANGED
@@ -245,5 +245,69 @@ export default Object.freeze({
245
245
  "Provider: {provider}. The selected model applies to the next call; active work keeps its model.": "Proveedor: {provider}. El modelo elegido se aplica a la siguiente llamada; el trabajo activo conserva su modelo.",
246
246
  "No options found. Check the provider or use /model ID.": "No hay opciones. Revisa el proveedor o usa /model ID.",
247
247
  "The provider changed. Open the model list again.": "El proveedor cambió. Abre de nuevo la lista de modelos.",
248
- "Selection queued. It will apply after the current turn.": "Selección en cola. Se aplicará al terminar el turno actual."
248
+ "Selection queued. It will apply after the current turn.": "Selección en cola. Se aplicará al terminar el turno actual.",
249
+ "Signed in": "Sesión iniciada",
250
+ "Credentials configured": "Credenciales configuradas",
251
+ "Not signed in": "Sin sesión iniciada",
252
+ "Sign-in expired": "Inicio de sesión vencido",
253
+ "Authentication status unknown": "Estado de autenticación desconocido",
254
+ "Workspace profile": "Perfil del espacio de trabajo",
255
+ "Choose a provider": "Elige un proveedor",
256
+ "Choose a workspace profile or login provider. The connection is checked before replacing the active provider.": "Elige un perfil del espacio de trabajo o un proveedor de inicio de sesión. Se comprueba la conexión antes de reemplazar el proveedor activo.",
257
+ "{count} input tokens": "{count} tokens de entrada",
258
+ "Choose effort": "Elige el esfuerzo",
259
+ "This model does not announce configurable effort. Auto uses the provider default.": "Este modelo no anuncia esfuerzo configurable. Auto usa el valor predeterminado del proveedor.",
260
+ "Only levels announced by the active model are offered. Auto uses the provider default.": "Solo se ofrecen los niveles anunciados por el modelo activo. Auto usa el valor predeterminado del proveedor.",
261
+ "Effort support is unknown. Only auto is available; use /capabilities refresh to check.": "El soporte de esfuerzo es desconocido. Solo está disponible auto; usa /capabilities refresh para comprobarlo.",
262
+ "Current effort {effort} is not in the announced levels.": "El esfuerzo actual {effort} no está entre los niveles anunciados.",
263
+ "Current effort is unknown. Auto is the safe default.": "El esfuerzo actual es desconocido. Auto es el valor predeterminado seguro.",
264
+ "Provider default; no explicit effort is sent.": "Valor predeterminado del proveedor; no se envía un esfuerzo explícito.",
265
+ "Announced by the active model": "Anunciado por el modelo activo",
266
+ "Connection change queued. The active connection stays in use.": "Cambio de conexión en cola. Se sigue usando la conexión activa.",
267
+ "Checking the selected connection before applying it.": "Comprobando la conexión elegida antes de aplicarla.",
268
+ "Connection change applied.": "Cambio de conexión aplicado.",
269
+ "Connection change cancelled.": "Cambio de conexión cancelado.",
270
+ "Connection change failed. The previous connection is still active.": "Falló el cambio de conexión. La conexión anterior sigue activa.",
271
+ "Connection change could not be confirmed. Check the active connection before trying again.": "No se pudo confirmar el cambio de conexión. Revisa la conexión activa antes de volver a intentarlo.",
272
+ "Connection change not applied": "Cambio de conexión no aplicado",
273
+ "Connection status needs review": "El estado de conexión requiere revisión",
274
+ "Keep the active connection": "Conservar la conexión activa",
275
+ "Return to your draft without changing the connection.": "Vuelve a tu borrador sin cambiar la conexión.",
276
+ "Retry this selection": "Reintentar esta selección",
277
+ "Sign in to the selected provider": "Iniciar sesión con el proveedor elegido",
278
+ "Choose another connection": "Elegir otra conexión",
279
+ "Open the provider and workspace profile selector.": "Abre el selector de proveedores y perfiles del espacio de trabajo.",
280
+ "Sign in or check the configured credential environment variable.": "Inicia sesión o revisa la variable de entorno configurada para las credenciales.",
281
+ "The model server timed out.": "Se agotó el tiempo de espera del servidor del modelo.",
282
+ "The model server could not be reached.": "No se pudo contactar al servidor del modelo.",
283
+ "The active model does not support this setting.": "El modelo activo no admite esta configuración.",
284
+ "The change could not be verified.": "No se pudo verificar el cambio.",
285
+ "Checking /{command} {target} · active: {model}": "Comprobando /{command} {target} · activo: {model}",
286
+ "Queued /{command} {target} · active: {model}": "En cola /{command} {target} · activo: {model}",
287
+ "Connection applied; live-session presence could not be refreshed.": "Conexión aplicada; no se pudo actualizar la presencia de la sesión en vivo.",
288
+ "Model catalog unavailable; the active connection is unverified. No inference was sent.": "Catálogo de modelos no disponible; la conexión activa no está verificada. No se envió ninguna inferencia.",
289
+ "Cross-session messaging is unavailable on this platform. Local work remains available.": "Los mensajes entre sesiones no están disponibles en esta plataforma. El trabajo local sigue disponible.",
290
+ "Attach up to two images per message.": "Adjunta hasta dos imágenes por mensaje.",
291
+ "The session changed; the image was not attached.": "La sesión cambió; no se adjuntó la imagen.",
292
+ "Checking vision support…": "Comprobando soporte de visión…",
293
+ "{count} image(s) attached · Backspace removes a label": "{count} imagen(es) adjunta(s) · Retroceso elimina una etiqueta",
294
+ "This model cannot receive images or vision is unverified. Switch model or remove the image before sending.": "Este modelo no puede recibir imágenes o la visión no está verificada. Cambia de modelo o elimina la imagen antes de enviar.",
295
+ "Attaching image…": "Adjuntando imagen…",
296
+ "The session changed; the message was not sent.": "La sesión cambió; no se envió el mensaje.",
297
+ "Image not sent: the current model does not support verified vision.": "Imagen no enviada: el modelo actual no tiene soporte de visión verificado.",
298
+ "Use a plain message with images; commands do not accept image attachments.": "Usa un mensaje normal con imágenes; los comandos no aceptan imágenes adjuntas.",
299
+ "Choose a connection": "Elige una conexión",
300
+ "Setup values support up to 4096 bytes.": "Los valores de configuración admiten hasta 4096 bytes.",
301
+ "Connection settings": "Configuración de conexión",
302
+ "Connecting": "Conectando",
303
+ "Use at most 4096 bytes.": "Usa un máximo de 4096 bytes.",
304
+ "Check the value and try again.": "Revisa el valor e inténtalo de nuevo.",
305
+ "Ctrl+C cancels": "Ctrl+C cancela",
306
+ "Ctrl+C exits": "Ctrl+C sale",
307
+ "↑/↓ choose · Enter continues · Esc back": "↑/↓ elige · Enter continúa · Esc vuelve",
308
+ "Enter continues · Esc back": "Enter continúa · Esc vuelve",
309
+ "Esc back": "Esc vuelve",
310
+ "Working…": "Trabajando…",
311
+ "Checking…": "Comprobando…",
312
+ "The draft changed while preparing the image. Press Enter again to send the current draft.": "El borrador cambió mientras se preparaba la imagen. Pulsa Enter de nuevo para enviar el borrador actual."
249
313
  });
@@ -16,10 +16,17 @@ function headers(provider) {
16
16
  ? { 'x-api-key': provider.apiKey, 'anthropic-version': '2023-06-01' }
17
17
  : { 'x-goog-api-key': provider.apiKey };
18
18
  }
19
- async function json(provider, url, { signal, ...options } = {}) {
19
+ async function json(provider, url, { signal, throwHttp = false, ...options } = {}) {
20
20
  const timeout = AbortSignal.timeout(3000);
21
21
  const response = await provider.fetch(url, { ...options, headers: { 'content-type': 'application/json', ...headers(provider) }, signal: signal ? AbortSignal.any([signal, timeout]) : timeout, redirect: 'error' });
22
- if (!response.ok) { await response.body?.cancel(); return null; }
22
+ if (!response.ok) {
23
+ try { await response.body?.cancel(); } catch {}
24
+ // Optional capability probes can still be absent. A user-requested catalog
25
+ // check must distinguish unsupported routes from refused authentication or
26
+ // an unavailable server, without retaining an error body or request URL.
27
+ if (throwHttp) throw Object.assign(new Error('The model catalog request was refused.'), {code: 'MODEL_DISCOVERY_HTTP', status: response.status});
28
+ return null;
29
+ }
23
30
  const reader = response.body?.getReader();
24
31
  if (!reader) return null;
25
32
  let text = '', size = 0; const decoder = new TextDecoder();
@@ -43,8 +50,8 @@ export async function listProviderModels(provider, { signal } = {}) {
43
50
  do {
44
51
  const url = new URL(base(provider));
45
52
  if (next) url.searchParams.set(protocol(provider) === 'gemini' ? 'pageToken' : 'after_id', next);
46
- const data = await json(provider, url.href, { signal });
47
- if (!data) throw Object.assign(new Error('The server did not allow listing models.'), { code: 'MODEL_DISCOVERY_UNAVAILABLE' });
53
+ const data = await json(provider, url.href, { signal, throwHttp: true });
54
+ if (!data || (!Array.isArray(data.data) && !Array.isArray(data.models))) throw Object.assign(new Error('The server did not return a model catalog.'), { code: 'MODEL_DISCOVERY_UNAVAILABLE' });
48
55
  for (const item of Array.isArray(data.data) ? data.data : Array.isArray(data.models) ? data.models : []) { const safe = safeModel(item); if (safe && !models.some(model => model.id === safe.id)) models.push(safe); }
49
56
  next = protocol(provider) === 'gemini' ? data.nextPageToken : data.has_more ? data.last_id : '';
50
57
  } while (next && ++pages < 8 && models.length < 1024);
@@ -60,13 +60,18 @@ function mailbox(rows) {
60
60
  /** Local, same-user IPC. The private registry authenticates live instances; it is
61
61
  * not a sandbox against other programs already running as this OS user. */
62
62
  export class SessionBus {
63
- constructor({ store, getSessionId, getMetadata = () => ({}), getAuthority = () => ({}), getChain = () => ({}), directory = process.env.COMAL_PEER_DIR || path.join(os.homedir(), '.local/state/comal/peers'), inbound, onPending, onEvent, timeoutMs = 5000, ttlMs = TTL } = {}) {
63
+ constructor({ store, getSessionId, getMetadata = () => ({}), getAuthority = () => ({}), getChain = () => ({}), directory = process.env.COMAL_PEER_DIR || path.join(os.homedir(), '.local/state/comal/peers'), inbound, onPending, onEvent, timeoutMs = 5000, ttlMs = TTL, platform = process.platform } = {}) {
64
64
  if (!store || typeof getSessionId !== 'function') throw new TypeError('SessionBus requiere store y getSessionId.');
65
65
  if (inbound !== undefined && !['accept', 'hold', 'refuse'].includes(inbound)) throw fail('PEER_INBOUND_INVALID', 'Usa accept, hold o refuse.');
66
66
  Object.assign(this, { store, getSessionId, getMetadata, getAuthority, getChain, directory: path.resolve(directory), onPending, onEvent, timeoutMs, ttlMs: Math.min(TTL, Math.max(10, ttlMs)) });
67
+ this.availability = Object.freeze(platform === 'win32' ? { available: false, transport: 'none', code: 'PEER_PLATFORM_UNSUPPORTED', reason: 'Cross-session messaging is unavailable on native Windows; the current IPC requires POSIX private directories and Unix sockets.' } : { available: true, transport: 'unix' });
67
68
  this.state = 'idle'; this.inbound = inbound || 'accept'; this.inboundOverride = inbound; this.inboundConfigured = new Set(); this.sockets = new Set(); this._starting = undefined;
68
69
  }
70
+ _requireSupported() {
71
+ if (!this.availability.available) throw Object.assign(fail(this.availability.code, this.availability.reason), { availability: this.availability });
72
+ }
69
73
  async _directory() {
74
+ this._requireSupported();
70
75
  await mkdir(this.directory, { recursive: true, mode: 0o700 });
71
76
  const stat = await lstat(this.directory);
72
77
  if (!stat.isDirectory() || stat.isSymbolicLink() || (uid() !== undefined && stat.uid !== uid()) || (stat.mode & 0o077)) throw fail('PEER_DIRECTORY_UNSAFE', 'El directorio de peers debe pertenecer al usuario y tener permisos 0700, sin symlinks.');
@@ -128,6 +133,10 @@ export class SessionBus {
128
133
  throw fail('PEER_SESSION_IN_USE', 'La sesión está iniciando en otra instancia.');
129
134
  }
130
135
  async start() {
136
+ if (!this.availability.available) {
137
+ if (!this._unavailableReported) { this._unavailableReported = true; this._event({ type: 'peer_unavailable', ...this.availability }); }
138
+ return this;
139
+ }
131
140
  if (this._starting) return this._starting;
132
141
  this._starting = this._start();
133
142
  try { return await this._starting; } finally { this._starting = undefined; }
@@ -205,6 +214,7 @@ export class SessionBus {
205
214
  }
206
215
  async send(input = {}, { signal } = {}) { return this._send(input, { signal }); }
207
216
  async _send(input = {}, { signal, kind = 'message', chain, inheritedAuthority } = {}) {
217
+ this._requireSupported();
208
218
  await this.start(); if (signal?.aborted) throw fail('ABORT_ERR', 'Envío cancelado.');
209
219
  const { to, message, request_id = randomUUID(), reply_to, notify_when_idle = false, required_capabilities = [] } = input;
210
220
  if ((typeof message !== 'string' || !message.trim()) && !notify_when_idle) throw fail('PEER_MESSAGE_REQUIRED', 'Se requiere un mensaje o notify_when_idle.');
@@ -301,11 +311,13 @@ export class SessionBus {
301
311
  return result;
302
312
  }
303
313
  async pending() {
314
+ if (!this.availability.available) return { count: 0, held: 0, total: 0, ...this.availability };
304
315
  const id = this.getSessionId(); this._paths(id); const state = mailbox((await this.store.load(id)).rows);
305
316
  const held = state.live.filter(item => item.held && !state.released.has(item.message_id)).length;
306
317
  return { count: this.inbound==='accept' ? state.live.length - held : 0, held, total: state.live.length };
307
318
  }
308
319
  async drain() {
320
+ if (!this.availability.available) return [];
309
321
  const id = this.getSessionId(); this._paths(id);
310
322
  if (this.inbound !== 'accept') return [];
311
323
  return this.store.workTransaction(id, async () => {
@@ -322,6 +334,7 @@ export class SessionBus {
322
334
  });
323
335
  }
324
336
  async releaseHeld(id = 'all') {
337
+ this._requireSupported();
325
338
  const sessionId = this.getSessionId(); this._paths(sessionId);
326
339
  const count = await this.store.workTransaction(sessionId, async () => {
327
340
  const state = mailbox((await this.store.load(sessionId)).rows); let released = 0;
@@ -331,6 +344,7 @@ export class SessionBus {
331
344
  if (count && this.inbound === 'accept') this._signal(); return { released: count };
332
345
  }
333
346
  async setInbound(value) {
347
+ this._requireSupported();
334
348
  if (!['accept', 'hold', 'refuse'].includes(value)) throw fail('PEER_INBOUND_INVALID', 'Usa accept, hold o refuse.');
335
349
  const id = this.getSessionId(); this._paths(id);
336
350
  const previous = this._inboundSetting || Promise.resolve();
@@ -350,7 +364,7 @@ export class SessionBus {
350
364
  if (!['busy', 'idle'].includes(value)) throw fail('PEER_STATE_INVALID', 'Usa busy o idle.');
351
365
  this.state = value; if (this.server) await this._publish();
352
366
  if (value === 'idle' && this.server && this.inbound === 'accept') await this._notifyIdle();
353
- return { state: this.state };
367
+ return { state: this.state, ...(!this.availability.available ? this.availability : {}) };
354
368
  }
355
369
  async _notifyIdle() {
356
370
  if (this.notifying || !this.server) return; this.notifying = true;
@@ -0,0 +1,111 @@
1
+ import { t } from '../i18n/index.mjs';
2
+ import { EFFORT_LEVELS } from '../model-controls.mjs';
3
+ import { safeText } from './text.mjs';
4
+
5
+ // These snapshots are presentation data, not credentials or executable command
6
+ // fragments. Never change an identifier while sanitizing its display label.
7
+ const controls = /[\s\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/u;
8
+ const id = value => typeof value === 'string' && value.length > 0 && value.length <= 256 && !controls.test(value) ? value : null;
9
+ const text = (value, length = 240) => typeof value === 'string'
10
+ ? safeText(value).replace(/[\u202a-\u202e\u2066-\u2069]/gu, '').replace(/\s+/gu, ' ').trim().slice(0, length) : '';
11
+ const list = values => Array.isArray(values) ? values.slice(0, 1024) : [];
12
+ const join = values => values.filter(Boolean).join(' · ');
13
+ const unique = values => [...new Map(values.map(value => [value.id, value])).values()];
14
+
15
+ function authState(provider, discovery) {
16
+ const snapshots = discovery.authStatus;
17
+ const snapshot = Array.isArray(snapshots) ? snapshots.find(item => item?.provider === provider.id)
18
+ : snapshots && typeof snapshots === 'object' ? snapshots[provider.id] : null;
19
+ const value = provider.status ?? (typeof snapshot === 'string' ? snapshot : snapshot?.status);
20
+ const states = { authenticated: t('Signed in'), configured: t('Credentials configured'), logged_out: t('Not signed in'), expired: t('Sign-in expired') };
21
+ return typeof value === 'string' && Object.hasOwn(states, value) ? states[value] : t('Authentication status unknown');
22
+ }
23
+
24
+ function authItems(discovery) {
25
+ const seen = new Set();
26
+ return list(discovery.authProviders).flatMap(value => {
27
+ const provider = typeof value === 'string' ? { id: value } : value;
28
+ const key = id(provider?.id);
29
+ if (!key || seen.has(key)) return [];
30
+ seen.add(key);
31
+ return [{ id: key, label: text(provider.label, 100) || key, description: join([key, authState(provider, discovery), text(provider.description)]), kind: 'auth' }];
32
+ });
33
+ }
34
+
35
+ function providerItems(discovery) {
36
+ const seen = new Set();
37
+ const profiles = list(discovery.profiles).flatMap(value => {
38
+ const profile = typeof value === 'string' ? { name: value } : value;
39
+ const key = id(profile?.name);
40
+ if (!key || seen.has(key)) return [];
41
+ seen.add(key);
42
+ const authProvider = id(profile.authProvider);
43
+ const auth = authProvider && list(discovery.authProviders).find(value => value?.id === authProvider);
44
+ return [{ id: key, label: key, description: join([t('Workspace profile'), text(profile.provider || profile.protocol, 40), text(profile.model, 100), authProvider ? authState(auth || { id: authProvider }, discovery) : t('Authentication status unknown'), text(profile.description)]), kind: 'profile' }];
45
+ });
46
+ // /provider resolves configured names first; the picker must show exactly the
47
+ // same choice instead of a second, misleading builtin with the same argument.
48
+ return [...profiles, ...authItems(discovery).filter(value => !seen.has(value.id))];
49
+ }
50
+
51
+ /** Build an inert, searchable connection menu from an already-read snapshot.
52
+ * Opening this helper performs no auth, filesystem, model or network requests.
53
+ * Current is actual metadata only: an unavailable current effort is described,
54
+ * never silently replaced by an invented selectable capability.
55
+ */
56
+ export function connectionOptions(kind, { discovery = {}, metadata = {}, filter = '' } = {}) {
57
+ if (kind === 'models') kind = 'model';
58
+ let title, description, items, current;
59
+ if (kind === 'login') {
60
+ title = t('Sign in to a model provider');
61
+ description = t('Choose how to authenticate. Your Comal account for Artifacts is separate.');
62
+ items = authItems(discovery);
63
+ // Being active is independent of being authenticated; do not mark a login
64
+ // as a no-op merely because that provider is already selected.
65
+ current = id(metadata.authProvider);
66
+ } else if (kind === 'provider') {
67
+ title = t('Choose a provider');
68
+ description = t('Choose a workspace profile or login provider. The connection is checked before replacing the active provider.');
69
+ items = providerItems(discovery);
70
+ current = id(metadata.profile);
71
+ if (!current) {
72
+ const auth = id(metadata.authProvider);
73
+ if (items.some(item => item.id === auth && item.kind === 'auth')) current = auth;
74
+ }
75
+ } else if (kind === 'model') {
76
+ title = t('Choose a model');
77
+ description = t('Provider: {provider}. The selected model applies to the next call; active work keeps its model.', { provider: text(metadata.profile || metadata.authProvider || metadata.provider, 100) || 'openai' });
78
+ current = id(metadata.model);
79
+ const seen = new Set();
80
+ items = list(discovery.models).flatMap(value => {
81
+ const model = typeof value === 'string' ? { id: value } : value;
82
+ const key = id(model?.id);
83
+ if (!key || seen.has(key)) return [];
84
+ seen.add(key);
85
+ const limit = Number.isSafeInteger(model.inputTokenLimit) && model.inputTokenLimit > 0 ? t('{count} input tokens', { count: model.inputTokenLimit }) : '';
86
+ return [{ id: key, label: key, description: join([text(model.display_name || model.displayName), limit]), kind: 'model' }];
87
+ });
88
+ } else if (kind === 'effort') {
89
+ title = t('Choose effort');
90
+ const report = discovery.capabilities || metadata.modelCapabilities;
91
+ const capability = report?.effort;
92
+ const stale = report?.model && metadata.model && report.model !== metadata.model;
93
+ const supported = stale ? null : capability?.supported;
94
+ // Explicit unsupported/stale metadata outranks an older autocomplete list.
95
+ const announced = stale || supported === false ? [] : capability?.levels ?? discovery.effortLevels;
96
+ const levels = new Set(list(announced));
97
+ const selectable = EFFORT_LEVELS.filter(level => level === 'auto' || levels.has(level));
98
+ current = EFFORT_LEVELS.includes(metadata.effort) ? metadata.effort : null;
99
+ description = supported === false ? t('This model does not announce configurable effort. Auto uses the provider default.')
100
+ : !stale && (supported === true || selectable.length > 1) ? t('Only levels announced by the active model are offered. Auto uses the provider default.')
101
+ : t('Effort support is unknown. Only auto is available; use /capabilities refresh to check.');
102
+ if (current && !selectable.includes(current)) description += ' ' + t('Current effort {effort} is not in the announced levels.', { effort: current });
103
+ if (!current) description += ' ' + t('Current effort is unknown. Auto is the safe default.');
104
+ items = selectable.map(level => ({ id: level, label: level, description: level === 'auto' ? t('Provider default; no explicit effort is sent.') : t('Announced by the active model'), kind: 'effort' }));
105
+ } else throw new TypeError('Unknown connection selector.');
106
+
107
+ const term = text(filter, 256).toLocaleLowerCase();
108
+ items = unique(items).map(item => ({ ...item, current: item.id === current, noOp: kind !== 'login' && item.id === current, ...(item.id === current ? { badge: t('Current') } : {}) }))
109
+ .filter(item => !term || `${item.id} ${item.label} ${item.description}`.toLocaleLowerCase().includes(term));
110
+ return { title, description, items, selected: items.some(item => item.id === current) ? current : undefined, command: kind, current };
111
+ }
@@ -0,0 +1,134 @@
1
+ // UI projection only. Admission, persistence, authentication and actual provider
2
+ // replacement remain the responsibility of the queue and local control boundary.
3
+ const COMMANDS = new Set(['model', 'provider', 'effort', 'login', 'logout']);
4
+ const CHANGES = new Set(['model', 'provider', 'effort']);
5
+ const TERMINAL = new Set(['applied', 'failed', 'cancelled']);
6
+ const REASONS = new Set(['cancelled', 'timeout', 'authentication', 'unavailable', 'unsupported', 'unknown', 'superseded']);
7
+ const KEYS = ['provider', 'authProvider', 'profile', 'model', 'effort'];
8
+ const MESSAGES = Object.freeze({
9
+ pending: 'Connection change queued. The active connection stays in use.',
10
+ applying: 'Checking the selected connection before applying it.',
11
+ applied: 'Connection change applied.',
12
+ cancelled: 'Connection change cancelled.',
13
+ failed: 'Connection change failed. The previous connection is still active.',
14
+ uncertain: 'Connection change could not be confirmed. Check the active connection before trying again.',
15
+ });
16
+
17
+ function label(value) {
18
+ if (typeof value !== 'string') return '';
19
+ // Never render terminal escapes or URL credentials/query strings in a status.
20
+ let text = value.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x1f\x7f-\x9f]/g, '');
21
+ text = text.replace(/https?:\/\/[^\s]+/gi, '[endpoint]');
22
+ return [...text].slice(0, 120).join('');
23
+ }
24
+ function selection(value = {}) {
25
+ return Object.fromEntries(KEYS.filter(key => typeof value[key] === 'string').map(key => [key, label(value[key])]));
26
+ }
27
+ function action(value) {
28
+ if (!value || !COMMANDS.has(value.command) || typeof value.argument !== 'string') throw new TypeError('Invalid connection action.');
29
+ const argument = value.argument.trim();
30
+ if (!argument || argument.length > 512 || /[\x00-\x1f\x7f-\x9f]/.test(argument)) throw new TypeError('Invalid connection argument.');
31
+ if (['model', 'effort'].includes(value.command) && /\s/.test(argument)) throw new TypeError('Connection selection must be one identifier.');
32
+ if (['login', 'logout'].includes(value.command) && !/^[a-z][a-z0-9_-]*(?: --api-key-env [A-Za-z_][A-Za-z0-9_]*)?$/.test(argument)) throw new TypeError('Invalid credential control action.');
33
+ return {command: value.command, argument};
34
+ }
35
+
36
+ export function connectionAction(text) {
37
+ if (typeof text !== 'string') return null;
38
+ const match = /^\/(model|provider|effort|login|logout)\s+([^\r\n]+)$/.exec(text.trim());
39
+ if (!match || match[1]==='login' && match[2] === 'status') return null;
40
+ try { return action({command: match[1], argument: match[2]}); } catch { return null; }
41
+ }
42
+
43
+ // Intentionally ignores error.message, stack, HTTP response bodies and URLs.
44
+ export function connectionFailureReason(error) {
45
+ if (error?.name === 'AbortError' || error?.code === 'ABORT_ERR') return 'cancelled';
46
+ if (error?.name === 'TimeoutError' || ['ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'MODEL_TIMEOUT'].includes(error?.code)) return 'timeout';
47
+ if ([401, 403].includes(error?.status) || ['AUTH_REQUIRED', 'UNAUTHENTICATED','PROVIDER_AUTH_REQUIRED','CONNECTION_AUTH_REQUIRED'].includes(error?.code)) return 'authentication';
48
+ if(Number.isInteger(error?.status) && error.status>=500)return 'unavailable';
49
+ if (['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH', 'EAI_AGAIN'].includes(error?.code)) return 'unavailable';
50
+ if (['MODEL_CONTROL_UNSUPPORTED', 'MODEL_EFFORT_UNAVAILABLE', 'UNSUPPORTED_EFFORT'].includes(error?.code)) return 'unsupported';
51
+ return 'unknown';
52
+ }
53
+
54
+ export class ConnectionTransitions {
55
+ constructor(active = {}, {limit = 64} = {}) {
56
+ if (!Number.isInteger(limit) || limit < 1 || limit > 256) throw new TypeError('Invalid connection history limit.');
57
+ this.active = selection(active); this.limit = limit; this.records = new Map();
58
+ this.sequence = 0; this.latestApplied = 0;
59
+ }
60
+ admit(id, requested, {queued = true} = {}) {
61
+ if (typeof id !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/.test(id) || this.records.has(id)) throw new TypeError('Connection action ID must be new.');
62
+ const intent = action(requested);
63
+ // Keep unfinished actions: history pressure must never hide queued controls.
64
+ if (this.records.size >= this.limit) {
65
+ const oldest = [...this.records.values()].find(record => TERMINAL.has(record.state));
66
+ if (!oldest) throw new Error('Connection action history is full.');
67
+ this.records.delete(oldest.id);
68
+ }
69
+ this.records.set(id, {id, intent, queued: Boolean(queued), state: 'pending', attempt: null, sequence: 0, revision: 0, unchanged: false, reason: null});
70
+ return this.get(id);
71
+ }
72
+ revisePending(id, requested) {
73
+ const record = this.records.get(id);
74
+ if (!record || record.state !== 'pending') return false;
75
+ const intent = action(requested);
76
+ if (record.intent.command === intent.command && record.intent.argument === intent.argument) return false;
77
+ record.intent = intent; record.revision++;
78
+ return true;
79
+ }
80
+ start(id) {
81
+ const record = this.records.get(id);
82
+ if (!record || record.state !== 'pending') return null;
83
+ record.state = 'applying'; record.attempt = ++this.sequence; record.sequence = record.attempt;
84
+ return record.attempt;
85
+ }
86
+ complete(id, attempt, {outcome, active, unchanged = false, reason, error} = {}) {
87
+ const record = this.records.get(id);
88
+ if (!record || record.state !== 'applying' || record.attempt !== attempt) return false;
89
+ if (!['applied', 'failed', 'cancelled'].includes(outcome)) throw new TypeError('Explicit connection outcome required.');
90
+ // A late result may close its card but cannot roll back a newer application.
91
+ if (attempt < this.latestApplied) {
92
+ record.state = 'cancelled'; record.reason = 'superseded'; record.unchanged = false;
93
+ return false;
94
+ }
95
+ if (outcome === 'applied') {
96
+ if (!active || typeof active !== 'object') throw new TypeError('Applied connection requires authoritative metadata.');
97
+ this.active = selection(active); this.latestApplied = attempt;
98
+ }
99
+ record.state = outcome; record.unchanged = outcome !== 'applied' && unchanged === true;
100
+ record.reason = outcome === 'applied' ? null : REASONS.has(reason) ? reason : connectionFailureReason(error);
101
+ return true;
102
+ }
103
+ cancel(id) {
104
+ const record = this.records.get(id);
105
+ // Removing a queued item is certain. An in-flight login/provider operation
106
+ // must settle at its actual control boundary, never by optimistic UI cancel.
107
+ if (!record || record.state !== 'pending') return false;
108
+ record.state = 'cancelled'; record.reason = 'cancelled'; record.unchanged = true;
109
+ return true;
110
+ }
111
+ canRetry(id) {
112
+ const record = this.records.get(id);
113
+ return Boolean(record?.state === 'failed' && record.queued && record.unchanged && CHANGES.has(record.intent.command));
114
+ }
115
+ retry(id, newId, {confirmed = false} = {}) {
116
+ if (confirmed !== true || !this.canRetry(id)) return null;
117
+ const previous = this.records.get(id), intent = {...previous.intent};
118
+ this.admit(newId, intent); this.records.get(newId).retryOf = id;
119
+ // This is a new admission proposal, not execution or a queue mutation.
120
+ return {...intent, text: `/${intent.command} ${intent.argument}`, retryOf: id, id: newId};
121
+ }
122
+ get(id) {
123
+ const record = this.records.get(id); if (!record) return null;
124
+ const uncertain = ['failed', 'cancelled'].includes(record.state) && !record.unchanged && record.reason !== 'superseded';
125
+ return {id: record.id, revision: record.revision, command: record.intent.command, target: label(record.intent.argument), state: record.state,
126
+ message: MESSAGES[uncertain ? 'uncertain' : record.state], reason: record.reason,
127
+ confirmedUnchanged: record.unchanged, retryable: this.canRetry(id),
128
+ ...(record.retryOf ? {retryOf: record.retryOf} : {})};
129
+ }
130
+ snapshot() {
131
+ const changes = [...this.records.keys()].map(id => this.get(id));
132
+ return {active: {...this.active}, pending: changes.filter(item => ['pending', 'applying'].includes(item.state)), changes, latest: changes.at(-1) || null};
133
+ }
134
+ }
@@ -9,6 +9,9 @@ import { shellCommands } from '../auto-guard.mjs';
9
9
  import { findImageCandidates } from '../image-attachments.mjs';
10
10
  import { Composer } from './composer.mjs';
11
11
  import { isRemoteCommand } from '../remote-commands.mjs';
12
+ import { randomUUID } from 'node:crypto';
13
+ import { connectionOptions } from './connection-options.mjs';
14
+ import { ConnectionTransitions, connectionAction, connectionFailureReason } from './connection-transition.mjs';
12
15
 
13
16
  const IMMEDIATE = new Set(['cloud', 'remote-control', 'rc', 'help', 'copy', 'select', 'status', 'context', 'tools', 'house', 'state', 'audit', 'board', 'tasks', 'work', 'operations', 'plugins', 'sessions', 'resume', 'clear', 'model', 'models', 'effort', 'capabilities', 'login', 'logout', 'permissions', 'rename', 'table', 'queue', 'agents', 'mcp','list-agents','inbox','providers','provider', 'exit', 'quit']);
14
17
 
@@ -28,12 +31,25 @@ export class InteractiveController {
28
31
  attachSession(session) {
29
32
  this.session = session; this.ui.loadSession(session); this.ui.updateMetadata(this.backend.metadata());
30
33
  const id = session.id;
34
+ this.connections=new ConnectionTransitions(this.backend.metadata());this.connectionItems=new Map();
31
35
  this.queue = new MessageQueue(session.queue, snapshot => this.backend.saveQueue(id, snapshot));
32
36
  this.runner = new QueueRunner(this.queue, async item => {
33
37
  if(item.remote){await this.backend.validateRemote(item);if(item.text.trimStart().startsWith('/')&&!isRemoteCommand(item.text))throw new Error('This command is only available in the local terminal.');}
34
38
  this.ui.setBusy(true);
35
- try { return await this.backend.execute(item.text, { userIntent: item.text, attachments: item.attachments || [], shouldYield: () => this.queue.items.length > 0 }); }
36
- finally { this.ui.setBusy(false); }
39
+ const change=this.connectionItems.get(item.id),attempt=change?this.connections.start(change.id):null,before=this.connectionIdentity();
40
+ this.syncConnectionStatus();
41
+ try {
42
+ const result=await this.backend.execute(item.text,{userIntent:item.text,attachments:item.attachments || [],shouldYield:()=>this.queue.items.length>0});
43
+ if(change && attempt!==null){
44
+ const failed=Boolean(result),unchanged=before===this.connectionIdentity() && ['model','provider','effort'].includes(change.action.command);
45
+ this.connections.complete(change.id,attempt,{outcome:failed?'failed':'applied',active:this.backend.metadata(),unchanged});
46
+ if(failed)this.scheduleConnectionRecovery(change.id,change.action);
47
+ }
48
+ return result;
49
+ }catch(error){
50
+ if(change && attempt!==null){this.connections.complete(change.id,attempt,{outcome:connectionFailureReason(error)==='cancelled'?'cancelled':'failed',active:this.backend.metadata(),unchanged:before===this.connectionIdentity() && ['model','provider','effort'].includes(change.action.command),error});this.scheduleConnectionRecovery(change.id,change.action);}
51
+ throw error;
52
+ }finally {this.ui.setBusy(false);this.ui.updateMetadata(this.backend.metadata());this.syncConnectionStatus();}
37
53
  }, { blocked: () => Boolean(this.peerProcessing || this.ui.selecting || this.modalOverlay() || this.ui.editingQueue || this.switching || this.closed), onChange: () => { this.syncQueue(); this.refreshDiscovery(); }, onError: error => this.ui.notice(error.message, true) });
38
54
  this.syncQueue(); this.refreshDiscovery();
39
55
  if (this.queue.items.length || this.queue.interrupted.length) this.ui.notice(t("The recovered queue is paused. Check F4 before resuming; interrupted messages were not resent."));
@@ -47,7 +63,7 @@ export class InteractiveController {
47
63
  finally{this.ui.setBusy(false);}
48
64
  })().catch(error=>this.ui.notice(error.message,true)).finally(()=>{this.peerProcessing=null;this.runner.kick();});
49
65
  }
50
- syncQueue() { this.ui.setQueue(this.queue.snapshot()); if (this.ui.overlay?.kind === 'queue') this.ui.overlay.index = Math.min(this.ui.overlay.index, Math.max(0, this.queue.items.length - 1)); }
66
+ syncQueue() { this.syncConnectionQueue();this.ui.setQueue(this.queue.snapshot()); if (this.ui.overlay?.kind === 'queue') this.ui.overlay.index = Math.min(this.ui.overlay.index, Math.max(0, this.queue.items.length - 1)); }
51
67
  scheduleDraft(text, attachments = this.ui.composer.attachments || []) {
52
68
  clearTimeout(this.draftTimer); this.pendingDraft = { id: this.session.id, text, attachments };
53
69
  this.draftTimer = setTimeout(() => { this.flushDraft().catch(e => this.ui.notice(e.message, true)); }, 500);
@@ -155,7 +171,7 @@ export class InteractiveController {
155
171
  }
156
172
  if (['rc','remote-control'].includes(command) && ['', 'start'].includes(arg.trim()))await this.requireIdle();
157
173
  if (command === 'help') { this.ui.showHelp(); return; }
158
- if (command==='models' || command==='model' && !arg.trim() || command==='login' && !arg.trim()) return this.chooseConnection(command,arg);
174
+ if (command==='models' || ['model','login','provider','effort'].includes(command) && !arg.trim()) return this.chooseConnection(command,arg);
159
175
  if (command === 'sessions' || (command === 'resume' && !arg)) { this.ui.showSessions(await this.backend.listSessions()); return; }
160
176
  if (command === 'resume' || command === 'clear') {
161
177
  await this.requireIdle(); this.switching = true;
@@ -174,6 +190,7 @@ export class InteractiveController {
174
190
  }
175
191
  if(command==='work'&&(arg.trim()==='recover'||arg.trim().startsWith('review '))){await this.requireIdle();const output=await this.backend.control(command,arg);if(output)this.ui.notice(output);return;}
176
192
  if (['board', 'tasks', 'work'].includes(command)) { this.ui.showBoard(JSON.parse(await this.backend.control('board', ''))); return; }
193
+ if(connectionAction(`/${command} ${arg}`)){await this.requireIdle();return this.connectionControl(command,arg);}
177
194
  if (['cloud', 'model', 'effort', 'provider', 'login', 'logout'].includes(command) && (arg.trim() || ['login', 'logout'].includes(command))) await this.requireIdle();
178
195
  if (command === 'table') { if (!arg) throw new Error(t("Use /table file.csv|tsv|json|md.")); const table = await this.backend.readTable(arg); this.ui.addTable(table.text, table.format, arg); return; }
179
196
  const changing=(['cloud','model','effort','provider','login','logout'].includes(command) && (arg.trim() || ['login','logout'].includes(command))) || (['rc','remote-control'].includes(command) && ['', 'start'].includes(arg.trim()));
@@ -184,42 +201,88 @@ export class InteractiveController {
184
201
  } finally {if(changing){this.switching=false;this.runner.kick();}}
185
202
 
186
203
  }
204
+ connectionIdentity() {
205
+ const meta=this.backend.metadata();return JSON.stringify(['provider','authProvider','profile','model','effort'].map(key=>meta[key] || null));
206
+ }
207
+ syncConnectionStatus() {if(this.connections)this.ui.setConnectionStatus?.(this.connections.snapshot());}
208
+ syncConnectionQueue() {
209
+ if(!this.connections || !this.queue)return;
210
+ const live=new Set([...this.queue.items,this.queue.running].filter(Boolean).map(item=>item.id));
211
+ for(const item of this.queue.items){
212
+ const action=connectionAction(item.text),old=this.connectionItems.get(item.id);
213
+ if(old?.text===item.text)continue;
214
+ if(action){
215
+ if(old && this.connections.get(old.id)?.state==='pending'){this.connections.revisePending(old.id,action);this.connectionItems.set(item.id,{...old,text:item.text,action});}
216
+ else {const id=old?randomUUID():item.id;this.connections.admit(id,action);this.connectionItems.set(item.id,{id,text:item.text,action});}
217
+ }else if(old){this.connections.cancel(old.id);this.connectionItems.set(item.id,{...old,text:item.text});}
218
+ }
219
+ for(const [queueId,change]of this.connectionItems)if(!live.has(queueId)){this.connections.cancel(change.id);this.connectionItems.delete(queueId);}
220
+ this.syncConnectionStatus();
221
+ }
222
+ async connectionControl(command,argument){
223
+ const requested=connectionAction(`/${command} ${argument}`),id=randomUUID(),before=this.connectionIdentity();
224
+ this.connections.admit(id,requested);const attempt=this.connections.start(id);this.switching=true;this.syncConnectionStatus();
225
+ let failure;
226
+ try{
227
+ const output=await this.backend.control(command,argument);if(output)this.ui.notice(output);
228
+ this.connections.complete(id,attempt,{outcome:'applied',active:this.backend.metadata()});
229
+ try{await this.backend.refreshConnections?.();}catch{}
230
+ }catch(error){
231
+ const reason=connectionFailureReason(error);this.connections.complete(id,attempt,{outcome:reason==='cancelled'?'cancelled':'failed',active:this.backend.metadata(),unchanged:before===this.connectionIdentity() && ['model','provider','effort'].includes(command),error});failure=error;
232
+ }finally{this.switching=false;this.ui.updateMetadata(this.backend.metadata());this.refreshDiscovery();this.syncConnectionStatus();this.runner.kick();}
233
+ if(failure){const state=this.connections.get(id);this.ui.notice(t(state.message),true);await this.recoverConnection(id,requested);}
234
+ }
235
+ scheduleConnectionRecovery(id,action){
236
+ const runner=this.runner;
237
+ // Finishing a queued control persists its outcome asynchronously. Wait for
238
+ // that boundary; one event-loop tick can still find the runner occupied.
239
+ Promise.resolve(runner.processing).catch(()=>{}).then(()=>{
240
+ if(this.runner===runner)setImmediate(()=>this.recoverConnection(id,action).catch(()=>{}));
241
+ });
242
+ }
243
+ async recoverConnection(id,action){
244
+ if(this.closed || this.ui.busy || this.runner.processing || this.modalOverlay() || this.ui.permission)return;
245
+ const state=this.connections.get(id);if(state?.state!=='failed')return;
246
+ const items=[{id:'keep',label:t('Keep the active connection'),description:t('Return to your draft without changing the connection.')}];
247
+ if(state.retryable)items.push({id:'retry',label:t('Retry this selection'),description:`/${action.command} ${state.target}`});
248
+ const discovery=this.backend.discovery?.() || {},profile=discovery.profiles?.find(p=>p.name===action.argument);
249
+ const loginProvider=profile?.authProvider || (discovery.authProviders?.some(p=>p.id===action.argument)?action.argument:null);
250
+ if(state.reason==='authentication' && action.command==='provider' && loginProvider)items.push({id:'login',label:t('Sign in to the selected provider'),description:loginProvider});
251
+ items.push({id:'choose',label:t('Choose another connection'),description:t('Open the provider and workspace profile selector.')});
252
+ const reason={authentication:'Sign in or check the configured credential environment variable.',timeout:'The model server timed out.',unavailable:'The model server could not be reached.',unsupported:'The active model does not support this setting.',unknown:'The change could not be verified.'}[state.reason];
253
+ const choice=await this.ui.setupChoice({title:t(state.confirmedUnchanged?'Connection change not applied':'Connection status needs review'),description:t(state.message)+(reason?'\n\n'+t(reason):''),items});
254
+ if(this.closed)return;
255
+ if(choice==='retry' && this.connections.canRetry(id))await this.submit(`/${action.command} ${action.argument}`);
256
+ else if(choice==='login')await this.submit(`/login ${loginProvider}`);
257
+ else if(choice==='choose')await this.chooseConnection('provider');
258
+ this.runner.kick();
259
+ }
187
260
  async chooseConnection(command, filter='') {
188
261
  if(this.modalOverlay() || this.ui.permission)return;
189
- const identity=()=>JSON.stringify([this.session.id,...['provider','authProvider','profile','model'].map(key=>this.backend.metadata()[key])]);
190
- const original=identity(), abort=new AbortController();let selected;
191
- try {
192
- let items, current, title, description;
193
- if(command==='login'){
194
- const providers=this.backend.discovery?.().authProviders || [];
195
- items=providers.map(p=>({id:p.id,label:p.label || p.id,description:`${p.id} · ${p.description || ''}`}));
196
- title=t('Sign in to a model provider');description=t('Choose how to authenticate. Your Comal account for Artifacts is separate.');
197
- }else{
262
+ const identity=()=>JSON.stringify([this.session.id,this.connectionIdentity()]),original=identity(),abort=new AbortController();
263
+ try{
264
+ await this.backend.refreshConnections?.();
265
+ let discovery=this.backend.discovery?.() || {};
266
+ if(['model','models'].includes(command)){
198
267
  this.ui.setupStatus({title:t('Finding models'),description:t('Reading the current provider catalog. No task is sent. Esc cancels.')});
199
- const previous=this.ui.callbacks.onSetupCancel;
200
- this.ui.callbacks.onSetupCancel=()=>abort.abort();
268
+ const state=this.ui.setup,previous=this.ui.callbacks.onSetupCancel;this.ui.callbacks.onSetupCancel=()=>abort.abort();
201
269
  let models;
202
- try {models=await this.backend.listModels({signal:abort.signal});}
203
- finally {this.ui.callbacks.onSetupCancel=previous;this.ui.clearSetup();}
270
+ try{models=await this.backend.listModels({signal:abort.signal});}
271
+ finally{this.ui.callbacks.onSetupCancel=previous;if(this.ui.setup===state)this.ui.clearSetup();}
204
272
  if(abort.signal.aborted || this.closed || identity()!==original)return;
205
- this.refreshDiscovery();
206
- const meta=this.backend.metadata();current=meta.model;
207
- items=models.filter(item=>!filter || item.id.toLowerCase().includes(filter.trim().toLowerCase())).map(m=>({id:m.id,label:m.id,badge:m.id===current?t('Current'):'',description:[m.display_name || m.displayName,m.inputTokenLimit?`${m.inputTokenLimit} input tokens`:null].filter(Boolean).join(' · ')}));
208
- title=t('Choose a model');description=t('Provider: {provider}. The selected model applies to the next call; active work keeps its model.',{provider:meta.authProvider || meta.profile || meta.provider || 'openai'});
273
+ this.refreshDiscovery();discovery={...(this.backend.discovery?.() || {}),models};
209
274
  }
210
- if(!items.length){this.ui.notice(t('No options found. Check the provider or use /model ID.'),true);return;}
211
- items=[...new Map(items.map(item=>[item.id,item])).values()];
212
- selected=await this.ui.setupChoice({title,description,items,selected:current,searchable:true,signal:abort.signal});
275
+ const menu=connectionOptions(command,{discovery,metadata:this.backend.metadata(),filter});
276
+ if(!menu.items.length){this.ui.notice(t('No options found. Check the provider or use /model ID.'),true);return;}
277
+ const selected=await this.ui.setupChoice({...menu,searchable:true,signal:abort.signal});
213
278
  if(!selected || this.closed)return;
214
279
  if(identity()!==original)throw new Error(t('The provider changed. Open the model list again.'));
215
- if(command!=='login' && selected===current)return;
216
- // Re-enter normal admission: a busy model/login change is queued, never
217
- // applied by a picker or while an inference call is in progress.
218
- await this.submit(`/${command==='login'?'login':'model'} ${selected}`);
219
- if(this.ui.busy)this.ui.notice(t('Selection queued. It will apply after the current turn.'));
220
- } catch(error) {if(!abort.signal.aborted)throw error;}
221
- finally {this.runner.kick();}
280
+ if(menu.items.find(item=>item.id===selected)?.noOp)return;
281
+ await this.submit(`/${menu.command} ${selected}`);
282
+ }catch(error){if(!abort.signal.aborted)throw error;}
283
+ finally{this.runner.kick();}
222
284
  }
285
+
223
286
  async action(name, arg) {
224
287
  if (name === 'exit') return this.exit();
225
288
  if (name === 'interrupt') { this.backend.interrupt(); await this.queue.setPaused(true); this.syncQueue(); return; }
@@ -179,6 +179,15 @@ export class TerminalScreen {
179
179
  motionEnabled() { return brandMotionEnabled({ env: this.env, stream: this.output }); }
180
180
  brandFrame() { return Math.floor((Date.now() - this.brandStarted) / BRAND_FRAME_INTERVAL_MS); }
181
181
  spinner() { return SPINNER[(this.motionEnabled() ? this.frame : 0) % SPINNER.length]; }
182
+ setConnectionStatus(snapshot) { this.connectionStatus=structuredClone(snapshot);this.schedule(); }
183
+ connectionHint() {
184
+ const snapshot=this.connectionStatus;if(!snapshot)return '';
185
+ const pending=snapshot.pending?.find(item=>item.state==='applying') || snapshot.pending?.[0];
186
+ if(pending)return t(pending.state==='applying'?'Checking /{command} {target} · active: {model}':'Queued /{command} {target} · active: {model}',{command:pending.command,target:pending.target,model:safeText(this.metadata.model || '?')}) + (snapshot.pending.length>1?` · +${snapshot.pending.length-1}`:'');
187
+ const latest=snapshot.latest;
188
+ if(latest?.state==='failed')return t(latest.message);
189
+ return '';
190
+ }
182
191
  setDiscovery(context = {}) { this.discovery = structuredClone(context); this.schedule(); }
183
192
  discoveryContext() { return { ...this.discovery, effortLevels: this.metadata.modelCapabilities?.effort?.levels || this.discovery.effortLevels, busy: this.busy, queue: this.queue, mode: this.mode, selecting: this.selecting, editingQueue: this.editingQueue }; }
184
193
  refreshDiscovery() { this.callbacks.onDiscover?.(); }
@@ -661,7 +670,7 @@ export class TerminalScreen {
661
670
  const footer = [clip(bar, columns), tone.muted('─'.repeat(columns)), composerHeading];
662
671
  const offset = Math.max(0, composer.cursorRow - composerHeight + 1);
663
672
  const inputLines = Array.from({ length: composerHeight }, (_, i) => tone.muted(i === 0 ? ' ❯ ' : ' │ ') + (composer.lines[offset + i] || ''));
664
- const hints = this.selecting ? tone.warning(t(" SELECTION · Drag and copy from your terminal · F5/Esc returns")) : this.attachmentStatus ? (['unsupported','invalid'].includes(this.attachmentStatus.state) ? tone.warning : tone.muted)(' ' + safeText(this.attachmentStatus.message)) : tone.muted(' ' + (completions.items.length ? t("Tab or Enter completes · ↑/↓ chooses · Esc closes · does not send") : (this.overlay?.kind === 'help' ? t("Help open · composer active · PgUp/PgDn scrolls · Esc/F1 closes") : this.preparedDraft ? t("Action prepared · Esc recovers your draft · Enter sends and restores it") : contextualTip(this.composer.text, this.discoveryContext()))));
673
+ const hints = this.selecting ? tone.warning(t(" SELECTION · Drag and copy from your terminal · F5/Esc returns")) : this.attachmentStatus ? (['unsupported','invalid'].includes(this.attachmentStatus.state) ? tone.warning : tone.muted)(' ' + safeText(this.attachmentStatus.message)) : tone.muted(' ' + (completions.items.length ? t("Tab or Enter completes · ↑/↓ chooses · Esc closes · does not send") : (this.overlay?.kind === 'help' ? t("Help open · composer active · PgUp/PgDn scrolls · Esc/F1 closes") : this.preparedDraft ? t("Action prepared · Esc recovers your draft · Enter sends and restores it") : (this.connectionHint() || contextualTip(this.composer.text, this.discoveryContext())))));
665
674
  const suggestionLines = [];
666
675
  if (completionHeight) {
667
676
  suggestionLines.push(tone.muted(t(" Suggestions · {v0} · Tab completes", {v0: completions.items.length})));