feat(plugins): one-click 'Install all' button for missing prerequisites (#9)

* chore: update zh-TW translations

* chore: update jp and kr translations

* feat(plugins): one-click 'Install all' button for missing prerequisites

Builds on PR #8. The prerequisites modal now has a primary 'Install
all' button that opens a fresh terminal tab and injects each missing
prerequisite's first install command into the PTY one at a time.

Implementation:

- New helper desktop/src/lib/terminalCommandInjection.ts:
  injectInstallScriptIntoNewTerminal(commands) opens a terminal tab
  via useTabStore.openTerminalTab(), waits via subscribeTerminalRuntime
  for the spawn to bind nativeSessionId, then writes each command +
  carriage return through terminalApi.write with a 150ms inter-command
  delay so output stays grouped. Times out at 15s if the spawn never
  completes (host without terminal capability raises a clear error).

- Modal: new 'Install all (N)' button in the footer (only shown when
  there is at least one row whose install map covers the current
  platform). Clicking shows a loading spinner; on success, fires an
  info toast 'N install commands injected — watch them run, then click
  Recheck'. Errors fall back to a toast pointing at the per-command
  Copy / Open-in-terminal buttons.

- Selects the FIRST install step per platform per row. Plugin authors
  put the most ergonomic option first (winget on Windows, brew on
  macOS), so this is the right default. Users can still copy alternate
  install methods manually.

- i18n: 4 new keys x 5 locales (en/zh added here; jp/kr/zh-TW already
  in by chore commits cf7ab076 + 819eaca4).

Why not pipe everything through one shell '&&' chain? Two reasons:
(1) a failure mid-chain blocks remaining commands silently — sending
each line independently lets the user see every result and fix
mid-stream; (2) chained command output collapses into a hard-to-read
wall.

Tested: bun run lint clean.
Confidence: high
Scope-risk: narrow

---------

Co-authored-by: 你的姓名 <you@example.com>
This commit is contained in:
小橙子 2026-06-11 20:06:46 +08:00 committed by GitHub
parent 5375ee23f3
commit d5825cd912
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 220 additions and 1 deletions

View File

@ -5,6 +5,7 @@ import { useTranslation } from '../../i18n'
import { useUIStore } from '../../stores/uiStore'
import { useTabStore } from '../../stores/tabStore'
import { copyTextToClipboard } from '../chat/clipboard'
import { injectInstallScriptIntoNewTerminal } from '../../lib/terminalCommandInjection'
import type {
PluginPrerequisiteInstallStep,
PluginPrerequisiteRow,
@ -92,12 +93,35 @@ export function PluginPrerequisitesModal({
// Optimistic copy-feedback state, keyed by `${rowIdx}:${stepIdx}`.
// Reset on modal close so reopening the modal starts fresh.
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const [installAllRunning, setInstallAllRunning] = useState(false)
useEffect(() => {
if (!open) setCopiedKey(null)
if (!open) {
setCopiedKey(null)
setInstallAllRunning(false)
}
}, [open])
const missingRows = rows.filter((r) => !r.installed)
// Build the "install all" command list for the current platform.
// Pick the FIRST install step per row — plugin authors put the most
// ergonomic option first (e.g. winget on Windows, brew on macOS).
// A row with no install step for this platform is silently dropped
// — the user still sees the row in the modal with the "no automated
// install for {platform}" hint.
const installAllCommands = useMemo(() => {
const cmds: string[] = []
for (const row of missingRows) {
const steps = row.install?.[platform] ?? []
if (steps.length > 0) {
cmds.push(steps[0]!.cmd)
}
}
return cmds
}, [missingRows, platform])
const canInstallAll = installAllCommands.length > 0
const handleCopy = async (cmd: string, key: string) => {
const ok = await copyTextToClipboard(cmd)
if (!ok) {
@ -131,6 +155,30 @@ export function PluginPrerequisitesModal({
})
}
const handleInstallAll = async () => {
if (installAllRunning || !canInstallAll) return
setInstallAllRunning(true)
try {
const result = await injectInstallScriptIntoNewTerminal(installAllCommands)
addToast({
type: 'info',
message: t('pluginPrereq.installAllRunningToast', {
count: String(result.commands.length),
}),
duration: 8000,
})
} catch (err) {
addToast({
type: 'error',
message: t('pluginPrereq.installAllFailedToast', {
detail: err instanceof Error ? err.message : String(err),
}),
})
} finally {
setInstallAllRunning(false)
}
}
return (
<Modal
open={open}
@ -142,6 +190,23 @@ export function PluginPrerequisitesModal({
<Button variant="ghost" size="sm" onClick={onClose}>
{t('pluginPrereq.dismiss')}
</Button>
{canInstallAll && (
<Button
variant="secondary"
size="sm"
onClick={handleInstallAll}
loading={installAllRunning}
data-testid="plugin-prereq-install-all"
title={t('pluginPrereq.installAllTooltip', {
count: String(installAllCommands.length),
})}
>
<span className="material-symbols-outlined text-[16px]">play_arrow</span>
{t('pluginPrereq.installAll', {
count: String(installAllCommands.length),
})}
</Button>
)}
<Button size="sm" onClick={onRecheck} loading={isRechecking}>
<span className="material-symbols-outlined text-[16px]">refresh</span>
{t('pluginPrereq.recheck')}

View File

@ -2107,6 +2107,10 @@ export const en = {
'pluginPrereq.allInstalledToast': 'All prerequisites are now installed.',
'pluginPrereq.noPlatformInstall': 'No automated install command for {platform}. See the homepage link above.',
'pluginPrereq.safetyNote': 'cc-haha never runs install commands automatically. The "Open in terminal" button copies the command and opens a terminal — you press Enter to actually run it.',
'pluginPrereq.installAll': 'Install all ({count})',
'pluginPrereq.installAllTooltip': 'Open a new terminal tab and inject {count} install command(s) one at a time. You will see each command echo and run in the terminal — cc-haha never hides output.',
'pluginPrereq.installAllRunningToast': '{count} install command(s) injected into a new terminal tab. Watch them run, then click "Recheck" when done.',
'pluginPrereq.installAllFailedToast': 'Could not auto-install: {detail}. Try the per-command "Copy" / "Open in terminal" buttons instead.',
} as const
export type TranslationKey = keyof typeof en

View File

@ -2082,4 +2082,8 @@ export const jp: Record<TranslationKey, string> = {
'pluginPrereq.allInstalledToast': 'すべての前提条件が揃いました。',
'pluginPrereq.noPlatformInstall': '現在のプラットフォーム {platform} 用の自動インストールコマンドはありません。上記のドキュメントリンクをご確認ください。',
'pluginPrereq.safetyNote': 'cc-haha がインストールコマンドを自動実行することはありません。「ターミナルで開く」はコマンドをクリップボードにコピーしてターミナルを開くだけで、貼り付けと Enter を押すのは利用者の操作です。',
'pluginPrereq.installAll': 'ワンクリックインストール({count}',
'pluginPrereq.installAllTooltip': '新しいターミナルタブを開き、{count} 件のインストールコマンドを順に注入して実行します。各コマンドのエコーと実行はターミナル上で確認できます。cc-haha は出力を隠しません。',
'pluginPrereq.installAllRunningToast': '{count} 件のインストールコマンドを新しいターミナルタブに注入しました。実行を確認したら「再チェック」をクリックしてください。',
'pluginPrereq.installAllFailedToast': 'ワンクリックインストールに失敗しました: {detail}。各コマンドの「コピー」/「ターミナルで開く」ボタンをご利用ください。',
}

View File

@ -2082,4 +2082,8 @@ export const kr: Record<TranslationKey, string> = {
'pluginPrereq.allInstalledToast': '모든 사전 요구사항이 준비되었습니다.',
'pluginPrereq.noPlatformInstall': '현재 플랫폼 {platform}용 자동 설치 명령이 없습니다. 위의 문서 링크를 참조하세요.',
'pluginPrereq.safetyNote': 'cc-haha는 설치 명령을 자동으로 실행하지 않습니다. "터미널에서 열기"는 명령을 클립보드에 복사하고 새 터미널을 열기만 하며, 붙여넣고 Enter를 누르는 것은 사용자의 명시적 동작입니다.',
'pluginPrereq.installAll': '원클릭 설치({count})',
'pluginPrereq.installAllTooltip': '새 터미널 탭을 열고 {count}개의 설치 명령을 순차적으로 주입하여 실행합니다. 각 명령의 에코와 실행은 터미널에서 직접 확인할 수 있습니다 —— cc-haha는 출력을 숨기지 않습니다.',
'pluginPrereq.installAllRunningToast': '{count}개의 설치 명령을 새 터미널 탭에 주입했습니다. 실행을 확인한 후 "다시 확인"을 클릭하세요.',
'pluginPrereq.installAllFailedToast': '원클릭 설치에 실패했습니다: {detail}. 개별 명령의 "복사"/"터미널에서 열기" 버튼을 사용하세요.',
}

View File

@ -2082,4 +2082,8 @@ export const zh: Record<TranslationKey, string> = {
'pluginPrereq.allInstalledToast': '所有前置相依套件皆已就緒。',
'pluginPrereq.noPlatformInstall': '目前平台 {platform} 沒有現成的安裝指令,請查看上方文件連結。',
'pluginPrereq.safetyNote': 'cc-haha 永遠不會自動執行安裝指令。「在終端機中開啟」只會把指令複製到剪貼簿並開啟新終端機 —— 你自己貼上 + 按 Enter 才會真正執行。',
'pluginPrereq.installAll': '一鍵安裝({count}',
'pluginPrereq.installAllTooltip': '開啟新終端機分頁,將 {count} 條安裝指令逐條注入並執行。你會在終端機裡看到每條指令回顯和執行 —— cc-haha 不會隱藏任何輸出。',
'pluginPrereq.installAllRunningToast': '已將 {count} 條安裝指令注入新終端機分頁,看著它們跑完後點「重新偵測」。',
'pluginPrereq.installAllFailedToast': '一鍵安裝失敗:{detail}。可改用單條指令的「複製」/「在終端機中開啟」按鈕。',
}

View File

@ -2082,4 +2082,8 @@ export const zh: Record<TranslationKey, string> = {
'pluginPrereq.allInstalledToast': '所有前置依赖已就绪。',
'pluginPrereq.noPlatformInstall': '当前平台 {platform} 没有现成的安装命令,请查看上方文档链接。',
'pluginPrereq.safetyNote': 'cc-haha 不会自动执行任何安装命令。"在终端中打开"只把命令复制到剪贴板并新开一个终端 —— 你自己粘贴 + 回车才会真正执行。',
'pluginPrereq.installAll': '一键安装({count})',
'pluginPrereq.installAllTooltip': '新开一个终端 tab,把 {count} 条安装命令逐条注入并执行。你会在终端里看到每条命令回显和运行 —— cc-haha 不会隐藏任何输出。',
'pluginPrereq.installAllRunningToast': '已把 {count} 条安装命令注入到新终端 tab,看着它们跑完后点"重新检测"。',
'pluginPrereq.installAllFailedToast': '一键安装失败:{detail}。可以换用单条命令的"复制"/"在终端中打开"按钮。',
}

View File

@ -0,0 +1,134 @@
/**
* Helper for the "Install all" affordance in
* `PluginPrerequisitesModal`. Opens a fresh terminal tab and writes a
* sequence of install commands into the spawned PTY so the user
* watches them run rather than copy-pasting one at a time.
*
* Important: this is "watched automation", not silent execution. The
* user clicked "Install all" sees a terminal open sees each
* command echo and run. Output is fully visible. cc-haha never
* suppresses or backgrounds anything.
*
* Why not pipe everything to a single shell `&&` chain? Two reasons:
* 1. A failure mid-chain would block remaining commands silently.
* Sending each line independently with `\r` lets the user see
* every result, fix mid-stream, or copy a different install
* method from the modal afterwards.
* 2. Each command's output stays grouped. Chained commands tend to
* collapse output into a hard-to-read wall.
*/
import { terminalApi } from '../api/terminal'
import { useTabStore } from '../stores/tabStore'
import {
getTerminalRuntime,
subscribeTerminalRuntime,
type TerminalRuntime,
} from './terminalRuntime'
const READY_TIMEOUT_MS = 15_000
const PER_COMMAND_DELAY_MS = 150
/**
* Wait until the terminal runtime has spawned its PTY and is in the
* `running` state with a valid `nativeSessionId`. Resolves when ready,
* rejects on timeout. Uses subscribeTerminalRuntime no busy-wait /
* polling.
*/
function waitForTerminalReady(runtimeId: string): Promise<TerminalRuntime> {
return new Promise((resolve, reject) => {
const runtime = getTerminalRuntime(runtimeId, 'idle')
if (runtime.status === 'running' && runtime.nativeSessionId != null) {
resolve(runtime)
return
}
let unsub: (() => void) | null = null
const timer = setTimeout(() => {
unsub?.()
reject(new Error(`Terminal ${runtimeId} did not start within ${READY_TIMEOUT_MS}ms`))
}, READY_TIMEOUT_MS)
unsub = subscribeTerminalRuntime(runtime, () => {
if (runtime.status === 'error' || runtime.status === 'unavailable') {
clearTimeout(timer)
unsub?.()
reject(new Error(`Terminal runtime entered ${runtime.status} state: ${runtime.error ?? ''}`))
return
}
if (runtime.status === 'running' && runtime.nativeSessionId != null) {
clearTimeout(timer)
unsub?.()
resolve(runtime)
}
})
})
}
export type InstallScriptResult = {
/** The new terminal tab's runtimeId. Caller may use this to focus / cleanup. */
runtimeId: string
/** Commands actually written (post-trim, post-empty-skip). */
commands: string[]
}
/**
* Open a new terminal tab and inject a sequence of install commands.
* Each command ends with `\r` so the PTY treats it as user input
* followed by Enter. Adds a small inter-command delay so output for
* one command finishes scrolling before the next is echoed
* eliminates the "all results pile up at the end" UX.
*
* Throws when:
* - the host platform doesn't have a terminal capability
* (`terminalApi.isAvailable()` is false)
* - terminal tab spawn fails or times out
* Returns the runtime metadata so callers can attach a follow-up
* UI (e.g. focus the tab, or show a "still running…" indicator).
*/
export async function injectInstallScriptIntoNewTerminal(
commands: ReadonlyArray<string>,
): Promise<InstallScriptResult> {
if (!terminalApi.isAvailable()) {
throw new Error('Terminal not available on this host platform')
}
const cleanCommands = commands
.map((c) => c.trim())
.filter((c) => c.length > 0)
if (cleanCommands.length === 0) {
throw new Error('No commands to inject')
}
// Open a brand-new terminal tab. The returned `tabSessionId` is also
// the runtimeId (since openTerminalTab passes no explicit
// terminalRuntimeId — see ContentRouter line that picks
// `tab.terminalRuntimeId ?? tab.sessionId`).
const tabSessionId = useTabStore.getState().openTerminalTab()
// Wait for the TerminalSettings host (mounted by ContentRouter) to
// call terminalApi.spawn() and bind nativeSessionId. Without this
// wait the very first write would race the spawn.
const runtime = await waitForTerminalReady(tabSessionId)
const sessionId = runtime.nativeSessionId
if (sessionId == null) {
throw new Error('Terminal nativeSessionId missing after ready')
}
// Send each command. `\r` (Carriage Return) is what xterm.js sends
// for the Enter key; the shell on the other side of the PTY treats
// it as a complete line and executes. Inter-command delay lets the
// shell finish printing the prompt before the next command echoes.
for (let i = 0; i < cleanCommands.length; i++) {
const cmd = cleanCommands[i]!
await terminalApi.write(sessionId, cmd + '\r')
if (i < cleanCommands.length - 1) {
await new Promise<void>((resolve) =>
setTimeout(resolve, PER_COMMAND_DELAY_MS),
)
}
}
return { runtimeId: tabSessionId, commands: cleanCommands }
}