-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat(tui): add experimental daemon stream path #4266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,234 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2025 Qwen Team | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import type { CommandModule } from 'yargs'; | ||
| import { createInterface } from 'node:readline/promises'; | ||
| import { stdin as input, stdout as output } from 'node:process'; | ||
| import { writeStderrLine } from '../utils/stdioHelpers.js'; | ||
| import type { | ||
| DaemonTuiUpdate, | ||
| DaemonTuiSessionClient, | ||
| } from '../ui/daemon/DaemonTuiAdapter.js'; | ||
| import { createDaemonTuiSession as createDaemonTuiSessionClient } from '../ui/daemon/createDaemonTuiSession.js'; | ||
|
|
||
| interface DaemonTuiArgs { | ||
| 'daemon-url': string; | ||
| token?: string; | ||
| workspace?: string; | ||
| model?: string; | ||
| 'session-id'?: string; | ||
| 'session-scope'?: 'single' | 'thread'; | ||
| prompt?: string; | ||
| } | ||
|
|
||
| function writeLine(line = ''): void { | ||
| output.write(`${line}\n`); | ||
| } | ||
|
|
||
| function formatHistoryItem(item: unknown): string { | ||
| if (!item || typeof item !== 'object') { | ||
| return String(item); | ||
| } | ||
| const record = item as Record<string, unknown>; | ||
| const type = typeof record['type'] === 'string' ? record['type'] : 'history'; | ||
| const text = | ||
| typeof record['text'] === 'string' | ||
| ? record['text'] | ||
| : JSON.stringify(record, null, 2); | ||
| return `[${type}] ${text}`; | ||
| } | ||
|
|
||
| function printDaemonUpdate(update: DaemonTuiUpdate): void { | ||
| switch (update.type) { | ||
| case 'history': | ||
| writeLine(formatHistoryItem(update.item)); | ||
| break; | ||
| case 'tool_group_update': | ||
| writeLine('[tool]'); | ||
| for (const tool of update.item.tools) { | ||
| writeLine(` - ${tool.name}: ${tool.status}`); | ||
| if (tool.resultDisplay !== undefined) { | ||
| writeLine(` ${JSON.stringify(tool.resultDisplay)}`); | ||
| } | ||
| } | ||
| break; | ||
| case 'permission_request': | ||
| writeLine(`[permission] ${update.requestId}`); | ||
| writeLine( | ||
| ` tool: ${update.request.toolCall.kind} (${update.request.toolCall.toolCallId})`, | ||
| ); | ||
| for (const option of update.request.options) { | ||
| writeLine(` - ${option.optionId}: ${option.name ?? option.optionId}`); | ||
| } | ||
| writeLine(` approve with: /approve ${update.requestId} <optionId>`); | ||
| writeLine(` reject with: /reject ${update.requestId}`); | ||
| break; | ||
| case 'permission_resolved': | ||
| writeLine( | ||
| `[permission_resolved] ${update.requestId} ${JSON.stringify( | ||
| update.outcome, | ||
| )}`, | ||
| ); | ||
| break; | ||
| case 'model_switched': | ||
| writeLine(`[model] ${update.modelId}`); | ||
| break; | ||
| case 'disconnected': | ||
| writeLine(`[disconnected] ${update.reason}`); | ||
| break; | ||
| default: { | ||
| const neverUpdate: never = update; | ||
| writeLine(JSON.stringify(neverUpdate)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| async function createDaemonTuiSession( | ||
| argv: DaemonTuiArgs, | ||
| ): Promise<DaemonTuiSessionClient> { | ||
| const workspaceCwd = argv.workspace ?? process.cwd(); | ||
| return await createDaemonTuiSessionClient({ | ||
| daemonUrl: argv['daemon-url'], | ||
| token: argv.token, | ||
| workspaceCwd, | ||
| model: argv.model, | ||
| sessionId: argv['session-id'], | ||
| sessionScope: argv['session-scope'], | ||
| }); | ||
| } | ||
|
|
||
| async function runPrompt( | ||
| session: DaemonTuiSessionClient, | ||
| prompt: string, | ||
| ): Promise<void> { | ||
| const { DaemonTuiAdapter } = await import('../ui/daemon/DaemonTuiAdapter.js'); | ||
| const adapter = new DaemonTuiAdapter({ | ||
| session, | ||
| onUpdate: printDaemonUpdate, | ||
| }); | ||
| await adapter.start(); | ||
| try { | ||
| await adapter.sendPrompt(prompt); | ||
| } finally { | ||
| await adapter.stop(); | ||
| } | ||
| } | ||
|
|
||
| async function runInteractive(session: DaemonTuiSessionClient): Promise<void> { | ||
| const { DaemonTuiAdapter } = await import('../ui/daemon/DaemonTuiAdapter.js'); | ||
| const adapter = new DaemonTuiAdapter({ | ||
| session, | ||
| onUpdate: printDaemonUpdate, | ||
| }); | ||
| await adapter.start(); | ||
| const rl = createInterface({ input, output }); | ||
| try { | ||
| writeLine( | ||
| `Connected to daemon session ${session.sessionId} (${session.workspaceCwd})`, | ||
| ); | ||
| writeLine( | ||
| 'Commands: /quit, /cancel, /model <id>, /approve <id> <option>, /reject <id>', | ||
| ); | ||
| for (;;) { | ||
| const line = (await rl.question('qwen-daemon> ')).trim(); | ||
| if (!line) { | ||
| continue; | ||
| } | ||
| if (line === '/quit' || line === '/exit') { | ||
| return; | ||
| } | ||
| if (line === '/cancel') { | ||
| await adapter.cancel(); | ||
| continue; | ||
| } | ||
| if (line.startsWith('/model ')) { | ||
| await adapter.setModel(line.slice('/model '.length).trim()); | ||
| continue; | ||
| } | ||
| if (line.startsWith('/approve ')) { | ||
| const [, requestId, optionId] = line.split(/\s+/, 3); | ||
| if (!requestId || !optionId) { | ||
| writeLine('usage: /approve <requestId> <optionId>'); | ||
| continue; | ||
| } | ||
| await adapter.approvePermission(requestId, optionId); | ||
| continue; | ||
| } | ||
| if (line.startsWith('/reject ')) { | ||
| const [, requestId] = line.split(/\s+/, 2); | ||
| if (!requestId) { | ||
| writeLine('usage: /reject <requestId>'); | ||
| continue; | ||
| } | ||
| await adapter.rejectPermission(requestId); | ||
| continue; | ||
| } | ||
| await adapter.sendPrompt(line); | ||
| } | ||
| } finally { | ||
| rl.close(); | ||
| await adapter.stop(); | ||
| } | ||
| } | ||
|
|
||
| export const daemonTuiCommand: CommandModule<unknown, DaemonTuiArgs> = { | ||
| command: 'daemon-tui', | ||
| describe: | ||
| 'Experimental local harness for driving the TUI daemon adapter against qwen serve', | ||
| builder: (yargs) => | ||
| yargs | ||
| .option('daemon-url', { | ||
| type: 'string', | ||
| default: process.env['QWEN_DAEMON_URL'] ?? 'http://127.0.0.1:4170', | ||
| description: 'Base URL of a running qwen serve daemon.', | ||
| }) | ||
| .option('token', { | ||
| type: 'string', | ||
| description: | ||
| 'Bearer token for the daemon. Defaults to QWEN_SERVER_TOKEN.', | ||
| }) | ||
| .option('workspace', { | ||
| type: 'string', | ||
| description: | ||
| 'Workspace cwd to pass to POST /session. Defaults to process.cwd().', | ||
| }) | ||
| .option('model', { | ||
| type: 'string', | ||
| description: 'Optional model service id for the daemon session.', | ||
| }) | ||
| .option('session-id', { | ||
| type: 'string', | ||
| description: | ||
| 'Attach to an existing daemon session via POST /session/:id/load.', | ||
| }) | ||
| .option('session-scope', { | ||
| type: 'string', | ||
| choices: ['single', 'thread'] as const, | ||
| description: | ||
| 'Optional session scope override for new sessions. Omit to use the daemon default.', | ||
| }) | ||
| .option('prompt', { | ||
| type: 'string', | ||
| description: | ||
| 'Send one prompt and exit. Omit for an interactive validation loop.', | ||
| }), | ||
| handler: async (argv) => { | ||
| try { | ||
| const session = await createDaemonTuiSession(argv); | ||
| if (argv.prompt) { | ||
| await runPrompt(session, argv.prompt); | ||
| } else { | ||
| await runInteractive(session); | ||
| } | ||
| } catch (err) { | ||
| writeStderrLine( | ||
| `qwen daemon-tui: ${err instanceof Error ? err.message : String(err)}`, | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| process.exit(0); | ||
| }, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -59,6 +59,7 @@ import { channelCommand } from '../commands/channel.js'; | |||||||||||
| import { authCommand } from '../commands/auth.js'; | ||||||||||||
| import { reviewCommand } from '../commands/review.js'; | ||||||||||||
| import { serveCommand } from '../commands/serve.js'; | ||||||||||||
| import { daemonTuiCommand } from '../commands/daemon-tui.js'; | ||||||||||||
|
|
||||||||||||
| // UUID v4 regex pattern for validation | ||||||||||||
| const SESSION_ID_REGEX = | ||||||||||||
|
|
@@ -137,6 +138,12 @@ export interface CliArgs { | |||||||||||
| acp: boolean | undefined; | ||||||||||||
| experimentalAcp: boolean | undefined; | ||||||||||||
| experimentalLsp: boolean | undefined; | ||||||||||||
| experimentalDaemonTui?: boolean | undefined; | ||||||||||||
| daemonUrl?: string | undefined; | ||||||||||||
| daemonToken?: string | undefined; | ||||||||||||
| daemonSessionId?: string | undefined; | ||||||||||||
| daemonSessionScope?: 'single' | 'thread' | undefined; | ||||||||||||
| daemonModel?: string | undefined; | ||||||||||||
| extensions: string[] | undefined; | ||||||||||||
| listExtensions: boolean | undefined; | ||||||||||||
| openaiLogging: boolean | undefined; | ||||||||||||
|
|
@@ -672,6 +679,42 @@ export async function parseArguments(): Promise<CliArgs> { | |||||||||||
| 'Enable experimental LSP (Language Server Protocol) feature for code intelligence', | ||||||||||||
| default: false, | ||||||||||||
| }) | ||||||||||||
| .option('experimental-daemon-tui', { | ||||||||||||
| type: 'boolean', | ||||||||||||
| description: | ||||||||||||
| 'Experimental: render the normal TUI against a running qwen serve daemon.', | ||||||||||||
| hidden: true, | ||||||||||||
| }) | ||||||||||||
| .option('daemon-url', { | ||||||||||||
| type: 'string', | ||||||||||||
| description: | ||||||||||||
| 'Experimental daemon TUI: base URL of a running qwen serve daemon.', | ||||||||||||
| hidden: true, | ||||||||||||
| }) | ||||||||||||
| .option('daemon-token', { | ||||||||||||
| type: 'string', | ||||||||||||
| description: 'Experimental daemon TUI: bearer token for the daemon.', | ||||||||||||
| hidden: true, | ||||||||||||
| }) | ||||||||||||
| .option('daemon-session-id', { | ||||||||||||
| type: 'string', | ||||||||||||
| description: | ||||||||||||
| 'Experimental daemon TUI: attach to an existing daemon session.', | ||||||||||||
| hidden: true, | ||||||||||||
| }) | ||||||||||||
| .option('daemon-session-scope', { | ||||||||||||
| type: 'string', | ||||||||||||
| choices: ['single', 'thread'] as const, | ||||||||||||
| description: | ||||||||||||
| 'Experimental daemon TUI: session scope for new daemon sessions.', | ||||||||||||
| hidden: true, | ||||||||||||
| }) | ||||||||||||
| .option('daemon-model', { | ||||||||||||
| type: 'string', | ||||||||||||
| description: | ||||||||||||
| 'Experimental daemon TUI: model service id for new daemon sessions.', | ||||||||||||
| hidden: true, | ||||||||||||
| }) | ||||||||||||
| .option('channel', { | ||||||||||||
| type: 'string', | ||||||||||||
| choices: ['VSCode', 'ACP', 'SDK', 'CI'], | ||||||||||||
|
|
@@ -1002,7 +1045,9 @@ export async function parseArguments(): Promise<CliArgs> { | |||||||||||
| // Register /review skill helpers (presubmit checks, cleanup) | ||||||||||||
| .command(reviewCommand) | ||||||||||||
| // Register `qwen serve` (Stage 1 daemon — see issue #3803) | ||||||||||||
| .command(serveCommand); | ||||||||||||
| .command(serveCommand) | ||||||||||||
| // Register experimental daemon TUI wire-up harness. | ||||||||||||
| .command(daemonTuiCommand); | ||||||||||||
|
|
||||||||||||
| yargsInstance | ||||||||||||
| .version(await getCliVersion()) // This will enable the --version flag based on package.json | ||||||||||||
|
|
@@ -1026,7 +1071,8 @@ export async function parseArguments(): Promise<CliArgs> { | |||||||||||
| result._[0] === 'auth' || | ||||||||||||
| result._[0] === 'hooks' || | ||||||||||||
| result._[0] === 'channel' || | ||||||||||||
| result._[0] === 'review') | ||||||||||||
| result._[0] === 'review' || | ||||||||||||
| result._[0] === 'daemon-tui') | ||||||||||||
| ) { | ||||||||||||
| // Note: `serve` is intentionally NOT in this list. Its handler blocks | ||||||||||||
| // forever (after the listener is up); SIGINT/SIGTERM in runQwenServe | ||||||||||||
|
|
@@ -1077,6 +1123,29 @@ export async function parseArguments(): Promise<CliArgs> { | |||||||||||
| (result as Record<string, unknown>)['channel'] = 'ACP'; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| if (result['experimentalDaemonTui']) { | ||||||||||||
| process.env['QWEN_EXPERIMENTAL_DAEMON_TUI'] = '1'; | ||||||||||||
| if (typeof result['daemonUrl'] === 'string') { | ||||||||||||
| process.env['QWEN_DAEMON_URL'] = result['daemonUrl']; | ||||||||||||
| } | ||||||||||||
| if (typeof result['daemonToken'] === 'string') { | ||||||||||||
| process.env['QWEN_DAEMON_TOKEN'] = result['daemonToken']; | ||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Bearer token 写入
Suggested change
— DeepSeek/deepseek-v4-pro via Qwen Code /review |
||||||||||||
| } | ||||||||||||
| if (typeof result['daemonSessionId'] === 'string') { | ||||||||||||
| process.env['QWEN_DAEMON_SESSION_ID'] = result['daemonSessionId']; | ||||||||||||
| } | ||||||||||||
| if (typeof result['daemonSessionScope'] === 'string') { | ||||||||||||
| process.env['QWEN_DAEMON_SESSION_SCOPE'] = result['daemonSessionScope']; | ||||||||||||
| } | ||||||||||||
| if (typeof result['daemonModel'] === 'string') { | ||||||||||||
| process.env['QWEN_DAEMON_MODEL'] = result['daemonModel']; | ||||||||||||
| } | ||||||||||||
| // Hidden draft path: bridge CLI flags into the daemon-native TUI adapter. | ||||||||||||
| // For now the normal local TUI flag only exercises local-local daemon use; | ||||||||||||
| // remote daemon smoke tests should pass QWEN_DAEMON_WORKSPACE explicitly. | ||||||||||||
| process.env['QWEN_DAEMON_WORKSPACE'] = process.cwd(); | ||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical]
Suggested change
— DeepSeek/deepseek-v4-pro via Qwen Code /review |
||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| return result as unknown as CliArgs; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Critical] No error handling around
await adapter.*calls in the interactive REPL loop. Any transient RPC error (timeout, bad model name, network blip) onsendPrompt,cancel,setModel,approvePermission, orrejectPermissionpropagates to the outertry/finally, which callsprocess.exit(1). A single flaky command kills the entire session.— qwen-latest-series-invite-beta-v28 via Qwen Code /review