Skip to content

Commit 558de07

Browse files
luoxuanzaoQoder-AI
andauthored
feat(auth): add in-app device-flow sign-in for signed-out CLI
The model selector now offers an in-app sign-in panel that drives `qodercli login` (device flow) when the CLI is unauthenticated: the authorization page opens once per attempt (the plugin is the sole opener; the CLI's own browser launch is disabled via BROWSER=www-browser), with open/copy/cancel controls, localized status and errors in all ten locales, and an automatic model-catalog refresh on success — so signing in no longer requires a terminal. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent 9687fcf commit 558de07

23 files changed

Lines changed: 1029 additions & 27 deletions

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ version with its date and start a fresh empty `[Unreleased]` above it.
1313

1414
### Added
1515

16+
- In-app sign-in when the Qoder CLI is not authenticated: the model selector
17+
now shows a sign-in panel that runs the CLI device flow inside Obsidian —
18+
the authorization page opens once per attempt, with open-link, copy-link and
19+
cancel controls, localized status and error messages in all ten locales, and
20+
an automatic model-catalog refresh once sign-in succeeds — so signing in no
21+
longer requires a terminal.
22+
1623
- Message timestamps, matching New Qoder: each user bubble shows when it was
1724
sent, and each assistant reply shows one time under the whole response next
1825
to the completion line. Both reveal on hover so idle conversations stay

‎src/features/chat/tabs/tab.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,7 @@ function initializeInputToolbar(
423423
retryRuntimeCatalog: async () => {
424424
await plugin.qoderServices.agentCatalog.refresh();
425425
},
426+
loginService: plugin.qoderServices.loginService,
426427
onModelChange: async (model: string) => {
427428
// Blank tabs keep their model choice until the first message binds them.
428429
if (tab.lifecycleState === 'blank') {

‎src/features/chat/ui/toolbar/toolbar-selectors.ts‎

Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import type { TranslationKey } from '../../../../i18n/types';
77
import { getActiveQoderCliEdition, getQoderCliLoginCommand } from '../../../../qoder/config/cli-edition';
88
import { getQoderModelOverride } from '../../../../qoder/config/settings';
99
import type { QoderModelConfig } from '../../../../qoder/models/qoder-model-config';
10+
import type {
11+
QoderLoginController,
12+
QoderLoginFailure,
13+
} from '../../../../qoder/services/qoder-login-service';
1014
import {
1115
CHECK_ICON,
1216
CHEVRON_LEFT_ICON,
@@ -43,6 +47,7 @@ export interface ToolbarCallbacks {
4347
getRuntimeStatus?: () => QoderRuntimeStatus;
4448
retryRuntimeCatalog?: () => Promise<void>;
4549
subscribeRuntimeStatus?: (listener: (status: QoderRuntimeStatus) => void) => () => void;
50+
loginService?: QoderLoginController;
4651
}
4752

4853
const DEFAULT_RUNTIME_STATUS: QoderRuntimeStatus = {
@@ -68,6 +73,15 @@ function canUseCachedModels(status: QoderRuntimeStatus): boolean {
6873
return status.kind === 'checking' || status.kind === 'offline' || status.kind === 'failed';
6974
}
7075

76+
function getSignInFailureMessage(failure: QoderLoginFailure): string {
77+
switch (failure.kind) {
78+
case 'cliMissing': return t('chat.signIn.errorCliMissing');
79+
case 'nodeMissing': return t('chat.signIn.errorNodeMissing');
80+
case 'spawn': return t('chat.signIn.errorStartFailed');
81+
case 'process': return t('chat.signIn.errorProcessFailed');
82+
}
83+
}
84+
7185
export class ModelSelector {
7286
private readonly container: HTMLElement;
7387
private buttonEl: HTMLElement | null = null;
@@ -77,6 +91,7 @@ export class ModelSelector {
7791
private popover: ClickPopover | null = null;
7892
private editingModel: string | null = null;
7993
private unsubscribeRuntimeStatus: (() => void) | null = null;
94+
private unsubscribeLoginState: (() => void) | null = null;
8095

8196
constructor(parentEl: HTMLElement, private readonly callbacks: ToolbarCallbacks) {
8297
this.container = parentEl.createDiv({ cls: 'qoderian-model-selector' });
@@ -85,13 +100,18 @@ export class ModelSelector {
85100
this.updateDisplay();
86101
this.renderOptions();
87102
}) ?? null;
103+
this.unsubscribeLoginState = callbacks.loginService?.subscribe(() => {
104+
this.renderOptions();
105+
}) ?? null;
88106
}
89107

90108
destroy(): void {
91109
this.popover?.destroy();
92110
this.popover = null;
93111
this.unsubscribeRuntimeStatus?.();
94112
this.unsubscribeRuntimeStatus = null;
113+
this.unsubscribeLoginState?.();
114+
this.unsubscribeLoginState = null;
95115
}
96116

97117
private getAvailableModels() {
@@ -177,15 +197,19 @@ export class ModelSelector {
177197
statusEl.createDiv({ cls: 'qoderian-model-runtime-title', text: getRuntimeStatusLabel(status) });
178198
statusEl.createDiv({ cls: 'qoderian-model-runtime-message', text: status.message });
179199
if (status.kind === 'authRequired') {
180-
statusEl.createEl('code', {
181-
cls: 'qoderian-model-runtime-command',
182-
text: getQoderCliLoginCommand(getActiveQoderCliEdition()),
183-
});
200+
this.renderSignInFlow(statusEl);
184201
}
185202
if (status.details) {
186203
statusEl.setAttribute('title', status.details);
187204
}
188-
if (this.callbacks.retryRuntimeCatalog) {
205+
const loginRunning = status.kind === 'authRequired'
206+
&& (this.callbacks.loginService?.isRunning() ?? false);
207+
// When the in-app sign-in flow is available it owns authRequired
208+
// recovery (a successful sign-in refreshes the catalog), so the generic
209+
// Retry button would only add a redundant, misaligned second action.
210+
const signInOwnsAuth = status.kind === 'authRequired'
211+
&& this.callbacks.loginService !== undefined;
212+
if (this.callbacks.retryRuntimeCatalog && !loginRunning && !signInOwnsAuth) {
189213
const retryButton = statusEl.createEl('button', {
190214
cls: 'qoderian-model-runtime-retry',
191215
text: status.kind === 'checking' ? 'Checking…' : 'Retry',
@@ -295,6 +319,94 @@ export class ModelSelector {
295319
this.updateDropdownPlacement();
296320
}
297321

322+
private renderSignInFlow(statusEl: HTMLElement): void {
323+
const loginService = this.callbacks.loginService;
324+
if (!loginService) {
325+
statusEl.createEl('code', {
326+
cls: 'qoderian-model-runtime-command',
327+
text: getQoderCliLoginCommand(getActiveQoderCliEdition()),
328+
});
329+
return;
330+
}
331+
332+
const state = loginService.getState();
333+
334+
if (state.phase === 'waiting') {
335+
statusEl.createDiv({
336+
cls: 'qoderian-signin-waiting',
337+
text: t('chat.signIn.waiting'),
338+
});
339+
} else if (state.phase === 'succeeded') {
340+
statusEl.createDiv({
341+
cls: 'qoderian-signin-waiting',
342+
text: t('chat.signIn.verifying'),
343+
});
344+
return;
345+
} else if (state.phase === 'failed' && state.failure) {
346+
const errorEl = statusEl.createDiv({
347+
cls: 'qoderian-signin-error',
348+
text: getSignInFailureMessage(state.failure),
349+
});
350+
if (state.failure.details) {
351+
errorEl.setAttribute('title', state.failure.details);
352+
}
353+
}
354+
355+
const actionsEl = statusEl.createDiv({ cls: 'qoderian-signin-actions' });
356+
357+
if (state.phase === 'waiting') {
358+
if (state.authUrl) {
359+
const openButton = actionsEl.createEl('button', {
360+
cls: 'qoderian-signin-open mod-cta',
361+
text: t('chat.signIn.openLink'),
362+
});
363+
openButton.addEventListener('click', (event) => {
364+
event.stopPropagation();
365+
loginService.openAuthUrl();
366+
});
367+
const copyButton = actionsEl.createEl('button', {
368+
cls: 'qoderian-signin-copy',
369+
text: t('chat.signIn.copyLink'),
370+
});
371+
copyButton.addEventListener('click', (event) => {
372+
event.stopPropagation();
373+
void this.copyAuthUrl(state.authUrl as string);
374+
});
375+
}
376+
const cancelButton = actionsEl.createEl('button', {
377+
cls: 'qoderian-signin-cancel',
378+
text: t('common.cancel'),
379+
});
380+
cancelButton.addEventListener('click', (event) => {
381+
event.stopPropagation();
382+
loginService.cancel();
383+
});
384+
return;
385+
}
386+
387+
const signInButton = actionsEl.createEl('button', {
388+
cls: 'qoderian-signin-button mod-cta',
389+
text: state.phase === 'failed' ? t('chat.signIn.retry') : t('chat.signIn.button'),
390+
});
391+
if (state.phase === 'starting') {
392+
signInButton.setAttribute('disabled', 'true');
393+
signInButton.setText(t('chat.signIn.starting'));
394+
}
395+
signInButton.addEventListener('click', (event) => {
396+
event.stopPropagation();
397+
loginService.start();
398+
});
399+
}
400+
401+
private async copyAuthUrl(authUrl: string): Promise<void> {
402+
try {
403+
await navigator.clipboard.writeText(authUrl);
404+
new Notice(t('chat.signIn.copied'));
405+
} catch {
406+
new Notice(t('chat.signIn.copyFailed'));
407+
}
408+
}
409+
298410
/**
299411
* Places the dropdown against the real viewport: stay flush with the
300412
* trigger (inline-start) and flip only when the panel would overflow,

‎src/i18n/locales/de.json‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,21 @@
9090
"running": "Wird ausgeführt...",
9191
"copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen"
9292
},
93+
"signIn": {
94+
"button": "Anmelden",
95+
"retry": "Erneut anmelden",
96+
"starting": "Anmeldung wird gestartet…",
97+
"waiting": "Warten auf Browser-Autorisierung…",
98+
"verifying": "Angemeldet. Wird überprüft…",
99+
"openLink": "Link öffnen",
100+
"copyLink": "Link kopieren",
101+
"copied": "Link kopiert",
102+
"copyFailed": "Link konnte nicht kopiert werden",
103+
"errorCliMissing": "Qoder CLI wurde nicht gefunden. Installieren Sie es oder legen Sie den Pfad in den Einstellungen fest.",
104+
"errorNodeMissing": "Qoder CLI erfordert Node.js, aber Node.js wurde nicht gefunden.",
105+
"errorStartFailed": "Der Anmeldevorgang konnte nicht gestartet werden.",
106+
"errorProcessFailed": "Anmeldung fehlgeschlagen. Überprüfen Sie die Details und versuchen Sie es erneut."
107+
},
93108
"view": {
94109
"openQoderian": "Qoderian öffnen",
95110
"moveBlockedStreaming": "Warte, bis die aktuelle Antwort abgeschlossen ist, bevor du die Qoderian-Ansicht verschiebst.",

‎src/i18n/locales/en.json‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,21 @@
9090
"running": "Running...",
9191
"copyFailed": "Failed to copy to clipboard"
9292
},
93+
"signIn": {
94+
"button": "Sign in",
95+
"retry": "Sign in again",
96+
"starting": "Starting sign-in…",
97+
"waiting": "Waiting for browser authorization…",
98+
"verifying": "Signed in. Checking status…",
99+
"openLink": "Open link",
100+
"copyLink": "Copy link",
101+
"copied": "Link copied",
102+
"copyFailed": "Could not copy the link",
103+
"errorCliMissing": "Qoder CLI was not found. Install it or set its path in settings.",
104+
"errorNodeMissing": "Qoder CLI requires Node.js, but Node.js was not found.",
105+
"errorStartFailed": "Could not start the sign-in process.",
106+
"errorProcessFailed": "Sign-in failed. Check the details and try again."
107+
},
93108
"view": {
94109
"openQoderian": "Open Qoderian",
95110
"moveBlockedStreaming": "Wait for the current response to finish before moving the Qoderian view.",

‎src/i18n/locales/es.json‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,21 @@
9090
"running": "Ejecutando...",
9191
"copyFailed": "No se pudo copiar al portapapeles"
9292
},
93+
"signIn": {
94+
"button": "Iniciar sesión",
95+
"retry": "Iniciar sesión de nuevo",
96+
"starting": "Iniciando sesión…",
97+
"waiting": "Esperando autorización del navegador…",
98+
"verifying": "Sesión iniciada. Verificando…",
99+
"openLink": "Abrir enlace",
100+
"copyLink": "Copiar enlace",
101+
"copied": "Enlace copiado",
102+
"copyFailed": "No se pudo copiar el enlace",
103+
"errorCliMissing": "No se encontró Qoder CLI. Instálalo o configura su ruta en los ajustes.",
104+
"errorNodeMissing": "Qoder CLI requiere Node.js, pero no se encontró.",
105+
"errorStartFailed": "No se pudo iniciar el proceso de inicio de sesión.",
106+
"errorProcessFailed": "Error al iniciar sesión. Revisa los detalles e inténtalo de nuevo."
107+
},
93108
"view": {
94109
"openQoderian": "Abrir Qoderian",
95110
"moveBlockedStreaming": "Espera a que termine la respuesta actual antes de mover la vista de Qoderian.",

‎src/i18n/locales/fr.json‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,21 @@
9090
"running": "Exécution...",
9191
"copyFailed": "Échec de la copie dans le presse-papiers"
9292
},
93+
"signIn": {
94+
"button": "Se connecter",
95+
"retry": "Se connecter à nouveau",
96+
"starting": "Démarrage de la connexion…",
97+
"waiting": "En attente d'autorisation du navigateur…",
98+
"verifying": "Connecté. Vérification…",
99+
"openLink": "Ouvrir le lien",
100+
"copyLink": "Copier le lien",
101+
"copied": "Lien copié",
102+
"copyFailed": "Impossible de copier le lien",
103+
"errorCliMissing": "Qoder CLI introuvable. Installez-le ou configurez son chemin dans les paramètres.",
104+
"errorNodeMissing": "Qoder CLI nécessite Node.js, mais Node.js est introuvable.",
105+
"errorStartFailed": "Impossible de démarrer le processus de connexion.",
106+
"errorProcessFailed": "Échec de la connexion. Vérifiez les détails et réessayez."
107+
},
93108
"view": {
94109
"openQoderian": "Ouvrir Qoderian",
95110
"moveBlockedStreaming": "Attendez la fin de la réponse actuelle avant de déplacer la vue Qoderian.",

‎src/i18n/locales/ja.json‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,21 @@
9090
"running": "実行中...",
9191
"copyFailed": "クリップボードへのコピーに失敗しました"
9292
},
93+
"signIn": {
94+
"button": "サインイン",
95+
"retry": "もう一度サインイン",
96+
"starting": "サインインを開始しています…",
97+
"waiting": "ブラウザでの認証を待っています…",
98+
"verifying": "サインインしました。確認中…",
99+
"openLink": "リンクを開く",
100+
"copyLink": "リンクをコピー",
101+
"copied": "リンクをコピーしました",
102+
"copyFailed": "リンクをコピーできませんでした",
103+
"errorCliMissing": "Qoder CLI が見つかりません。インストールするか、設定でパスを指定してください。",
104+
"errorNodeMissing": "Qoder CLI には Node.js が必要ですが、見つかりませんでした。",
105+
"errorStartFailed": "サインイン プロセスを開始できませんでした。",
106+
"errorProcessFailed": "サインインに失敗しました。詳細を確認して再試行してください。"
107+
},
93108
"view": {
94109
"openQoderian": "Qoderian を開く",
95110
"moveBlockedStreaming": "現在の応答が完了するまで Qoderian ビューを移動しないでください。",

‎src/i18n/locales/ko.json‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,21 @@
9090
"running": "실행 중...",
9191
"copyFailed": "클립보드에 복사하지 못했습니다"
9292
},
93+
"signIn": {
94+
"button": "로그인",
95+
"retry": "다시 로그인",
96+
"starting": "로그인 시작 중…",
97+
"waiting": "브라우저 승인 대기 중…",
98+
"verifying": "로그인됨. 확인 중…",
99+
"openLink": "링크 열기",
100+
"copyLink": "링크 복사",
101+
"copied": "링크가 복사되었습니다",
102+
"copyFailed": "링크를 복사할 수 없습니다",
103+
"errorCliMissing": "Qoder CLI를 찾을 수 없습니다. 설치하거나 설정에서 경로를 지정하세요.",
104+
"errorNodeMissing": "Qoder CLI에는 Node.js가 필요하지만 찾을 수 없습니다.",
105+
"errorStartFailed": "로그인 프로세스를 시작할 수 없습니다.",
106+
"errorProcessFailed": "로그인에 실패했습니다. 세부 정보를 확인하고 다시 시도하세요."
107+
},
93108
"view": {
94109
"openQoderian": "Qoderian 열기",
95110
"moveBlockedStreaming": "현재 응답이 완료될 때까지 기다린 후 Qoderian 뷰를 이동하세요.",

‎src/i18n/locales/pt.json‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,21 @@
9090
"running": "Executando...",
9191
"copyFailed": "Falha ao copiar para a área de transferência"
9292
},
93+
"signIn": {
94+
"button": "Entrar",
95+
"retry": "Entrar novamente",
96+
"starting": "Iniciando login…",
97+
"waiting": "Aguardando autorização do navegador…",
98+
"verifying": "Conectado. Verificando…",
99+
"openLink": "Abrir link",
100+
"copyLink": "Copiar link",
101+
"copied": "Link copiado",
102+
"copyFailed": "Não foi possível copiar o link",
103+
"errorCliMissing": "Qoder CLI não encontrado. Instale-o ou configure o caminho nas configurações.",
104+
"errorNodeMissing": "Qoder CLI requer Node.js, mas o Node.js não foi encontrado.",
105+
"errorStartFailed": "Não foi possível iniciar o processo de login.",
106+
"errorProcessFailed": "Falha no login. Verifique os detalhes e tente novamente."
107+
},
93108
"view": {
94109
"openQoderian": "Abrir Qoderian",
95110
"moveBlockedStreaming": "Aguarde a resposta atual terminar antes de mover a visualização do Qoderian.",

0 commit comments

Comments
 (0)