nikcli 0.0.6
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/.turbo/turbo-typecheck.log +1 -0
- package/AGENTS.md +27 -0
- package/Dockerfile +18 -0
- package/README.md +15 -0
- package/bin/nikcli +84 -0
- package/config.json +13 -0
- package/docs/tailscale-mobile/01-tailscale-setup.md +94 -0
- package/docs/tailscale-mobile/02-host-setup.md +115 -0
- package/docs/tailscale-mobile/03-phone-and-serve.md +134 -0
- package/docs/tailscale-mobile/README.md +59 -0
- package/examples/README.md +54 -0
- package/package.json +147 -0
- package/parsers-config.ts +253 -0
- package/script/build.ts +179 -0
- package/script/postinstall.mjs +125 -0
- package/script/publish-registries.ts +187 -0
- package/script/publish.ts +100 -0
- package/script/schema.ts +47 -0
- package/script/seed-e2e.ts +50 -0
- package/sequential-prancing-forest.md +373 -0
- package/src/acp/README.md +164 -0
- package/src/acp/agent.ts +1303 -0
- package/src/acp/session.ts +105 -0
- package/src/acp/types.ts +22 -0
- package/src/agent/agent.ts +528 -0
- package/src/agent/generate.txt +32 -0
- package/src/agent/prompt/compaction.txt +14 -0
- package/src/agent/prompt/explore.txt +18 -0
- package/src/agent/prompt/summary.txt +11 -0
- package/src/agent/prompt/title.txt +44 -0
- package/src/auth/index.ts +73 -0
- package/src/bun/index.ts +119 -0
- package/src/bun/registry.ts +54 -0
- package/src/bus/bus-event.ts +43 -0
- package/src/bus/global.ts +10 -0
- package/src/bus/index.ts +105 -0
- package/src/chatbot/handlers.ts +150 -0
- package/src/chatbot/index.ts +132 -0
- package/src/cli/bootstrap.ts +17 -0
- package/src/cli/cmd/acp.ts +69 -0
- package/src/cli/cmd/ads.ts +377 -0
- package/src/cli/cmd/agent.ts +259 -0
- package/src/cli/cmd/auth.ts +400 -0
- package/src/cli/cmd/chatbot.ts +420 -0
- package/src/cli/cmd/cmd.ts +7 -0
- package/src/cli/cmd/companion.ts +81 -0
- package/src/cli/cmd/connectors.ts +593 -0
- package/src/cli/cmd/debug/agent.ts +166 -0
- package/src/cli/cmd/debug/config.ts +16 -0
- package/src/cli/cmd/debug/file.ts +97 -0
- package/src/cli/cmd/debug/index.ts +48 -0
- package/src/cli/cmd/debug/lsp.ts +52 -0
- package/src/cli/cmd/debug/ripgrep.ts +87 -0
- package/src/cli/cmd/debug/scrap.ts +16 -0
- package/src/cli/cmd/debug/skill.ts +16 -0
- package/src/cli/cmd/debug/snapshot.ts +52 -0
- package/src/cli/cmd/export.ts +88 -0
- package/src/cli/cmd/generate.ts +38 -0
- package/src/cli/cmd/github.ts +412 -0
- package/src/cli/cmd/image-model.ts +128 -0
- package/src/cli/cmd/import.ts +201 -0
- package/src/cli/cmd/lovable.ts +128 -0
- package/src/cli/cmd/mcp.ts +738 -0
- package/src/cli/cmd/mobile.ts +223 -0
- package/src/cli/cmd/models.ts +77 -0
- package/src/cli/cmd/plug.ts +231 -0
- package/src/cli/cmd/pr.ts +104 -0
- package/src/cli/cmd/rag-model.ts +167 -0
- package/src/cli/cmd/remote.ts +416 -0
- package/src/cli/cmd/run.ts +589 -0
- package/src/cli/cmd/serve.ts +51 -0
- package/src/cli/cmd/session.ts +133 -0
- package/src/cli/cmd/speak-model.ts +204 -0
- package/src/cli/cmd/stats.ts +402 -0
- package/src/cli/cmd/tui/app.tsx +841 -0
- package/src/cli/cmd/tui/attach.ts +31 -0
- package/src/cli/cmd/tui/component/border.tsx +75 -0
- package/src/cli/cmd/tui/component/dialog-agent.tsx +31 -0
- package/src/cli/cmd/tui/component/dialog-command.tsx +172 -0
- package/src/cli/cmd/tui/component/dialog-config.tsx +291 -0
- package/src/cli/cmd/tui/component/dialog-connectors.tsx +440 -0
- package/src/cli/cmd/tui/component/dialog-image-model.tsx +97 -0
- package/src/cli/cmd/tui/component/dialog-mcp.tsx +86 -0
- package/src/cli/cmd/tui/component/dialog-model.tsx +234 -0
- package/src/cli/cmd/tui/component/dialog-provider.tsx +260 -0
- package/src/cli/cmd/tui/component/dialog-rag-model.tsx +217 -0
- package/src/cli/cmd/tui/component/dialog-remote.tsx +489 -0
- package/src/cli/cmd/tui/component/dialog-session-list.tsx +170 -0
- package/src/cli/cmd/tui/component/dialog-session-rename.tsx +31 -0
- package/src/cli/cmd/tui/component/dialog-settings/index.tsx +59 -0
- package/src/cli/cmd/tui/component/dialog-settings/prompt.tsx +40 -0
- package/src/cli/cmd/tui/component/dialog-settings/sidebar.tsx +39 -0
- package/src/cli/cmd/tui/component/dialog-settings/spinner.tsx +62 -0
- package/src/cli/cmd/tui/component/dialog-settings/ui.tsx +58 -0
- package/src/cli/cmd/tui/component/dialog-skills.tsx +117 -0
- package/src/cli/cmd/tui/component/dialog-speak-model.tsx +304 -0
- package/src/cli/cmd/tui/component/dialog-stash.tsx +87 -0
- package/src/cli/cmd/tui/component/dialog-status.tsx +165 -0
- package/src/cli/cmd/tui/component/dialog-tag.tsx +44 -0
- package/src/cli/cmd/tui/component/dialog-theme-create.tsx +717 -0
- package/src/cli/cmd/tui/component/dialog-theme-list.tsx +52 -0
- package/src/cli/cmd/tui/component/dialog-workspace-list.tsx +350 -0
- package/src/cli/cmd/tui/component/error-component.tsx +91 -0
- package/src/cli/cmd/tui/component/logo.tsx +103 -0
- package/src/cli/cmd/tui/component/plugin-route-missing.tsx +14 -0
- package/src/cli/cmd/tui/component/prompt/autocomplete.tsx +669 -0
- package/src/cli/cmd/tui/component/prompt/frecency.tsx +89 -0
- package/src/cli/cmd/tui/component/prompt/history.tsx +108 -0
- package/src/cli/cmd/tui/component/prompt/index.tsx +2165 -0
- package/src/cli/cmd/tui/component/prompt/stash.tsx +63 -0
- package/src/cli/cmd/tui/component/spinner.tsx +24 -0
- package/src/cli/cmd/tui/component/startup-loading.tsx +63 -0
- package/src/cli/cmd/tui/component/table/markdown-table.tsx +267 -0
- package/src/cli/cmd/tui/component/table-db/db/connections.ts +75 -0
- package/src/cli/cmd/tui/component/table-db/db/db-connection.ts +223 -0
- package/src/cli/cmd/tui/component/table-db/db/db-preview.ts +202 -0
- package/src/cli/cmd/tui/component/table-db/db/factory.ts +77 -0
- package/src/cli/cmd/tui/component/table-db/db/index.ts +9 -0
- package/src/cli/cmd/tui/component/table-db/db/mysql-connection.ts +330 -0
- package/src/cli/cmd/tui/component/table-db/db/postgres-connection.ts +338 -0
- package/src/cli/cmd/tui/component/table-db/db/sqlite-connection.ts +302 -0
- package/src/cli/cmd/tui/component/table-db/db/types.ts +108 -0
- package/src/cli/cmd/tui/component/table-db/table/dbedit-hooks.ts +74 -0
- package/src/cli/cmd/tui/component/table-db/table/index.ts +15 -0
- package/src/cli/cmd/tui/component/table-db/table/table-events.ts +54 -0
- package/src/cli/cmd/tui/component/table-db/table/table-formatters.ts +191 -0
- package/src/cli/cmd/tui/component/table-db/table/table-hooks.ts +105 -0
- package/src/cli/cmd/tui/component/table-db/table/table-keyboard-handler.ts +255 -0
- package/src/cli/cmd/tui/component/table-db/table/table-layout-engine.ts +208 -0
- package/src/cli/cmd/tui/component/table-db/table/table-renderable.ts +486 -0
- package/src/cli/cmd/tui/component/table-db/table/table-selection-manager.ts +136 -0
- package/src/cli/cmd/tui/component/table-db/table/table-state.ts +198 -0
- package/src/cli/cmd/tui/component/table-db/table/types.ts +69 -0
- package/src/cli/cmd/tui/component/table-db/ui/db-visualizer.tsx +71 -0
- package/src/cli/cmd/tui/component/table-db/ui/index.ts +2 -0
- package/src/cli/cmd/tui/component/table-db/ui/table-renderer.ts +607 -0
- package/src/cli/cmd/tui/component/textarea-keybindings.ts +73 -0
- package/src/cli/cmd/tui/component/tips.tsx +195 -0
- package/src/cli/cmd/tui/component/todo-item.tsx +32 -0
- package/src/cli/cmd/tui/context/args.tsx +14 -0
- package/src/cli/cmd/tui/context/directory.ts +13 -0
- package/src/cli/cmd/tui/context/exit.tsx +24 -0
- package/src/cli/cmd/tui/context/helper.tsx +25 -0
- package/src/cli/cmd/tui/context/keybind.tsx +102 -0
- package/src/cli/cmd/tui/context/kv.tsx +52 -0
- package/src/cli/cmd/tui/context/local.tsx +458 -0
- package/src/cli/cmd/tui/context/plugin-keybinds.ts +41 -0
- package/src/cli/cmd/tui/context/prompt.tsx +18 -0
- package/src/cli/cmd/tui/context/route.tsx +54 -0
- package/src/cli/cmd/tui/context/sdk.tsx +128 -0
- package/src/cli/cmd/tui/context/server.tsx +8 -0
- package/src/cli/cmd/tui/context/sync.tsx +510 -0
- package/src/cli/cmd/tui/context/theme/abyss.json +233 -0
- package/src/cli/cmd/tui/context/theme/apple.json +235 -0
- package/src/cli/cmd/tui/context/theme/arctic.json +232 -0
- package/src/cli/cmd/tui/context/theme/aura.json +69 -0
- package/src/cli/cmd/tui/context/theme/ayu.json +80 -0
- package/src/cli/cmd/tui/context/theme/ayuai.json +229 -0
- package/src/cli/cmd/tui/context/theme/blood.json +229 -0
- package/src/cli/cmd/tui/context/theme/carbonfox.json +248 -0
- package/src/cli/cmd/tui/context/theme/catmoe.json +235 -0
- package/src/cli/cmd/tui/context/theme/catppuccin-frappe.json +233 -0
- package/src/cli/cmd/tui/context/theme/catppuccin-latte.json +233 -0
- package/src/cli/cmd/tui/context/theme/catppuccin-macchiato.json +233 -0
- package/src/cli/cmd/tui/context/theme/catppuccin.json +259 -0
- package/src/cli/cmd/tui/context/theme/charcoal.json +230 -0
- package/src/cli/cmd/tui/context/theme/chromatic.json +235 -0
- package/src/cli/cmd/tui/context/theme/cobalt2.json +228 -0
- package/src/cli/cmd/tui/context/theme/cosmic.json +234 -0
- package/src/cli/cmd/tui/context/theme/cursor.json +249 -0
- package/src/cli/cmd/tui/context/theme/cyber.json +235 -0
- package/src/cli/cmd/tui/context/theme/dawnfox.json +229 -0
- package/src/cli/cmd/tui/context/theme/dimension.json +235 -0
- package/src/cli/cmd/tui/context/theme/dracula-official.json +222 -0
- package/src/cli/cmd/tui/context/theme/dracula.json +219 -0
- package/src/cli/cmd/tui/context/theme/dream.json +235 -0
- package/src/cli/cmd/tui/context/theme/duo.json +235 -0
- package/src/cli/cmd/tui/context/theme/dusk.json +235 -0
- package/src/cli/cmd/tui/context/theme/ebony.json +232 -0
- package/src/cli/cmd/tui/context/theme/equilibrium.json +232 -0
- package/src/cli/cmd/tui/context/theme/ethereal.json +235 -0
- package/src/cli/cmd/tui/context/theme/everforest.json +241 -0
- package/src/cli/cmd/tui/context/theme/flexoki.json +237 -0
- package/src/cli/cmd/tui/context/theme/fusion.json +235 -0
- package/src/cli/cmd/tui/context/theme/ghost.json +235 -0
- package/src/cli/cmd/tui/context/theme/github-dark.json +229 -0
- package/src/cli/cmd/tui/context/theme/github-dimmed.json +231 -0
- package/src/cli/cmd/tui/context/theme/github-light.json +229 -0
- package/src/cli/cmd/tui/context/theme/github.json +233 -0
- package/src/cli/cmd/tui/context/theme/glass.json +235 -0
- package/src/cli/cmd/tui/context/theme/gold.json +235 -0
- package/src/cli/cmd/tui/context/theme/gone.json +234 -0
- package/src/cli/cmd/tui/context/theme/greyscale.json +229 -0
- package/src/cli/cmd/tui/context/theme/gruvbox.json +242 -0
- package/src/cli/cmd/tui/context/theme/hacker.json +229 -0
- package/src/cli/cmd/tui/context/theme/holo.json +235 -0
- package/src/cli/cmd/tui/context/theme/ink.json +235 -0
- package/src/cli/cmd/tui/context/theme/jet.json +233 -0
- package/src/cli/cmd/tui/context/theme/kanagawa.json +227 -0
- package/src/cli/cmd/tui/context/theme/lavender.json +236 -0
- package/src/cli/cmd/tui/context/theme/lightph.json +235 -0
- package/src/cli/cmd/tui/context/theme/lucent-orng.json +237 -0
- package/src/cli/cmd/tui/context/theme/material-ocean.json +230 -0
- package/src/cli/cmd/tui/context/theme/material.json +235 -0
- package/src/cli/cmd/tui/context/theme/matrix.json +227 -0
- package/src/cli/cmd/tui/context/theme/mercury.json +245 -0
- package/src/cli/cmd/tui/context/theme/midnight.json +235 -0
- package/src/cli/cmd/tui/context/theme/modern.json +235 -0
- package/src/cli/cmd/tui/context/theme/monokai.json +221 -0
- package/src/cli/cmd/tui/context/theme/muted.json +229 -0
- package/src/cli/cmd/tui/context/theme/neon.json +229 -0
- package/src/cli/cmd/tui/context/theme/neonfusion.json +235 -0
- package/src/cli/cmd/tui/context/theme/neutral.json +235 -0
- package/src/cli/cmd/tui/context/theme/nightowl.json +221 -0
- package/src/cli/cmd/tui/context/theme/nikcli.json +245 -0
- package/src/cli/cmd/tui/context/theme/nord.json +223 -0
- package/src/cli/cmd/tui/context/theme/nordic.json +235 -0
- package/src/cli/cmd/tui/context/theme/nova.json +235 -0
- package/src/cli/cmd/tui/context/theme/obsidian.json +234 -0
- package/src/cli/cmd/tui/context/theme/one-dark.json +231 -0
- package/src/cli/cmd/tui/context/theme/one-pro.json +229 -0
- package/src/cli/cmd/tui/context/theme/onyx.json +233 -0
- package/src/cli/cmd/tui/context/theme/orng.json +249 -0
- package/src/cli/cmd/tui/context/theme/osaka-jade.json +240 -0
- package/src/cli/cmd/tui/context/theme/oxocarbon.json +229 -0
- package/src/cli/cmd/tui/context/theme/palenight.json +222 -0
- package/src/cli/cmd/tui/context/theme/poimandres.json +230 -0
- package/src/cli/cmd/tui/context/theme/prism.json +235 -0
- package/src/cli/cmd/tui/context/theme/radiant.json +235 -0
- package/src/cli/cmd/tui/context/theme/rosepine.json +234 -0
- package/src/cli/cmd/tui/context/theme/shadow.json +235 -0
- package/src/cli/cmd/tui/context/theme/silicon.json +235 -0
- package/src/cli/cmd/tui/context/theme/slate.json +233 -0
- package/src/cli/cmd/tui/context/theme/soft.json +235 -0
- package/src/cli/cmd/tui/context/theme/solarized.json +223 -0
- package/src/cli/cmd/tui/context/theme/spectrum.json +235 -0
- package/src/cli/cmd/tui/context/theme/starlight.json +233 -0
- package/src/cli/cmd/tui/context/theme/sunrise.json +235 -0
- package/src/cli/cmd/tui/context/theme/synthwave84.json +226 -0
- package/src/cli/cmd/tui/context/theme/tech.json +235 -0
- package/src/cli/cmd/tui/context/theme/tokyonight-storm.json +245 -0
- package/src/cli/cmd/tui/context/theme/tokyonight.json +243 -0
- package/src/cli/cmd/tui/context/theme/vapor.json +235 -0
- package/src/cli/cmd/tui/context/theme/vercel.json +245 -0
- package/src/cli/cmd/tui/context/theme/vesper.json +218 -0
- package/src/cli/cmd/tui/context/theme/vivid.json +232 -0
- package/src/cli/cmd/tui/context/theme/void.json +235 -0
- package/src/cli/cmd/tui/context/theme/vscode.json +235 -0
- package/src/cli/cmd/tui/context/theme/zenburn.json +223 -0
- package/src/cli/cmd/tui/context/theme/zinc.json +236 -0
- package/src/cli/cmd/tui/context/theme.tsx +1303 -0
- package/src/cli/cmd/tui/event.ts +48 -0
- package/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx +152 -0
- package/src/cli/cmd/tui/feature-plugins/home/tips.tsx +50 -0
- package/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +63 -0
- package/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx +62 -0
- package/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx +93 -0
- package/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx +66 -0
- package/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx +96 -0
- package/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx +48 -0
- package/src/cli/cmd/tui/feature-plugins/system/plugins.tsx +288 -0
- package/src/cli/cmd/tui/plugin/api.tsx +407 -0
- package/src/cli/cmd/tui/plugin/index.ts +3 -0
- package/src/cli/cmd/tui/plugin/internal.ts +25 -0
- package/src/cli/cmd/tui/plugin/runtime.ts +1048 -0
- package/src/cli/cmd/tui/plugin/slots.tsx +61 -0
- package/src/cli/cmd/tui/routes/home.tsx +153 -0
- package/src/cli/cmd/tui/routes/session/dbedit.tsx +474 -0
- package/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx +65 -0
- package/src/cli/cmd/tui/routes/session/dialog-message.tsx +110 -0
- package/src/cli/cmd/tui/routes/session/dialog-subagent.tsx +105 -0
- package/src/cli/cmd/tui/routes/session/dialog-timeline.tsx +47 -0
- package/src/cli/cmd/tui/routes/session/footer.tsx +75 -0
- package/src/cli/cmd/tui/routes/session/header.tsx +177 -0
- package/src/cli/cmd/tui/routes/session/index.tsx +2280 -0
- package/src/cli/cmd/tui/routes/session/permission.tsx +540 -0
- package/src/cli/cmd/tui/routes/session/question.tsx +435 -0
- package/src/cli/cmd/tui/routes/session/sidebar.tsx +313 -0
- package/src/cli/cmd/tui/thread.ts +174 -0
- package/src/cli/cmd/tui/ui/dialog-alert.tsx +57 -0
- package/src/cli/cmd/tui/ui/dialog-confirm.tsx +83 -0
- package/src/cli/cmd/tui/ui/dialog-export-options.tsx +204 -0
- package/src/cli/cmd/tui/ui/dialog-help.tsx +38 -0
- package/src/cli/cmd/tui/ui/dialog-prompt.tsx +102 -0
- package/src/cli/cmd/tui/ui/dialog-select.tsx +389 -0
- package/src/cli/cmd/tui/ui/dialog.tsx +180 -0
- package/src/cli/cmd/tui/ui/link.tsx +34 -0
- package/src/cli/cmd/tui/ui/spinner.ts +368 -0
- package/src/cli/cmd/tui/ui/toast.tsx +138 -0
- package/src/cli/cmd/tui/util/clipboard.ts +154 -0
- package/src/cli/cmd/tui/util/editor.ts +32 -0
- package/src/cli/cmd/tui/util/signal.ts +7 -0
- package/src/cli/cmd/tui/util/terminal.ts +114 -0
- package/src/cli/cmd/tui/util/transcript.ts +98 -0
- package/src/cli/cmd/tui/win32.ts +110 -0
- package/src/cli/cmd/tui/worker.ts +156 -0
- package/src/cli/cmd/uninstall.ts +357 -0
- package/src/cli/cmd/upgrade.ts +72 -0
- package/src/cli/cmd/web.ts +87 -0
- package/src/cli/cmd/workspace-serve.ts +16 -0
- package/src/cli/error.ts +57 -0
- package/src/cli/network.ts +55 -0
- package/src/cli/remote/index.ts +36 -0
- package/src/cli/remote/notifications.ts +104 -0
- package/src/cli/remote/qr-renderer.ts +86 -0
- package/src/cli/remote/remote-service.ts +757 -0
- package/src/cli/remote/session-manager.ts +284 -0
- package/src/cli/remote/subagent-hooks.ts +151 -0
- package/src/cli/remote/types.ts +121 -0
- package/src/cli/ui.ts +96 -0
- package/src/cli/upgrade.ts +25 -0
- package/src/command/index.ts +174 -0
- package/src/command/template/initialize.txt +10 -0
- package/src/command/template/review.txt +99 -0
- package/src/config/config.ts +1760 -0
- package/src/config/markdown.ts +88 -0
- package/src/config/migrate-tui-config.ts +155 -0
- package/src/config/paths.ts +174 -0
- package/src/config/tui-schema.ts +36 -0
- package/src/config/tui.ts +209 -0
- package/src/connectors/api/base.ts +75 -0
- package/src/connectors/api/figma.ts +103 -0
- package/src/connectors/api/github.ts +247 -0
- package/src/connectors/api/lovable.ts +126 -0
- package/src/connectors/api/slack.ts +137 -0
- package/src/connectors/auth.ts +68 -0
- package/src/connectors/cache.ts +119 -0
- package/src/connectors/credentials.ts +81 -0
- package/src/connectors/index.ts +202 -0
- package/src/connectors/registry.ts +358 -0
- package/src/docs/context.ts +120 -0
- package/src/docs/library.ts +189 -0
- package/src/env/index.ts +26 -0
- package/src/file/ignore.ts +83 -0
- package/src/file/index.ts +411 -0
- package/src/file/ripgrep.ts +402 -0
- package/src/file/time.ts +65 -0
- package/src/file/watcher.ts +127 -0
- package/src/flag/flag.ts +128 -0
- package/src/format/formatter.ts +356 -0
- package/src/format/index.ts +137 -0
- package/src/global/index.ts +57 -0
- package/src/id/id.ts +83 -0
- package/src/ide/index.ts +76 -0
- package/src/index.ts +184 -0
- package/src/installation/index.ts +246 -0
- package/src/lsp/client.ts +250 -0
- package/src/lsp/index.ts +483 -0
- package/src/lsp/language.ts +119 -0
- package/src/lsp/server.ts +2046 -0
- package/src/mcp/auth.ts +121 -0
- package/src/mcp/index.ts +860 -0
- package/src/mcp/oauth-callback.ts +198 -0
- package/src/mcp/oauth-provider.ts +148 -0
- package/src/mobile/auth.ts +97 -0
- package/src/mobile/github-repo.ts +185 -0
- package/src/patch/index.ts +631 -0
- package/src/permission/arity.ts +150 -0
- package/src/permission/dbedit.ts +236 -0
- package/src/permission/index.ts +210 -0
- package/src/permission/next.ts +287 -0
- package/src/plugin/codex.ts +493 -0
- package/src/plugin/copilot.ts +261 -0
- package/src/plugin/index.ts +714 -0
- package/src/plugin/install.ts +379 -0
- package/src/plugin/meta.ts +165 -0
- package/src/plugin/shared.ts +188 -0
- package/src/project/bootstrap.ts +35 -0
- package/src/project/instance.ts +84 -0
- package/src/project/project.ts +373 -0
- package/src/project/state.ts +66 -0
- package/src/project/vcs.ts +76 -0
- package/src/prompt/stash-store.ts +93 -0
- package/src/provider/auth.ts +147 -0
- package/src/provider/models-macro.ts +22 -0
- package/src/provider/models.ts +216 -0
- package/src/provider/provider.ts +1483 -0
- package/src/provider/sdk/openai-compatible/src/README.md +5 -0
- package/src/provider/sdk/openai-compatible/src/index.ts +2 -0
- package/src/provider/sdk/openai-compatible/src/openai-compatible-provider.ts +100 -0
- package/src/provider/sdk/openai-compatible/src/responses/convert-to-openai-responses-input.ts +303 -0
- package/src/provider/sdk/openai-compatible/src/responses/map-openai-responses-finish-reason.ts +22 -0
- package/src/provider/sdk/openai-compatible/src/responses/openai-config.ts +18 -0
- package/src/provider/sdk/openai-compatible/src/responses/openai-error.ts +22 -0
- package/src/provider/sdk/openai-compatible/src/responses/openai-responses-api-types.ts +207 -0
- package/src/provider/sdk/openai-compatible/src/responses/openai-responses-language-model.ts +1732 -0
- package/src/provider/sdk/openai-compatible/src/responses/openai-responses-prepare-tools.ts +177 -0
- package/src/provider/sdk/openai-compatible/src/responses/openai-responses-settings.ts +1 -0
- package/src/provider/sdk/openai-compatible/src/responses/tool/code-interpreter.ts +88 -0
- package/src/provider/sdk/openai-compatible/src/responses/tool/file-search.ts +128 -0
- package/src/provider/sdk/openai-compatible/src/responses/tool/image-generation.ts +115 -0
- package/src/provider/sdk/openai-compatible/src/responses/tool/local-shell.ts +65 -0
- package/src/provider/sdk/openai-compatible/src/responses/tool/web-search-preview.ts +104 -0
- package/src/provider/sdk/openai-compatible/src/responses/tool/web-search.ts +103 -0
- package/src/provider/transform.ts +828 -0
- package/src/pty/index.ts +241 -0
- package/src/question/index.ts +171 -0
- package/src/rag/chunk.ts +43 -0
- package/src/rag/embed.ts +179 -0
- package/src/rag/index.ts +376 -0
- package/src/rag/storage.ts +76 -0
- package/src/scheduler/index.ts +61 -0
- package/src/server/error.ts +36 -0
- package/src/server/event.ts +7 -0
- package/src/server/mdns.ts +59 -0
- package/src/server/routes/chatbot.ts +205 -0
- package/src/server/routes/companion.ts +729 -0
- package/src/server/routes/config.ts +92 -0
- package/src/server/routes/connectors.ts +121 -0
- package/src/server/routes/dbedit.ts +76 -0
- package/src/server/routes/experimental.ts +210 -0
- package/src/server/routes/file.ts +197 -0
- package/src/server/routes/global.ts +135 -0
- package/src/server/routes/mcp.ts +225 -0
- package/src/server/routes/mobile.ts +2044 -0
- package/src/server/routes/permission.ts +68 -0
- package/src/server/routes/project.ts +82 -0
- package/src/server/routes/provider.ts +235 -0
- package/src/server/routes/pty.ts +169 -0
- package/src/server/routes/question.ts +98 -0
- package/src/server/routes/session.ts +968 -0
- package/src/server/routes/tui.ts +379 -0
- package/src/server/routes/workspace.ts +104 -0
- package/src/server/server.ts +761 -0
- package/src/server/ssh.ts +207 -0
- package/src/session/auth.ts +402 -0
- package/src/session/compaction.ts +253 -0
- package/src/session/generate.ts +38 -0
- package/src/session/index.ts +598 -0
- package/src/session/llm.ts +273 -0
- package/src/session/message-v2.ts +836 -0
- package/src/session/message.ts +189 -0
- package/src/session/processor.ts +408 -0
- package/src/session/prompt/anthropic-20250930.txt +165 -0
- package/src/session/prompt/anthropic.txt +105 -0
- package/src/session/prompt/anthropic_spoof.txt +1 -0
- package/src/session/prompt/beast.txt +147 -0
- package/src/session/prompt/build-switch.txt +5 -0
- package/src/session/prompt/codex_header.txt +79 -0
- package/src/session/prompt/copilot-gpt-5.txt +143 -0
- package/src/session/prompt/gemini.txt +155 -0
- package/src/session/prompt/max-steps.txt +16 -0
- package/src/session/prompt/plan-reminder-anthropic.txt +67 -0
- package/src/session/prompt/plan.txt +25 -0
- package/src/session/prompt/qwen.txt +108 -0
- package/src/session/prompt.ts +1942 -0
- package/src/session/retry.ts +90 -0
- package/src/session/revert.ts +120 -0
- package/src/session/stats.ts +404 -0
- package/src/session/status.ts +84 -0
- package/src/session/summary.ts +184 -0
- package/src/session/system.ts +195 -0
- package/src/session/toast.tsx +105 -0
- package/src/session/todo.ts +258 -0
- package/src/session/uninstall.ts +357 -0
- package/src/share/share-next.ts +421 -0
- package/src/share/share.ts +92 -0
- package/src/shell/shell.ts +65 -0
- package/src/skill/index.ts +1 -0
- package/src/skill/skill.ts +232 -0
- package/src/snapshot/index.ts +297 -0
- package/src/storage/storage.ts +227 -0
- package/src/tool/apply_patch.ts +288 -0
- package/src/tool/apply_patch.txt +33 -0
- package/src/tool/bash.ts +252 -0
- package/src/tool/bash.txt +115 -0
- package/src/tool/batch.ts +175 -0
- package/src/tool/batch.txt +24 -0
- package/src/tool/codesearch.ts +132 -0
- package/src/tool/codesearch.txt +12 -0
- package/src/tool/context_collect.ts +152 -0
- package/src/tool/context_collect.txt +9 -0
- package/src/tool/context_diagnostics.ts +81 -0
- package/src/tool/context_diagnostics.txt +5 -0
- package/src/tool/context_related.ts +117 -0
- package/src/tool/context_related.txt +5 -0
- package/src/tool/context_search.ts +108 -0
- package/src/tool/context_search.txt +8 -0
- package/src/tool/db-diff.ts +434 -0
- package/src/tool/db-table.txt +15 -0
- package/src/tool/docs_add.ts +50 -0
- package/src/tool/docs_add.txt +5 -0
- package/src/tool/docs_context.ts +56 -0
- package/src/tool/docs_context.txt +4 -0
- package/src/tool/docs_gap_report.ts +79 -0
- package/src/tool/docs_gap_report.txt +7 -0
- package/src/tool/docs_load.ts +41 -0
- package/src/tool/docs_load.txt +4 -0
- package/src/tool/docs_request.ts +129 -0
- package/src/tool/docs_request.txt +7 -0
- package/src/tool/docs_search.ts +51 -0
- package/src/tool/docs_search.txt +6 -0
- package/src/tool/docs_unload.ts +38 -0
- package/src/tool/docs_unload.txt +5 -0
- package/src/tool/edit.ts +614 -0
- package/src/tool/edit.txt +10 -0
- package/src/tool/external-directory.ts +32 -0
- package/src/tool/generate_image.ts +174 -0
- package/src/tool/generate_image.txt +12 -0
- package/src/tool/glob.ts +79 -0
- package/src/tool/glob.txt +6 -0
- package/src/tool/grep.ts +153 -0
- package/src/tool/grep.txt +8 -0
- package/src/tool/invalid.ts +17 -0
- package/src/tool/ls.ts +116 -0
- package/src/tool/ls.txt +1 -0
- package/src/tool/lsp.ts +96 -0
- package/src/tool/lsp.txt +19 -0
- package/src/tool/memory_search.ts +141 -0
- package/src/tool/memory_search.txt +8 -0
- package/src/tool/multiedit.ts +46 -0
- package/src/tool/multiedit.txt +41 -0
- package/src/tool/plan-enter.txt +14 -0
- package/src/tool/plan-exit.txt +13 -0
- package/src/tool/plan.ts +130 -0
- package/src/tool/question.ts +33 -0
- package/src/tool/question.txt +10 -0
- package/src/tool/rag_index.ts +77 -0
- package/src/tool/rag_index.txt +10 -0
- package/src/tool/rag_reset.ts +26 -0
- package/src/tool/rag_reset.txt +4 -0
- package/src/tool/rag_search.ts +62 -0
- package/src/tool/rag_search.txt +6 -0
- package/src/tool/rag_status.ts +45 -0
- package/src/tool/rag_status.txt +4 -0
- package/src/tool/read.ts +203 -0
- package/src/tool/read.txt +12 -0
- package/src/tool/registry.ts +214 -0
- package/src/tool/skill.ts +169 -0
- package/src/tool/skill.txt +3 -0
- package/src/tool/smart_docs.ts +74 -0
- package/src/tool/smart_docs.txt +7 -0
- package/src/tool/speak/elevenlabs.ts +201 -0
- package/src/tool/speak/openrouter.ts +240 -0
- package/src/tool/speak/provider.ts +83 -0
- package/src/tool/speak.ts +440 -0
- package/src/tool/task.ts +194 -0
- package/src/tool/task.txt +60 -0
- package/src/tool/todo.ts +53 -0
- package/src/tool/todoread.txt +14 -0
- package/src/tool/todowrite.txt +167 -0
- package/src/tool/tool.ts +87 -0
- package/src/tool/tree.ts +218 -0
- package/src/tool/tree.txt +8 -0
- package/src/tool/truncation.ts +106 -0
- package/src/tool/use-connector.ts +47 -0
- package/src/tool/voice.ts +188 -0
- package/src/tool/webfetch.ts +205 -0
- package/src/tool/webfetch.txt +13 -0
- package/src/tool/websearch.ts +150 -0
- package/src/tool/websearch.txt +14 -0
- package/src/tool/write.ts +80 -0
- package/src/tool/write.txt +8 -0
- package/src/util/archive.ts +16 -0
- package/src/util/color.ts +19 -0
- package/src/util/context.ts +25 -0
- package/src/util/defer.ts +12 -0
- package/src/util/error.ts +77 -0
- package/src/util/eventloop.ts +20 -0
- package/src/util/filesystem.ts +125 -0
- package/src/util/flock.ts +329 -0
- package/src/util/fn.ts +11 -0
- package/src/util/format.ts +20 -0
- package/src/util/hash.ts +7 -0
- package/src/util/iife.ts +3 -0
- package/src/util/keybind.ts +103 -0
- package/src/util/lazy.ts +18 -0
- package/src/util/locale.ts +81 -0
- package/src/util/lock.ts +98 -0
- package/src/util/log.ts +180 -0
- package/src/util/network.ts +9 -0
- package/src/util/process.ts +15 -0
- package/src/util/queue.ts +32 -0
- package/src/util/record.ts +3 -0
- package/src/util/rpc.ts +66 -0
- package/src/util/scrap.ts +10 -0
- package/src/util/signal.ts +12 -0
- package/src/util/timeout.ts +14 -0
- package/src/util/token.ts +7 -0
- package/src/util/wildcard.ts +56 -0
- package/src/workspace/adaptors/index.ts +271 -0
- package/src/workspace/adaptors/types.ts +14 -0
- package/src/workspace/adaptors/worktree.ts +31 -0
- package/src/workspace/config.ts +19 -0
- package/src/workspace/index.ts +223 -0
- package/src/workspace/session-proxy-middleware.ts +97 -0
- package/src/workspace/sse.ts +66 -0
- package/src/workspace/workspace-context.ts +23 -0
- package/src/workspace/workspace-server/routes.ts +33 -0
- package/src/workspace/workspace-server/server.ts +47 -0
- package/src/worktree/index.ts +487 -0
- package/sst-env.d.ts +10 -0
- package/test/benchmark.test.ts +121 -0
- package/test/build-optimizations.test.ts +124 -0
- package/test/id-benchmark.test.ts +132 -0
- package/test/optimizations.test.ts +302 -0
- package/test/preload.ts +1 -0
- package/test/solidjs-benchmark.test.ts +262 -0
- package/test/solidjs-optimizations.test.ts +259 -0
- package/test/tui-benchmark.test.ts +230 -0
- package/test/wildcard-benchmark.test.ts +180 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,2165 @@
|
|
|
1
|
+
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent } from "@opentui/core"
|
|
2
|
+
import {
|
|
3
|
+
createEffect,
|
|
4
|
+
createMemo,
|
|
5
|
+
type JSX,
|
|
6
|
+
onMount,
|
|
7
|
+
createSignal,
|
|
8
|
+
onCleanup,
|
|
9
|
+
on,
|
|
10
|
+
Show,
|
|
11
|
+
Switch,
|
|
12
|
+
Match,
|
|
13
|
+
For,
|
|
14
|
+
} from "solid-js"
|
|
15
|
+
import "opentui-spinner/solid"
|
|
16
|
+
import { useLocal } from "@tui/context/local"
|
|
17
|
+
import { useTheme } from "@tui/context/theme"
|
|
18
|
+
import { EmptyBorder } from "@tui/component/border"
|
|
19
|
+
import { useSDK } from "@tui/context/sdk"
|
|
20
|
+
import { useRoute } from "@tui/context/route"
|
|
21
|
+
import { useSync } from "@tui/context/sync"
|
|
22
|
+
import { Identifier } from "@/id/id"
|
|
23
|
+
import { createStore, produce } from "solid-js/store"
|
|
24
|
+
import { useKeybind } from "@tui/context/keybind"
|
|
25
|
+
import { usePromptHistory, type PromptInfo } from "./history"
|
|
26
|
+
import { usePromptStash } from "./stash"
|
|
27
|
+
import { DialogStash } from "../dialog-stash"
|
|
28
|
+
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
|
29
|
+
import { useCommandDialog } from "../dialog-command"
|
|
30
|
+
import { useRenderer } from "@opentui/solid"
|
|
31
|
+
import { Editor } from "@tui/util/editor"
|
|
32
|
+
import { useExit } from "../../context/exit"
|
|
33
|
+
import { Clipboard } from "../../util/clipboard"
|
|
34
|
+
import type { FilePart } from "@nikcli-ai/sdk/v2"
|
|
35
|
+
import { TuiEvent } from "../../event"
|
|
36
|
+
import { iife } from "@/util/iife"
|
|
37
|
+
import { Locale } from "@/util/locale"
|
|
38
|
+
import { formatDuration } from "@/util/format"
|
|
39
|
+
import { createColors, createFrames } from "../../ui/spinner.ts"
|
|
40
|
+
import type { SpinnerStyle } from "../dialog-settings/spinner"
|
|
41
|
+
import { useDialog } from "@tui/ui/dialog"
|
|
42
|
+
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
|
|
43
|
+
import { DialogAlert } from "../../ui/dialog-alert"
|
|
44
|
+
import { useToast } from "../../ui/toast"
|
|
45
|
+
import { useKV } from "../../context/kv"
|
|
46
|
+
import { useTextareaKeybindings } from "../textarea-keybindings"
|
|
47
|
+
import { DialogThemeCreate } from "../dialog-theme-create"
|
|
48
|
+
import { DialogRagModel } from "../dialog-rag-model"
|
|
49
|
+
import { DialogImageModel } from "../dialog-image-model"
|
|
50
|
+
import { DialogSpeakModel } from "../dialog-speak-model"
|
|
51
|
+
import { DialogRemote } from "../dialog-remote"
|
|
52
|
+
import { DialogSubagent } from "@tui/routes/session/dialog-subagent"
|
|
53
|
+
import os from "os"
|
|
54
|
+
import path from "path"
|
|
55
|
+
import { rmSync } from "fs"
|
|
56
|
+
import { Auth } from "@/auth"
|
|
57
|
+
|
|
58
|
+
export type PromptProps = {
|
|
59
|
+
sessionID?: string
|
|
60
|
+
workspaceID?: string
|
|
61
|
+
visible?: boolean
|
|
62
|
+
disabled?: boolean
|
|
63
|
+
onSubmit?: () => void
|
|
64
|
+
ref?: (ref: PromptRef) => void
|
|
65
|
+
hint?: JSX.Element
|
|
66
|
+
showPlaceholder?: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type PromptRef = {
|
|
70
|
+
focused: boolean
|
|
71
|
+
current: PromptInfo
|
|
72
|
+
set(prompt: PromptInfo): void
|
|
73
|
+
reset(): void
|
|
74
|
+
blur(): void
|
|
75
|
+
focus(): void
|
|
76
|
+
submit(): void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"]
|
|
80
|
+
const SHELL_PLACEHOLDERS = ["ls -la", "git status", "pwd"]
|
|
81
|
+
const VOICE_TRANSCRIBE_MODEL = "openai/gpt-audio-mini"
|
|
82
|
+
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
83
|
+
const SWIFT_MIC_PERMISSION_ERROR = "__NIKCLI_MIC_PERMISSION_DENIED__"
|
|
84
|
+
|
|
85
|
+
const SWIFT_RECORDER_SOURCE = String.raw`
|
|
86
|
+
import Foundation
|
|
87
|
+
import AVFoundation
|
|
88
|
+
|
|
89
|
+
let permissionMarker = "__NIKCLI_MIC_PERMISSION_DENIED__"
|
|
90
|
+
|
|
91
|
+
guard CommandLine.arguments.count >= 2 else {
|
|
92
|
+
fputs("missing output path\n", stderr)
|
|
93
|
+
exit(2)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let outputPath = CommandLine.arguments[1]
|
|
97
|
+
let outputURL = URL(fileURLWithPath: outputPath)
|
|
98
|
+
|
|
99
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
100
|
+
var granted = false
|
|
101
|
+
|
|
102
|
+
AVCaptureDevice.requestAccess(for: .audio) { allowed in
|
|
103
|
+
granted = allowed
|
|
104
|
+
semaphore.signal()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
_ = semaphore.wait(timeout: .now() + 30)
|
|
108
|
+
|
|
109
|
+
if !granted {
|
|
110
|
+
fputs(permissionMarker + "\n", stderr)
|
|
111
|
+
exit(13)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let settings: [String: Any] = [
|
|
115
|
+
AVFormatIDKey: Int(kAudioFormatLinearPCM),
|
|
116
|
+
AVSampleRateKey: 16000,
|
|
117
|
+
AVNumberOfChannelsKey: 1,
|
|
118
|
+
AVLinearPCMBitDepthKey: 16,
|
|
119
|
+
AVLinearPCMIsBigEndianKey: false,
|
|
120
|
+
AVLinearPCMIsFloatKey: false,
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
do {
|
|
124
|
+
let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
|
125
|
+
recorder.prepareToRecord()
|
|
126
|
+
|
|
127
|
+
if !recorder.record() {
|
|
128
|
+
fputs("failed to start recording\n", stderr)
|
|
129
|
+
exit(14)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
signal(SIGINT, SIG_IGN)
|
|
133
|
+
signal(SIGTERM, SIG_IGN)
|
|
134
|
+
|
|
135
|
+
let stop: () -> Void = {
|
|
136
|
+
recorder.stop()
|
|
137
|
+
exit(0)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let sigintSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
|
|
141
|
+
sigintSource.setEventHandler(handler: stop)
|
|
142
|
+
sigintSource.resume()
|
|
143
|
+
|
|
144
|
+
let sigtermSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
|
|
145
|
+
sigtermSource.setEventHandler(handler: stop)
|
|
146
|
+
sigtermSource.resume()
|
|
147
|
+
|
|
148
|
+
RunLoop.main.run()
|
|
149
|
+
} catch {
|
|
150
|
+
fputs("recorder error: \(error)\n", stderr)
|
|
151
|
+
exit(15)
|
|
152
|
+
}
|
|
153
|
+
`
|
|
154
|
+
|
|
155
|
+
export function Prompt(props: PromptProps) {
|
|
156
|
+
let input: TextareaRenderable
|
|
157
|
+
let anchor: BoxRenderable
|
|
158
|
+
let autocomplete: AutocompleteRef
|
|
159
|
+
|
|
160
|
+
const keybind = useKeybind()
|
|
161
|
+
const local = useLocal()
|
|
162
|
+
const sdk = useSDK()
|
|
163
|
+
const route = useRoute()
|
|
164
|
+
const sync = useSync()
|
|
165
|
+
const dialog = useDialog()
|
|
166
|
+
const toast = useToast()
|
|
167
|
+
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
|
|
168
|
+
const history = usePromptHistory()
|
|
169
|
+
const stash = usePromptStash()
|
|
170
|
+
const command = useCommandDialog()
|
|
171
|
+
const renderer = useRenderer()
|
|
172
|
+
const { theme, syntax } = useTheme()
|
|
173
|
+
const kv = useKV()
|
|
174
|
+
const ads = createMemo(() => sync.data.config.ads)
|
|
175
|
+
const [currentAd, setCurrentAd] = createSignal<string | null>(null)
|
|
176
|
+
const [voiceStatus, setVoiceStatus] = createSignal<"idle" | "recording" | "transcribing">("idle")
|
|
177
|
+
|
|
178
|
+
let voiceRecorder: ReturnType<typeof Bun.spawn> | null = null
|
|
179
|
+
let voiceAudioPath: string | null = null
|
|
180
|
+
let voiceAutoStopTimer: ReturnType<typeof setTimeout> | undefined
|
|
181
|
+
let voicePressStartedAt = 0
|
|
182
|
+
let swiftRecorderScriptPath: string | null = null
|
|
183
|
+
let hasShownMicHint = false
|
|
184
|
+
|
|
185
|
+
function cleanupVoiceAudio(filePath: string | null) {
|
|
186
|
+
if (!filePath) return
|
|
187
|
+
try {
|
|
188
|
+
rmSync(filePath, { force: true })
|
|
189
|
+
} catch {
|
|
190
|
+
// ignore cleanup errors
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function ensureSwiftRecorderScriptPath(): Promise<string | null> {
|
|
195
|
+
if (process.platform !== "darwin") return null
|
|
196
|
+
if (swiftRecorderScriptPath) return swiftRecorderScriptPath
|
|
197
|
+
|
|
198
|
+
const swift = Bun.which("swift")
|
|
199
|
+
if (!swift) return null
|
|
200
|
+
|
|
201
|
+
const scriptPath = path.join(os.tmpdir(), "nikcli-mic-recorder.swift")
|
|
202
|
+
const file = Bun.file(scriptPath)
|
|
203
|
+
const exists = await file.exists()
|
|
204
|
+
|
|
205
|
+
if (!exists) {
|
|
206
|
+
await Bun.write(scriptPath, SWIFT_RECORDER_SOURCE)
|
|
207
|
+
} else {
|
|
208
|
+
const current = await file.text().catch(() => "")
|
|
209
|
+
if (current !== SWIFT_RECORDER_SOURCE) {
|
|
210
|
+
await Bun.write(scriptPath, SWIFT_RECORDER_SOURCE)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
swiftRecorderScriptPath = scriptPath
|
|
215
|
+
return swiftRecorderScriptPath
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function detectVoiceRecorder(filePath: string): Promise<{ command: string[]; name: string } | null> {
|
|
219
|
+
const ffmpeg = Bun.which("ffmpeg")
|
|
220
|
+
if (ffmpeg) {
|
|
221
|
+
if (process.platform === "darwin") {
|
|
222
|
+
return {
|
|
223
|
+
name: "ffmpeg",
|
|
224
|
+
command: [
|
|
225
|
+
ffmpeg,
|
|
226
|
+
"-hide_banner",
|
|
227
|
+
"-loglevel",
|
|
228
|
+
"error",
|
|
229
|
+
"-f",
|
|
230
|
+
"avfoundation",
|
|
231
|
+
"-i",
|
|
232
|
+
"none:0",
|
|
233
|
+
"-ac",
|
|
234
|
+
"1",
|
|
235
|
+
"-ar",
|
|
236
|
+
"16000",
|
|
237
|
+
"-c:a",
|
|
238
|
+
"pcm_s16le",
|
|
239
|
+
"-y",
|
|
240
|
+
filePath,
|
|
241
|
+
],
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (process.platform === "linux") {
|
|
246
|
+
return {
|
|
247
|
+
name: "ffmpeg",
|
|
248
|
+
command: [
|
|
249
|
+
ffmpeg,
|
|
250
|
+
"-hide_banner",
|
|
251
|
+
"-loglevel",
|
|
252
|
+
"error",
|
|
253
|
+
"-f",
|
|
254
|
+
"pulse",
|
|
255
|
+
"-i",
|
|
256
|
+
"default",
|
|
257
|
+
"-ac",
|
|
258
|
+
"1",
|
|
259
|
+
"-ar",
|
|
260
|
+
"16000",
|
|
261
|
+
"-c:a",
|
|
262
|
+
"pcm_s16le",
|
|
263
|
+
"-y",
|
|
264
|
+
filePath,
|
|
265
|
+
],
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const rec = Bun.which("rec")
|
|
271
|
+
if (rec) {
|
|
272
|
+
return {
|
|
273
|
+
name: "rec",
|
|
274
|
+
command: [rec, "-q", "-c", "1", "-r", "16000", filePath],
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (process.platform === "darwin") {
|
|
279
|
+
const swift = Bun.which("swift")
|
|
280
|
+
const scriptPath = await ensureSwiftRecorderScriptPath()
|
|
281
|
+
if (swift && scriptPath) {
|
|
282
|
+
return {
|
|
283
|
+
name: "swift-avfoundation",
|
|
284
|
+
command: [swift, scriptPath, filePath],
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return null
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function looksLikeMicPermissionError(message: string): boolean {
|
|
293
|
+
const value = message.toLowerCase()
|
|
294
|
+
return (
|
|
295
|
+
value.includes(SWIFT_MIC_PERMISSION_ERROR.toLowerCase()) ||
|
|
296
|
+
value.includes("permission denied") ||
|
|
297
|
+
value.includes("not permitted") ||
|
|
298
|
+
value.includes("not authorized") ||
|
|
299
|
+
value.includes("operation not permitted")
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function currentTerminalName(): string {
|
|
304
|
+
return process.env.TERM_PROGRAM || process.env.TERMINAL_EMULATOR || process.env.TERM || "terminal"
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function isLikelyIntegratedTerminal(): boolean {
|
|
308
|
+
const value = currentTerminalName().toLowerCase()
|
|
309
|
+
return value.includes("vscode") || value.includes("zed") || value.includes("warp") || value.includes("jetbrains")
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function extractTranscriptContent(content: unknown): string {
|
|
313
|
+
if (typeof content === "string") return content.trim()
|
|
314
|
+
if (!Array.isArray(content)) return ""
|
|
315
|
+
|
|
316
|
+
const merged = content
|
|
317
|
+
.map((part) => {
|
|
318
|
+
if (typeof part === "string") return part
|
|
319
|
+
if (part && typeof part === "object" && "text" in part && typeof (part as any).text === "string") {
|
|
320
|
+
return (part as any).text
|
|
321
|
+
}
|
|
322
|
+
return ""
|
|
323
|
+
})
|
|
324
|
+
.join(" ")
|
|
325
|
+
.trim()
|
|
326
|
+
|
|
327
|
+
return merged
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function openRouterEndpoint(baseURL: string, endpoint: string): string {
|
|
331
|
+
return `${baseURL.replace(/\/+$/, "")}${endpoint}`
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function normalizeOpenRouterBaseURL(value: string | undefined): string {
|
|
335
|
+
if (!value) return OPENROUTER_BASE_URL
|
|
336
|
+
try {
|
|
337
|
+
const parsed = new URL(value)
|
|
338
|
+
if (!parsed.hostname.endsWith("openrouter.ai")) {
|
|
339
|
+
return OPENROUTER_BASE_URL
|
|
340
|
+
}
|
|
341
|
+
return `${parsed.origin}/api/v1`
|
|
342
|
+
} catch {
|
|
343
|
+
return OPENROUTER_BASE_URL
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function openRouterErrorDetail(response: Response): Promise<string> {
|
|
348
|
+
const text = await response.text().catch(() => "")
|
|
349
|
+
if (!text) return response.statusText
|
|
350
|
+
try {
|
|
351
|
+
const parsed = JSON.parse(text) as { error?: { message?: string }; message?: string }
|
|
352
|
+
return parsed.error?.message ?? parsed.message ?? text
|
|
353
|
+
} catch {
|
|
354
|
+
return text
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function resolveOpenRouterConfig(): Promise<{ apiKey: string; baseURL: string }> {
|
|
359
|
+
const auth = await Auth.get("openrouter").catch(() => undefined)
|
|
360
|
+
const providerOptions = (sync.data.config as any)?.provider?.openrouter?.options ?? {}
|
|
361
|
+
const optionApiKey = typeof providerOptions.apiKey === "string" ? providerOptions.apiKey : undefined
|
|
362
|
+
|
|
363
|
+
const apiKey =
|
|
364
|
+
process.env.NIKCLI_OPENROUTER_API_KEY ??
|
|
365
|
+
process.env.OPENROUTER_API_KEY ??
|
|
366
|
+
(auth?.type === "api" ? auth.key : undefined) ??
|
|
367
|
+
optionApiKey
|
|
368
|
+
|
|
369
|
+
if (!apiKey || !apiKey.trim()) {
|
|
370
|
+
throw new Error("OpenRouter API key not configured")
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const baseURL = normalizeOpenRouterBaseURL(
|
|
374
|
+
process.env.NIKCLI_OPENROUTER_BASE_URL ??
|
|
375
|
+
process.env.OPENROUTER_BASE_URL ??
|
|
376
|
+
(typeof providerOptions.baseURL === "string" ? providerOptions.baseURL : undefined),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
apiKey: apiKey.trim(),
|
|
381
|
+
baseURL,
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function transcribeVoiceAudioViaResponses(
|
|
386
|
+
base64Audio: string,
|
|
387
|
+
config: { apiKey: string; baseURL: string },
|
|
388
|
+
signal: AbortSignal,
|
|
389
|
+
): Promise<string> {
|
|
390
|
+
const response = await fetch(openRouterEndpoint(config.baseURL, "/responses"), {
|
|
391
|
+
method: "POST",
|
|
392
|
+
headers: {
|
|
393
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
394
|
+
"Content-Type": "application/json",
|
|
395
|
+
"HTTP-Referer": "https://nikcli.store/",
|
|
396
|
+
"X-Title": "nikcli",
|
|
397
|
+
},
|
|
398
|
+
body: JSON.stringify({
|
|
399
|
+
model: process.env.NIKCLI_VOICE_TRANSCRIBE_MODEL ?? VOICE_TRANSCRIBE_MODEL,
|
|
400
|
+
temperature: 0,
|
|
401
|
+
input: [
|
|
402
|
+
{
|
|
403
|
+
role: "user",
|
|
404
|
+
content: [
|
|
405
|
+
{
|
|
406
|
+
type: "input_text",
|
|
407
|
+
text: "Transcribe this audio. Return only the transcript text without extra commentary.",
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
type: "input_audio",
|
|
411
|
+
input_audio: {
|
|
412
|
+
data: base64Audio,
|
|
413
|
+
format: "wav",
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
],
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
}),
|
|
420
|
+
signal,
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
if (!response.ok) {
|
|
424
|
+
const detail = await openRouterErrorDetail(response)
|
|
425
|
+
throw new Error(`OpenRouter transcription failed (${response.status}): ${detail}`)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const result = (await response.json()) as {
|
|
429
|
+
output_text?: string
|
|
430
|
+
output?: Array<{
|
|
431
|
+
content?: Array<{
|
|
432
|
+
type?: string
|
|
433
|
+
text?: string
|
|
434
|
+
}>
|
|
435
|
+
}>
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const fromOutputText = (result.output_text ?? "").trim()
|
|
439
|
+
if (fromOutputText) return fromOutputText
|
|
440
|
+
|
|
441
|
+
const fromContent =
|
|
442
|
+
result.output
|
|
443
|
+
?.flatMap((x) => x.content ?? [])
|
|
444
|
+
.map((x) => (x.type === "output_text" && x.text ? x.text : ""))
|
|
445
|
+
.join(" ")
|
|
446
|
+
.trim() ?? ""
|
|
447
|
+
|
|
448
|
+
if (!fromContent) {
|
|
449
|
+
throw new Error("No transcript returned")
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return fromContent
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function transcribeVoiceAudio(filePath: string): Promise<string> {
|
|
456
|
+
const audio = await Bun.file(filePath).arrayBuffer()
|
|
457
|
+
if (audio.byteLength === 0) {
|
|
458
|
+
throw new Error("Recorded audio is empty")
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const config = await resolveOpenRouterConfig()
|
|
462
|
+
const base64Audio = Buffer.from(audio).toString("base64")
|
|
463
|
+
const controller = new AbortController()
|
|
464
|
+
const timeout = setTimeout(() => controller.abort(), 60_000)
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
const response = await fetch(openRouterEndpoint(config.baseURL, "/chat/completions"), {
|
|
468
|
+
method: "POST",
|
|
469
|
+
headers: {
|
|
470
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
471
|
+
"Content-Type": "application/json",
|
|
472
|
+
"HTTP-Referer": "https://nikcli.store/",
|
|
473
|
+
"X-Title": "nikcli",
|
|
474
|
+
},
|
|
475
|
+
body: JSON.stringify({
|
|
476
|
+
model: process.env.NIKCLI_VOICE_TRANSCRIBE_MODEL ?? VOICE_TRANSCRIBE_MODEL,
|
|
477
|
+
temperature: 0,
|
|
478
|
+
messages: [
|
|
479
|
+
{
|
|
480
|
+
role: "user",
|
|
481
|
+
content: [
|
|
482
|
+
{
|
|
483
|
+
type: "text",
|
|
484
|
+
text: "Transcribe this audio. Return only the transcript text without extra commentary.",
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
type: "input_audio",
|
|
488
|
+
input_audio: {
|
|
489
|
+
data: base64Audio,
|
|
490
|
+
format: "wav",
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
],
|
|
494
|
+
},
|
|
495
|
+
],
|
|
496
|
+
}),
|
|
497
|
+
signal: controller.signal,
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
if (!response.ok) {
|
|
501
|
+
if (response.status === 402) {
|
|
502
|
+
const detail = await openRouterErrorDetail(response)
|
|
503
|
+
throw new Error(`OpenRouter audio credits required: ${detail}`)
|
|
504
|
+
}
|
|
505
|
+
const detail = await openRouterErrorDetail(response)
|
|
506
|
+
throw new Error(`OpenRouter transcription failed (${response.status}): ${detail}`)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const result = (await response.json()) as {
|
|
510
|
+
choices?: Array<{
|
|
511
|
+
message?: {
|
|
512
|
+
content?: unknown
|
|
513
|
+
}
|
|
514
|
+
}>
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const content = result.choices?.[0]?.message?.content
|
|
518
|
+
const transcript = extractTranscriptContent(content)
|
|
519
|
+
if (!transcript) {
|
|
520
|
+
throw new Error("No transcript returned")
|
|
521
|
+
}
|
|
522
|
+
return transcript
|
|
523
|
+
} catch (error) {
|
|
524
|
+
const message = error instanceof Error ? error.message : ""
|
|
525
|
+
if (message.includes("credits required") || message.includes("(402)")) {
|
|
526
|
+
throw error
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return transcribeVoiceAudioViaResponses(base64Audio, config, controller.signal)
|
|
530
|
+
} finally {
|
|
531
|
+
clearTimeout(timeout)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
let isStartingRecording = false
|
|
536
|
+
async function startVoiceRecording() {
|
|
537
|
+
if (voiceStatus() !== "idle") return
|
|
538
|
+
if (isStartingRecording) return
|
|
539
|
+
isStartingRecording = true
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
const filePath = path.join(os.tmpdir(), `nikcli-voice-${Date.now()}-${Math.random().toString(16).slice(2)}.wav`)
|
|
543
|
+
const recorder = await detectVoiceRecorder(filePath)
|
|
544
|
+
|
|
545
|
+
if (!recorder) {
|
|
546
|
+
toast.show({
|
|
547
|
+
variant: "error",
|
|
548
|
+
message:
|
|
549
|
+
process.platform === "darwin"
|
|
550
|
+
? "Voice mode requires ffmpeg, sox, or macOS Command Line Tools (swift)"
|
|
551
|
+
: "Voice mode requires ffmpeg or sox (rec) installed",
|
|
552
|
+
duration: 4000,
|
|
553
|
+
})
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (!hasShownMicHint) {
|
|
558
|
+
hasShownMicHint = true
|
|
559
|
+
const message =
|
|
560
|
+
process.platform === "darwin"
|
|
561
|
+
? "If prompted, allow microphone access for your terminal"
|
|
562
|
+
: "Allow microphone access when prompted by your operating system"
|
|
563
|
+
toast.show({ variant: "info", message, duration: 3500 })
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
voiceAudioPath = filePath
|
|
568
|
+
voiceRecorder = Bun.spawn(recorder.command, {
|
|
569
|
+
stdout: "ignore",
|
|
570
|
+
stderr: "pipe",
|
|
571
|
+
})
|
|
572
|
+
voiceAutoStopTimer = setTimeout(() => {
|
|
573
|
+
void stopVoiceRecording()
|
|
574
|
+
}, 90_000)
|
|
575
|
+
setVoiceStatus("recording")
|
|
576
|
+
toast.show({
|
|
577
|
+
variant: "info",
|
|
578
|
+
message: `Recording started (${recorder.name}). Hold to talk, or click again to stop`,
|
|
579
|
+
duration: 2500,
|
|
580
|
+
})
|
|
581
|
+
} catch {
|
|
582
|
+
cleanupVoiceAudio(filePath)
|
|
583
|
+
voiceAudioPath = null
|
|
584
|
+
voiceRecorder = null
|
|
585
|
+
if (voiceAutoStopTimer) {
|
|
586
|
+
clearTimeout(voiceAutoStopTimer)
|
|
587
|
+
voiceAutoStopTimer = undefined
|
|
588
|
+
}
|
|
589
|
+
setVoiceStatus("idle")
|
|
590
|
+
toast.show({ variant: "error", message: "Failed to start voice recording", duration: 3000 })
|
|
591
|
+
}
|
|
592
|
+
} finally {
|
|
593
|
+
isStartingRecording = false
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function stopVoiceRecording() {
|
|
598
|
+
if (!voiceRecorder || !voiceAudioPath) {
|
|
599
|
+
setVoiceStatus("idle")
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const recorder = voiceRecorder
|
|
604
|
+
const filePath = voiceAudioPath
|
|
605
|
+
voiceRecorder = null
|
|
606
|
+
voiceAudioPath = null
|
|
607
|
+
if (voiceAutoStopTimer) {
|
|
608
|
+
clearTimeout(voiceAutoStopTimer)
|
|
609
|
+
voiceAutoStopTimer = undefined
|
|
610
|
+
}
|
|
611
|
+
setVoiceStatus("transcribing")
|
|
612
|
+
|
|
613
|
+
try {
|
|
614
|
+
try {
|
|
615
|
+
recorder.kill("SIGINT")
|
|
616
|
+
} catch {
|
|
617
|
+
recorder.kill()
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
await Promise.race([recorder.exited, new Promise((resolve) => setTimeout(resolve, 4000))])
|
|
621
|
+
|
|
622
|
+
const stderrText =
|
|
623
|
+
recorder.stderr && typeof recorder.stderr !== "number"
|
|
624
|
+
? await new Response(recorder.stderr).text().catch(() => "")
|
|
625
|
+
: ""
|
|
626
|
+
if (looksLikeMicPermissionError(stderrText)) {
|
|
627
|
+
const detail = stderrText
|
|
628
|
+
.split("\n")
|
|
629
|
+
.map((x) => x.trim())
|
|
630
|
+
.filter(Boolean)
|
|
631
|
+
.at(-1)
|
|
632
|
+
const terminalName = currentTerminalName()
|
|
633
|
+
const message =
|
|
634
|
+
process.platform === "darwin"
|
|
635
|
+
? isLikelyIntegratedTerminal()
|
|
636
|
+
? `Microphone denied for ${terminalName}. Allow it in System Settings > Privacy & Security > Microphone, or run nikcli in Terminal.app/iTerm2`
|
|
637
|
+
: `Microphone access denied for ${terminalName}. Enable it in System Settings > Privacy & Security > Microphone`
|
|
638
|
+
: "Microphone access denied. Allow microphone permission for your terminal"
|
|
639
|
+
toast.show({
|
|
640
|
+
variant: "error",
|
|
641
|
+
message: detail ? `${message} (${detail})` : message,
|
|
642
|
+
duration: 8000,
|
|
643
|
+
})
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const recordedBytes = await Bun.file(filePath)
|
|
648
|
+
.arrayBuffer()
|
|
649
|
+
.then((x) => x.byteLength)
|
|
650
|
+
.catch(() => 0)
|
|
651
|
+
|
|
652
|
+
if (recordedBytes < 1024) {
|
|
653
|
+
const detail = stderrText.trim().split("\n").at(-1)
|
|
654
|
+
toast.show({
|
|
655
|
+
variant: "error",
|
|
656
|
+
message: detail ? `No audio captured: ${detail}` : "No audio captured. Try holding the button longer",
|
|
657
|
+
duration: 5000,
|
|
658
|
+
})
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const transcript = await transcribeVoiceAudio(filePath)
|
|
663
|
+
const withSpacing = input.plainText.length > 0 && !input.plainText.endsWith(" ") ? ` ${transcript}` : transcript
|
|
664
|
+
const nextInput = `${input.plainText}${withSpacing}`
|
|
665
|
+
|
|
666
|
+
input.focus()
|
|
667
|
+
input.setText(nextInput)
|
|
668
|
+
setStore("prompt", "input", nextInput)
|
|
669
|
+
autocomplete.onInput(nextInput)
|
|
670
|
+
await Clipboard.copy(transcript).catch(() => {})
|
|
671
|
+
|
|
672
|
+
setTimeout(() => {
|
|
673
|
+
input.cursorOffset = nextInput.length
|
|
674
|
+
renderer.requestRender()
|
|
675
|
+
}, 0)
|
|
676
|
+
|
|
677
|
+
toast.show({ variant: "success", message: "Voice transcript inserted and copied", duration: 2000 })
|
|
678
|
+
} catch (error) {
|
|
679
|
+
const message = error instanceof Error ? error.message : "Voice transcription failed"
|
|
680
|
+
toast.show({ variant: "error", message, duration: 4000 })
|
|
681
|
+
} finally {
|
|
682
|
+
cleanupVoiceAudio(filePath)
|
|
683
|
+
setVoiceStatus("idle")
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async function handleVoiceButtonDown() {
|
|
688
|
+
if (props.disabled) return
|
|
689
|
+
if (voiceStatus() === "transcribing") return
|
|
690
|
+
if (voiceStatus() === "recording") {
|
|
691
|
+
await stopVoiceRecording()
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
voicePressStartedAt = Date.now()
|
|
695
|
+
await startVoiceRecording()
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function handleVoiceButtonUp() {
|
|
699
|
+
if (props.disabled) return
|
|
700
|
+
if (Date.now() - voicePressStartedAt < 220) return
|
|
701
|
+
if (voiceStatus() !== "recording") return
|
|
702
|
+
await stopVoiceRecording()
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
onCleanup(() => {
|
|
706
|
+
if (voiceAutoStopTimer) {
|
|
707
|
+
clearTimeout(voiceAutoStopTimer)
|
|
708
|
+
voiceAutoStopTimer = undefined
|
|
709
|
+
}
|
|
710
|
+
if (voiceRecorder) {
|
|
711
|
+
try {
|
|
712
|
+
voiceRecorder.kill("SIGINT")
|
|
713
|
+
} catch {
|
|
714
|
+
try {
|
|
715
|
+
voiceRecorder.kill()
|
|
716
|
+
} catch {
|
|
717
|
+
// ignore
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
voiceRecorder = null
|
|
721
|
+
}
|
|
722
|
+
cleanupVoiceAudio(voiceAudioPath)
|
|
723
|
+
voiceAudioPath = null
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
type BackgroundSubtasksMap = Record<string, string[]>
|
|
727
|
+
|
|
728
|
+
function getBackgroundSubtasksMap(): BackgroundSubtasksMap {
|
|
729
|
+
return (kv.get("background_subtasks", {}) ?? {}) as BackgroundSubtasksMap
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function setBackgroundSubtasksMap(next: BackgroundSubtasksMap) {
|
|
733
|
+
kv.set("background_subtasks", next)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function removeBackgroundSubtask(parentID: string, childID: string) {
|
|
737
|
+
const map = getBackgroundSubtasksMap()
|
|
738
|
+
const list = map[parentID] ?? []
|
|
739
|
+
if (!list.includes(childID)) return
|
|
740
|
+
setBackgroundSubtasksMap({ ...map, [parentID]: list.filter((x) => x !== childID) })
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const backgroundedSubtaskIDs = createMemo(() => {
|
|
744
|
+
if (!props.sessionID) return [] as string[]
|
|
745
|
+
const map = getBackgroundSubtasksMap()
|
|
746
|
+
return map[props.sessionID] ?? []
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
const backgroundedSubtaskCount = createMemo(() => backgroundedSubtaskIDs().length)
|
|
750
|
+
|
|
751
|
+
function openBackgroundSubtasks() {
|
|
752
|
+
if (!props.sessionID) return
|
|
753
|
+
dialog.replace(() => <DialogSubagent sessionID={props.sessionID!} />)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function stripSubagentSuffix(title: string): string {
|
|
757
|
+
return title.replace(/\s*\(@[^\s]+\s+subagent\)$/, "")
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Auto-resurface: when a backgrounded subtask finishes, reopen it in the foreground.
|
|
761
|
+
const previousSubtaskStatus = new Map<string, string>()
|
|
762
|
+
createEffect(() => {
|
|
763
|
+
if (!props.sessionID) return
|
|
764
|
+
|
|
765
|
+
const ids = backgroundedSubtaskIDs()
|
|
766
|
+
const live = new Set(ids)
|
|
767
|
+
for (const existing of previousSubtaskStatus.keys()) {
|
|
768
|
+
if (!live.has(existing)) previousSubtaskStatus.delete(existing)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Resurface the first task that transitioned to idle.
|
|
772
|
+
for (const id of ids) {
|
|
773
|
+
const current = sync.data.session_status?.[id]?.type ?? "idle"
|
|
774
|
+
const prev = previousSubtaskStatus.get(id)
|
|
775
|
+
previousSubtaskStatus.set(id, current)
|
|
776
|
+
if (!prev) continue
|
|
777
|
+
if (prev !== "idle" && current === "idle") {
|
|
778
|
+
const title = sync.data.session.find((s) => s.id === id)?.title
|
|
779
|
+
toast.show({
|
|
780
|
+
variant: "success",
|
|
781
|
+
message: `${stripSubagentSuffix(title ?? "Subtask")} finished`,
|
|
782
|
+
duration: 3000,
|
|
783
|
+
})
|
|
784
|
+
removeBackgroundSubtask(props.sessionID, id)
|
|
785
|
+
route.navigate({ type: "session", sessionID: id, workspaceID: sync.session.get(id)?.workspaceID })
|
|
786
|
+
break
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
const getAvailableAds = () => {
|
|
792
|
+
const adsConfig = ads()
|
|
793
|
+
const items = (adsConfig?.items ?? []).filter((item) => item.enabled !== false)
|
|
794
|
+
const enabled = adsConfig?.enabled ?? true
|
|
795
|
+
if (!enabled || items.length === 0) return []
|
|
796
|
+
return items
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const selectNextAd = () => {
|
|
800
|
+
const items = getAvailableAds()
|
|
801
|
+
if (items.length === 0) {
|
|
802
|
+
setCurrentAd(null)
|
|
803
|
+
return
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const adsConfig = ads()
|
|
807
|
+
const ratio = adsConfig?.ratio ?? 0.3
|
|
808
|
+
if (Math.random() >= ratio) {
|
|
809
|
+
setCurrentAd(null)
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const index = store.currentAdIndex % items.length
|
|
814
|
+
const item = items[index]
|
|
815
|
+
setStore("currentAdIndex", (store.currentAdIndex + 1) % items.length)
|
|
816
|
+
|
|
817
|
+
if (item.url) setCurrentAd(`${item.text} {highlight}${item.url}{/highlight}`)
|
|
818
|
+
else setCurrentAd(item.text)
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
createEffect(
|
|
822
|
+
on(
|
|
823
|
+
() => status(),
|
|
824
|
+
(currentStatus) => {
|
|
825
|
+
if (currentStatus.type === "idle") {
|
|
826
|
+
selectNextAd()
|
|
827
|
+
}
|
|
828
|
+
},
|
|
829
|
+
{ defer: true },
|
|
830
|
+
),
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
const sponsoredTip = currentAd
|
|
834
|
+
|
|
835
|
+
const parseTipParts = (tip: string) => {
|
|
836
|
+
const parts: { text: string; highlight: boolean }[] = []
|
|
837
|
+
const regex = /\{highlight\}(.*?)\{\/highlight\}/g
|
|
838
|
+
let lastIndex = 0
|
|
839
|
+
for (const match of tip.matchAll(regex)) {
|
|
840
|
+
if (match.index! > lastIndex) {
|
|
841
|
+
parts.push({ text: tip.slice(lastIndex, match.index), highlight: false })
|
|
842
|
+
}
|
|
843
|
+
parts.push({ text: match[1], highlight: true })
|
|
844
|
+
lastIndex = match.index! + match[0].length
|
|
845
|
+
}
|
|
846
|
+
if (lastIndex < tip.length) {
|
|
847
|
+
parts.push({ text: tip.slice(lastIndex), highlight: false })
|
|
848
|
+
}
|
|
849
|
+
return parts
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function promptModelWarning() {
|
|
853
|
+
toast.show({
|
|
854
|
+
variant: "warning",
|
|
855
|
+
message: "Connect a provider to send prompts",
|
|
856
|
+
duration: 3000,
|
|
857
|
+
})
|
|
858
|
+
if (sync.data.provider.length === 0) {
|
|
859
|
+
dialog.replace(() => <DialogProviderConnect />)
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const textareaKeybindings = useTextareaKeybindings()
|
|
864
|
+
|
|
865
|
+
const fileStyleId = syntax().getStyleId("extmark.file")!
|
|
866
|
+
const agentStyleId = syntax().getStyleId("extmark.agent")!
|
|
867
|
+
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
|
868
|
+
let promptPartTypeId = 0
|
|
869
|
+
|
|
870
|
+
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
|
|
871
|
+
input.insertText(evt.properties.text)
|
|
872
|
+
setTimeout(() => {
|
|
873
|
+
input.getLayoutNode().markDirty()
|
|
874
|
+
input.gotoBufferEnd()
|
|
875
|
+
renderer.requestRender()
|
|
876
|
+
}, 0)
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
createEffect(
|
|
880
|
+
on(
|
|
881
|
+
() => [props.disabled, theme.backgroundElement, theme.text] as const,
|
|
882
|
+
([disabled, bg, text]) => {
|
|
883
|
+
if (disabled) input.cursorColor = bg
|
|
884
|
+
if (!disabled) input.cursorColor = text
|
|
885
|
+
},
|
|
886
|
+
{ defer: true },
|
|
887
|
+
),
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
const lastUserMessage = createMemo(() => {
|
|
891
|
+
if (!props.sessionID) return undefined
|
|
892
|
+
const messages = sync.data.message[props.sessionID]
|
|
893
|
+
if (!messages) return undefined
|
|
894
|
+
return messages.findLast((m) => m.role === "user")
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
const [store, setStore] = createStore<{
|
|
898
|
+
prompt: PromptInfo
|
|
899
|
+
mode: "normal" | "shell"
|
|
900
|
+
extmarkToPartIndex: Map<number, number>
|
|
901
|
+
interrupt: number
|
|
902
|
+
placeholder: number
|
|
903
|
+
currentAdIndex: number
|
|
904
|
+
}>({
|
|
905
|
+
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
|
|
906
|
+
prompt: {
|
|
907
|
+
input: "",
|
|
908
|
+
parts: [],
|
|
909
|
+
},
|
|
910
|
+
mode: "normal",
|
|
911
|
+
extmarkToPartIndex: new Map(),
|
|
912
|
+
interrupt: 0,
|
|
913
|
+
currentAdIndex: 0,
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
createEffect(
|
|
917
|
+
on(
|
|
918
|
+
() => props.sessionID,
|
|
919
|
+
() => {
|
|
920
|
+
setStore("placeholder", Math.floor(Math.random() * PLACEHOLDERS.length))
|
|
921
|
+
},
|
|
922
|
+
{ defer: true },
|
|
923
|
+
),
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
// Initialize agent/model/variant from last user message when session changes
|
|
927
|
+
let syncedSessionID: string | undefined
|
|
928
|
+
createEffect(
|
|
929
|
+
on(
|
|
930
|
+
() => ({ sessionID: props.sessionID, msg: lastUserMessage() }),
|
|
931
|
+
({ sessionID, msg }) => {
|
|
932
|
+
if (sessionID !== syncedSessionID) {
|
|
933
|
+
if (!sessionID || !msg) return
|
|
934
|
+
|
|
935
|
+
syncedSessionID = sessionID
|
|
936
|
+
|
|
937
|
+
const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent)
|
|
938
|
+
if (msg.agent && isPrimaryAgent) {
|
|
939
|
+
local.agent.set(msg.agent)
|
|
940
|
+
if (msg.model) local.model.set(msg.model)
|
|
941
|
+
if (msg.variant) local.model.variant.set(msg.variant)
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
},
|
|
945
|
+
{ defer: true },
|
|
946
|
+
),
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
command.register(() => {
|
|
950
|
+
return [
|
|
951
|
+
{
|
|
952
|
+
title: "Clear prompt",
|
|
953
|
+
value: "prompt.clear",
|
|
954
|
+
category: "Prompt",
|
|
955
|
+
hidden: true,
|
|
956
|
+
onSelect: (dialog) => {
|
|
957
|
+
input.extmarks.clear()
|
|
958
|
+
input.clear()
|
|
959
|
+
dialog.clear()
|
|
960
|
+
},
|
|
961
|
+
},
|
|
962
|
+
{
|
|
963
|
+
title: "Submit prompt",
|
|
964
|
+
value: "prompt.submit",
|
|
965
|
+
keybind: "input_submit",
|
|
966
|
+
category: "Prompt",
|
|
967
|
+
hidden: true,
|
|
968
|
+
onSelect: (dialog) => {
|
|
969
|
+
if (!input.focused) return
|
|
970
|
+
submit()
|
|
971
|
+
dialog.clear()
|
|
972
|
+
},
|
|
973
|
+
},
|
|
974
|
+
{
|
|
975
|
+
title: "Paste",
|
|
976
|
+
value: "prompt.paste",
|
|
977
|
+
keybind: "input_paste",
|
|
978
|
+
category: "Prompt",
|
|
979
|
+
hidden: true,
|
|
980
|
+
onSelect: async () => {
|
|
981
|
+
const content = await Clipboard.read()
|
|
982
|
+
if (content?.mime.startsWith("image/")) {
|
|
983
|
+
await pasteImage({
|
|
984
|
+
filename: "clipboard",
|
|
985
|
+
mime: content.mime,
|
|
986
|
+
content: content.data,
|
|
987
|
+
})
|
|
988
|
+
}
|
|
989
|
+
},
|
|
990
|
+
},
|
|
991
|
+
{
|
|
992
|
+
title: "Interrupt session",
|
|
993
|
+
value: "session.interrupt",
|
|
994
|
+
keybind: "session_interrupt",
|
|
995
|
+
category: "Session",
|
|
996
|
+
hidden: true,
|
|
997
|
+
enabled: status().type !== "idle",
|
|
998
|
+
onSelect: (dialog) => {
|
|
999
|
+
if (autocomplete.visible) return
|
|
1000
|
+
if (!input.focused) return
|
|
1001
|
+
// TODO: this should be its own command
|
|
1002
|
+
if (store.mode === "shell") {
|
|
1003
|
+
setStore("mode", "normal")
|
|
1004
|
+
return
|
|
1005
|
+
}
|
|
1006
|
+
if (!props.sessionID) return
|
|
1007
|
+
|
|
1008
|
+
setStore("interrupt", store.interrupt + 1)
|
|
1009
|
+
|
|
1010
|
+
setTimeout(() => {
|
|
1011
|
+
setStore("interrupt", 0)
|
|
1012
|
+
}, 5000)
|
|
1013
|
+
|
|
1014
|
+
if (store.interrupt >= 2) {
|
|
1015
|
+
sdk.client.session.abort({
|
|
1016
|
+
sessionID: props.sessionID,
|
|
1017
|
+
})
|
|
1018
|
+
setStore("interrupt", 0)
|
|
1019
|
+
}
|
|
1020
|
+
dialog.clear()
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
{
|
|
1024
|
+
title: "Open editor",
|
|
1025
|
+
category: "Session",
|
|
1026
|
+
keybind: "editor_open",
|
|
1027
|
+
value: "prompt.editor",
|
|
1028
|
+
slash: {
|
|
1029
|
+
name: "editor",
|
|
1030
|
+
},
|
|
1031
|
+
onSelect: async (dialog) => {
|
|
1032
|
+
dialog.clear()
|
|
1033
|
+
|
|
1034
|
+
// replace summarized text parts with the actual text
|
|
1035
|
+
const text = store.prompt.parts
|
|
1036
|
+
.filter((p) => p.type === "text")
|
|
1037
|
+
.reduce((acc, p) => {
|
|
1038
|
+
if (!p.source) return acc
|
|
1039
|
+
return acc.replace(p.source.text.value, p.text)
|
|
1040
|
+
}, store.prompt.input)
|
|
1041
|
+
|
|
1042
|
+
const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
|
|
1043
|
+
|
|
1044
|
+
const value = text
|
|
1045
|
+
const content = await Editor.open({ value, renderer })
|
|
1046
|
+
if (!content) return
|
|
1047
|
+
|
|
1048
|
+
input.setText(content)
|
|
1049
|
+
|
|
1050
|
+
// Update positions for nonTextParts based on their location in new content
|
|
1051
|
+
// Filter out parts whose virtual text was deleted
|
|
1052
|
+
// this handles a case where the user edits the text in the editor
|
|
1053
|
+
// such that the virtual text moves around or is deleted
|
|
1054
|
+
const updatedNonTextParts = nonTextParts
|
|
1055
|
+
.map((part) => {
|
|
1056
|
+
let virtualText = ""
|
|
1057
|
+
if (part.type === "file" && part.source?.text) {
|
|
1058
|
+
virtualText = part.source.text.value
|
|
1059
|
+
} else if (part.type === "agent" && part.source) {
|
|
1060
|
+
virtualText = part.source.value
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (!virtualText) return part
|
|
1064
|
+
|
|
1065
|
+
const newStart = content.indexOf(virtualText)
|
|
1066
|
+
// if the virtual text is deleted, remove the part
|
|
1067
|
+
if (newStart === -1) return null
|
|
1068
|
+
|
|
1069
|
+
const newEnd = newStart + virtualText.length
|
|
1070
|
+
|
|
1071
|
+
if (part.type === "file" && part.source?.text) {
|
|
1072
|
+
return {
|
|
1073
|
+
...part,
|
|
1074
|
+
source: {
|
|
1075
|
+
...part.source,
|
|
1076
|
+
text: {
|
|
1077
|
+
...part.source.text,
|
|
1078
|
+
start: newStart,
|
|
1079
|
+
end: newEnd,
|
|
1080
|
+
},
|
|
1081
|
+
},
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (part.type === "agent" && part.source) {
|
|
1086
|
+
return {
|
|
1087
|
+
...part,
|
|
1088
|
+
source: {
|
|
1089
|
+
...part.source,
|
|
1090
|
+
start: newStart,
|
|
1091
|
+
end: newEnd,
|
|
1092
|
+
},
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
return part
|
|
1097
|
+
})
|
|
1098
|
+
.filter((part) => part !== null)
|
|
1099
|
+
|
|
1100
|
+
setStore("prompt", {
|
|
1101
|
+
input: content,
|
|
1102
|
+
// keep only the non-text parts because the text parts were
|
|
1103
|
+
// already expanded inline
|
|
1104
|
+
parts: updatedNonTextParts,
|
|
1105
|
+
})
|
|
1106
|
+
restoreExtmarksFromParts(updatedNonTextParts)
|
|
1107
|
+
input.cursorOffset = Bun.stringWidth(content)
|
|
1108
|
+
},
|
|
1109
|
+
},
|
|
1110
|
+
]
|
|
1111
|
+
})
|
|
1112
|
+
|
|
1113
|
+
const ref: PromptRef = {
|
|
1114
|
+
get focused() {
|
|
1115
|
+
return input.focused
|
|
1116
|
+
},
|
|
1117
|
+
get current() {
|
|
1118
|
+
return store.prompt
|
|
1119
|
+
},
|
|
1120
|
+
focus() {
|
|
1121
|
+
input.focus()
|
|
1122
|
+
},
|
|
1123
|
+
blur() {
|
|
1124
|
+
input.blur()
|
|
1125
|
+
},
|
|
1126
|
+
set(prompt) {
|
|
1127
|
+
input.setText(prompt.input)
|
|
1128
|
+
setStore("prompt", prompt)
|
|
1129
|
+
restoreExtmarksFromParts(prompt.parts)
|
|
1130
|
+
input.gotoBufferEnd()
|
|
1131
|
+
},
|
|
1132
|
+
reset() {
|
|
1133
|
+
input.clear()
|
|
1134
|
+
input.extmarks.clear()
|
|
1135
|
+
setStore("prompt", {
|
|
1136
|
+
input: "",
|
|
1137
|
+
parts: [],
|
|
1138
|
+
})
|
|
1139
|
+
setStore("extmarkToPartIndex", new Map())
|
|
1140
|
+
},
|
|
1141
|
+
submit() {
|
|
1142
|
+
submit()
|
|
1143
|
+
},
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
createEffect(
|
|
1147
|
+
on(
|
|
1148
|
+
() => props.visible,
|
|
1149
|
+
(visible) => {
|
|
1150
|
+
if (visible !== false) input?.focus()
|
|
1151
|
+
if (visible === false) input?.blur()
|
|
1152
|
+
},
|
|
1153
|
+
{ defer: true },
|
|
1154
|
+
),
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
|
|
1158
|
+
input.extmarks.clear()
|
|
1159
|
+
setStore("extmarkToPartIndex", new Map())
|
|
1160
|
+
|
|
1161
|
+
parts.forEach((part, partIndex) => {
|
|
1162
|
+
let start = 0
|
|
1163
|
+
let end = 0
|
|
1164
|
+
let virtualText = ""
|
|
1165
|
+
let styleId: number | undefined
|
|
1166
|
+
|
|
1167
|
+
if (part.type === "file" && part.source?.text) {
|
|
1168
|
+
start = part.source.text.start
|
|
1169
|
+
end = part.source.text.end
|
|
1170
|
+
virtualText = part.source.text.value
|
|
1171
|
+
styleId = fileStyleId
|
|
1172
|
+
} else if (part.type === "agent" && part.source) {
|
|
1173
|
+
start = part.source.start
|
|
1174
|
+
end = part.source.end
|
|
1175
|
+
virtualText = part.source.value
|
|
1176
|
+
styleId = agentStyleId
|
|
1177
|
+
} else if (part.type === "text" && part.source?.text) {
|
|
1178
|
+
start = part.source.text.start
|
|
1179
|
+
end = part.source.text.end
|
|
1180
|
+
virtualText = part.source.text.value
|
|
1181
|
+
styleId = pasteStyleId
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
if (virtualText) {
|
|
1185
|
+
const extmarkId = input.extmarks.create({
|
|
1186
|
+
start,
|
|
1187
|
+
end,
|
|
1188
|
+
virtual: true,
|
|
1189
|
+
styleId,
|
|
1190
|
+
typeId: promptPartTypeId,
|
|
1191
|
+
})
|
|
1192
|
+
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
|
|
1193
|
+
const newMap = new Map(map)
|
|
1194
|
+
newMap.set(extmarkId, partIndex)
|
|
1195
|
+
return newMap
|
|
1196
|
+
})
|
|
1197
|
+
}
|
|
1198
|
+
})
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function syncExtmarksWithPromptParts() {
|
|
1202
|
+
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
|
|
1203
|
+
setStore(
|
|
1204
|
+
produce((draft) => {
|
|
1205
|
+
const newMap = new Map<number, number>()
|
|
1206
|
+
const newParts: typeof draft.prompt.parts = []
|
|
1207
|
+
|
|
1208
|
+
for (const extmark of allExtmarks) {
|
|
1209
|
+
const partIndex = draft.extmarkToPartIndex.get(extmark.id)
|
|
1210
|
+
if (partIndex !== undefined) {
|
|
1211
|
+
const part = draft.prompt.parts[partIndex]
|
|
1212
|
+
if (part) {
|
|
1213
|
+
if (part.type === "agent" && part.source) {
|
|
1214
|
+
part.source.start = extmark.start
|
|
1215
|
+
part.source.end = extmark.end
|
|
1216
|
+
} else if (part.type === "file" && part.source?.text) {
|
|
1217
|
+
part.source.text.start = extmark.start
|
|
1218
|
+
part.source.text.end = extmark.end
|
|
1219
|
+
} else if (part.type === "text" && part.source?.text) {
|
|
1220
|
+
part.source.text.start = extmark.start
|
|
1221
|
+
part.source.text.end = extmark.end
|
|
1222
|
+
}
|
|
1223
|
+
newMap.set(extmark.id, newParts.length)
|
|
1224
|
+
newParts.push(part)
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
draft.extmarkToPartIndex = newMap
|
|
1230
|
+
draft.prompt.parts = newParts
|
|
1231
|
+
}),
|
|
1232
|
+
)
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
command.register(() => [
|
|
1236
|
+
{
|
|
1237
|
+
title: "Stash prompt",
|
|
1238
|
+
value: "prompt.stash",
|
|
1239
|
+
category: "Prompt",
|
|
1240
|
+
enabled: !!store.prompt.input,
|
|
1241
|
+
onSelect: (dialog) => {
|
|
1242
|
+
if (!store.prompt.input) return
|
|
1243
|
+
stash.push({
|
|
1244
|
+
input: store.prompt.input,
|
|
1245
|
+
parts: store.prompt.parts,
|
|
1246
|
+
})
|
|
1247
|
+
input.extmarks.clear()
|
|
1248
|
+
input.clear()
|
|
1249
|
+
setStore("prompt", { input: "", parts: [] })
|
|
1250
|
+
setStore("extmarkToPartIndex", new Map())
|
|
1251
|
+
dialog.clear()
|
|
1252
|
+
},
|
|
1253
|
+
},
|
|
1254
|
+
{
|
|
1255
|
+
title: "Stash pop",
|
|
1256
|
+
value: "prompt.stash.pop",
|
|
1257
|
+
category: "Prompt",
|
|
1258
|
+
enabled: stash.list().length > 0,
|
|
1259
|
+
onSelect: (dialog) => {
|
|
1260
|
+
const entry = stash.pop()
|
|
1261
|
+
if (entry) {
|
|
1262
|
+
input.setText(entry.input)
|
|
1263
|
+
setStore("prompt", { input: entry.input, parts: entry.parts })
|
|
1264
|
+
restoreExtmarksFromParts(entry.parts)
|
|
1265
|
+
input.gotoBufferEnd()
|
|
1266
|
+
}
|
|
1267
|
+
dialog.clear()
|
|
1268
|
+
},
|
|
1269
|
+
},
|
|
1270
|
+
{
|
|
1271
|
+
title: "Stash list",
|
|
1272
|
+
value: "prompt.stash.list",
|
|
1273
|
+
category: "Prompt",
|
|
1274
|
+
enabled: stash.list().length > 0,
|
|
1275
|
+
onSelect: (dialog) => {
|
|
1276
|
+
dialog.replace(() => (
|
|
1277
|
+
<DialogStash
|
|
1278
|
+
onSelect={(entry) => {
|
|
1279
|
+
input.setText(entry.input)
|
|
1280
|
+
setStore("prompt", { input: entry.input, parts: entry.parts })
|
|
1281
|
+
restoreExtmarksFromParts(entry.parts)
|
|
1282
|
+
input.gotoBufferEnd()
|
|
1283
|
+
}}
|
|
1284
|
+
/>
|
|
1285
|
+
))
|
|
1286
|
+
},
|
|
1287
|
+
},
|
|
1288
|
+
])
|
|
1289
|
+
|
|
1290
|
+
command.register(() => [
|
|
1291
|
+
{
|
|
1292
|
+
title: "Create Theme",
|
|
1293
|
+
value: "theme.create",
|
|
1294
|
+
category: "Theme",
|
|
1295
|
+
slash: { name: "theme-create" },
|
|
1296
|
+
onSelect: (dialog) => {
|
|
1297
|
+
dialog.replace(() => <DialogThemeCreate />)
|
|
1298
|
+
},
|
|
1299
|
+
},
|
|
1300
|
+
{
|
|
1301
|
+
title: "RAG Embedding Models",
|
|
1302
|
+
value: "rag-model",
|
|
1303
|
+
category: "Config",
|
|
1304
|
+
slash: { name: "rag-models", aliases: ["rag-model"] },
|
|
1305
|
+
onSelect: (dialog) => {
|
|
1306
|
+
dialog.replace(() => <DialogRagModel />)
|
|
1307
|
+
},
|
|
1308
|
+
},
|
|
1309
|
+
{
|
|
1310
|
+
title: "Image Models",
|
|
1311
|
+
value: "image-models",
|
|
1312
|
+
category: "Config",
|
|
1313
|
+
slash: { name: "image-models" },
|
|
1314
|
+
onSelect: (dialog) => {
|
|
1315
|
+
dialog.replace(() => <DialogImageModel />)
|
|
1316
|
+
},
|
|
1317
|
+
},
|
|
1318
|
+
{
|
|
1319
|
+
title: "TTS Voice",
|
|
1320
|
+
value: "speak-model",
|
|
1321
|
+
category: "Config",
|
|
1322
|
+
slash: { name: "speak-model" },
|
|
1323
|
+
onSelect: (dialog) => {
|
|
1324
|
+
dialog.replace(() => <DialogSpeakModel />)
|
|
1325
|
+
},
|
|
1326
|
+
},
|
|
1327
|
+
{
|
|
1328
|
+
title: "Remote Access",
|
|
1329
|
+
value: "remote",
|
|
1330
|
+
category: "Config",
|
|
1331
|
+
slash: { name: "remote" },
|
|
1332
|
+
onSelect: (dialog) => {
|
|
1333
|
+
dialog.replace(() => <DialogRemote />)
|
|
1334
|
+
},
|
|
1335
|
+
},
|
|
1336
|
+
])
|
|
1337
|
+
|
|
1338
|
+
async function submit() {
|
|
1339
|
+
if (props.disabled) return
|
|
1340
|
+
if (autocomplete?.visible) return
|
|
1341
|
+
if (!store.prompt.input) return
|
|
1342
|
+
const trimmed = store.prompt.input.trim()
|
|
1343
|
+
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
|
1344
|
+
exit()
|
|
1345
|
+
return
|
|
1346
|
+
}
|
|
1347
|
+
const selectedModel = local.model.current()
|
|
1348
|
+
if (!selectedModel) {
|
|
1349
|
+
promptModelWarning()
|
|
1350
|
+
return
|
|
1351
|
+
}
|
|
1352
|
+
const sessionID = props.sessionID
|
|
1353
|
+
? props.sessionID
|
|
1354
|
+
: await (async () => {
|
|
1355
|
+
const sessionID = await sdk.client.session.create({ workspaceID: props.workspaceID }).then((x) => x.data!.id)
|
|
1356
|
+
return sessionID
|
|
1357
|
+
})()
|
|
1358
|
+
const messageID = Identifier.ascending("message")
|
|
1359
|
+
let inputText = store.prompt.input
|
|
1360
|
+
|
|
1361
|
+
// Expand pasted text inline before submitting
|
|
1362
|
+
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
|
|
1363
|
+
const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
|
|
1364
|
+
|
|
1365
|
+
for (const extmark of sortedExtmarks) {
|
|
1366
|
+
const partIndex = store.extmarkToPartIndex.get(extmark.id)
|
|
1367
|
+
if (partIndex !== undefined) {
|
|
1368
|
+
const part = store.prompt.parts[partIndex]
|
|
1369
|
+
if (part?.type === "text" && part.text) {
|
|
1370
|
+
const before = inputText.slice(0, extmark.start)
|
|
1371
|
+
const after = inputText.slice(extmark.end)
|
|
1372
|
+
inputText = before + part.text + after
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Filter out text parts (pasted content) since they're now expanded inline
|
|
1378
|
+
const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
|
|
1379
|
+
|
|
1380
|
+
// Capture mode before it gets reset
|
|
1381
|
+
const currentMode = store.mode
|
|
1382
|
+
const variant = local.model.variant.current()
|
|
1383
|
+
|
|
1384
|
+
if (store.mode === "shell") {
|
|
1385
|
+
sdk.client.session.shell({
|
|
1386
|
+
sessionID,
|
|
1387
|
+
agent: local.agent.current().name,
|
|
1388
|
+
model: {
|
|
1389
|
+
providerID: selectedModel.providerID,
|
|
1390
|
+
modelID: selectedModel.modelID,
|
|
1391
|
+
},
|
|
1392
|
+
command: inputText,
|
|
1393
|
+
})
|
|
1394
|
+
setStore("mode", "normal")
|
|
1395
|
+
} else if (
|
|
1396
|
+
inputText.startsWith("/") &&
|
|
1397
|
+
iife(() => {
|
|
1398
|
+
const firstLine = inputText.split("\n")[0]
|
|
1399
|
+
const command = firstLine.split(" ")[0].slice(1)
|
|
1400
|
+
return sync.data.command.some((x) => x.name === command)
|
|
1401
|
+
})
|
|
1402
|
+
) {
|
|
1403
|
+
// Parse command from first line, preserve multi-line content in arguments
|
|
1404
|
+
const firstLineEnd = inputText.indexOf("\n")
|
|
1405
|
+
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
|
|
1406
|
+
const [command, ...firstLineArgs] = firstLine.split(" ")
|
|
1407
|
+
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
|
|
1408
|
+
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
|
|
1409
|
+
|
|
1410
|
+
sdk.client.session.command({
|
|
1411
|
+
sessionID,
|
|
1412
|
+
command: command.slice(1),
|
|
1413
|
+
arguments: args,
|
|
1414
|
+
agent: local.agent.current().name,
|
|
1415
|
+
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
|
|
1416
|
+
messageID,
|
|
1417
|
+
variant,
|
|
1418
|
+
parts: nonTextParts
|
|
1419
|
+
.filter((x) => x.type === "file")
|
|
1420
|
+
.map((x) => ({
|
|
1421
|
+
id: Identifier.ascending("part"),
|
|
1422
|
+
...x,
|
|
1423
|
+
})),
|
|
1424
|
+
})
|
|
1425
|
+
} else {
|
|
1426
|
+
sdk.client.session
|
|
1427
|
+
.prompt({
|
|
1428
|
+
sessionID,
|
|
1429
|
+
...selectedModel,
|
|
1430
|
+
messageID,
|
|
1431
|
+
agent: local.agent.current().name,
|
|
1432
|
+
model: selectedModel,
|
|
1433
|
+
variant,
|
|
1434
|
+
parts: [
|
|
1435
|
+
{
|
|
1436
|
+
id: Identifier.ascending("part"),
|
|
1437
|
+
type: "text",
|
|
1438
|
+
text: inputText,
|
|
1439
|
+
},
|
|
1440
|
+
...nonTextParts.map((x) => ({
|
|
1441
|
+
id: Identifier.ascending("part"),
|
|
1442
|
+
...x,
|
|
1443
|
+
})),
|
|
1444
|
+
],
|
|
1445
|
+
})
|
|
1446
|
+
.catch(() => {})
|
|
1447
|
+
}
|
|
1448
|
+
history.append({
|
|
1449
|
+
...store.prompt,
|
|
1450
|
+
mode: currentMode,
|
|
1451
|
+
})
|
|
1452
|
+
input.extmarks.clear()
|
|
1453
|
+
setStore("prompt", {
|
|
1454
|
+
input: "",
|
|
1455
|
+
parts: [],
|
|
1456
|
+
})
|
|
1457
|
+
setStore("extmarkToPartIndex", new Map())
|
|
1458
|
+
props.onSubmit?.()
|
|
1459
|
+
|
|
1460
|
+
// temporary hack to make sure the message is sent
|
|
1461
|
+
if (!props.sessionID)
|
|
1462
|
+
setTimeout(() => {
|
|
1463
|
+
route.navigate({
|
|
1464
|
+
type: "session",
|
|
1465
|
+
sessionID,
|
|
1466
|
+
workspaceID: props.workspaceID ?? sync.session.get(sessionID)?.workspaceID,
|
|
1467
|
+
})
|
|
1468
|
+
}, 50)
|
|
1469
|
+
input.clear()
|
|
1470
|
+
}
|
|
1471
|
+
const exit = useExit()
|
|
1472
|
+
|
|
1473
|
+
function pasteText(text: string, virtualText: string) {
|
|
1474
|
+
const currentOffset = input.visualCursor.offset
|
|
1475
|
+
const extmarkStart = currentOffset
|
|
1476
|
+
const extmarkEnd = extmarkStart + virtualText.length
|
|
1477
|
+
|
|
1478
|
+
input.insertText(virtualText + " ")
|
|
1479
|
+
|
|
1480
|
+
const extmarkId = input.extmarks.create({
|
|
1481
|
+
start: extmarkStart,
|
|
1482
|
+
end: extmarkEnd,
|
|
1483
|
+
virtual: true,
|
|
1484
|
+
styleId: pasteStyleId,
|
|
1485
|
+
typeId: promptPartTypeId,
|
|
1486
|
+
})
|
|
1487
|
+
|
|
1488
|
+
setStore(
|
|
1489
|
+
produce((draft) => {
|
|
1490
|
+
const partIndex = draft.prompt.parts.length
|
|
1491
|
+
draft.prompt.parts.push({
|
|
1492
|
+
type: "text" as const,
|
|
1493
|
+
text,
|
|
1494
|
+
source: {
|
|
1495
|
+
text: {
|
|
1496
|
+
start: extmarkStart,
|
|
1497
|
+
end: extmarkEnd,
|
|
1498
|
+
value: virtualText,
|
|
1499
|
+
},
|
|
1500
|
+
},
|
|
1501
|
+
})
|
|
1502
|
+
draft.extmarkToPartIndex.set(extmarkId, partIndex)
|
|
1503
|
+
}),
|
|
1504
|
+
)
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
async function pasteImage(file: { filename?: string; content: string; mime: string }) {
|
|
1508
|
+
const currentOffset = input.visualCursor.offset
|
|
1509
|
+
const extmarkStart = currentOffset
|
|
1510
|
+
const count = store.prompt.parts.filter((x) => x.type === "file").length
|
|
1511
|
+
const virtualText = `[Image ${count + 1}]`
|
|
1512
|
+
const extmarkEnd = extmarkStart + virtualText.length
|
|
1513
|
+
const textToInsert = virtualText + " "
|
|
1514
|
+
|
|
1515
|
+
input.insertText(textToInsert)
|
|
1516
|
+
|
|
1517
|
+
const extmarkId = input.extmarks.create({
|
|
1518
|
+
start: extmarkStart,
|
|
1519
|
+
end: extmarkEnd,
|
|
1520
|
+
virtual: true,
|
|
1521
|
+
styleId: pasteStyleId,
|
|
1522
|
+
typeId: promptPartTypeId,
|
|
1523
|
+
})
|
|
1524
|
+
|
|
1525
|
+
const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
|
|
1526
|
+
type: "file" as const,
|
|
1527
|
+
mime: file.mime,
|
|
1528
|
+
filename: file.filename,
|
|
1529
|
+
url: `data:${file.mime};base64,${file.content}`,
|
|
1530
|
+
source: {
|
|
1531
|
+
type: "file",
|
|
1532
|
+
path: file.filename ?? "",
|
|
1533
|
+
text: {
|
|
1534
|
+
start: extmarkStart,
|
|
1535
|
+
end: extmarkEnd,
|
|
1536
|
+
value: virtualText,
|
|
1537
|
+
},
|
|
1538
|
+
},
|
|
1539
|
+
}
|
|
1540
|
+
setStore(
|
|
1541
|
+
produce((draft) => {
|
|
1542
|
+
const partIndex = draft.prompt.parts.length
|
|
1543
|
+
draft.prompt.parts.push(part)
|
|
1544
|
+
draft.extmarkToPartIndex.set(extmarkId, partIndex)
|
|
1545
|
+
}),
|
|
1546
|
+
)
|
|
1547
|
+
return
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
const highlight = createMemo(() => {
|
|
1551
|
+
if (keybind.leader) return theme.border
|
|
1552
|
+
if (store.mode === "shell") return theme.primary
|
|
1553
|
+
return local.agent.color(local.agent.current().name)
|
|
1554
|
+
})
|
|
1555
|
+
|
|
1556
|
+
const showVariant = createMemo(() => {
|
|
1557
|
+
const variants = local.model.variant.list()
|
|
1558
|
+
if (variants.length === 0) return false
|
|
1559
|
+
const current = local.model.variant.current()
|
|
1560
|
+
return !!current
|
|
1561
|
+
})
|
|
1562
|
+
|
|
1563
|
+
const spinnerDef = createMemo(() => {
|
|
1564
|
+
const style = kv.get("settings.spinner.style", "knight_rider_blocks") as SpinnerStyle
|
|
1565
|
+
const enabled = kv.get("settings.spinner.enabled", true)
|
|
1566
|
+
|
|
1567
|
+
if (!enabled) {
|
|
1568
|
+
return null
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
const color = local.agent.color(local.agent.current().name)
|
|
1572
|
+
|
|
1573
|
+
const brailleFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
1574
|
+
const dotsFrames = ["·", "⠂", "⠄", "⠆", "⠖", "⠗", "⠞", "⠟", "⠿", "⠛"]
|
|
1575
|
+
const lineFrames = ["│", "⠐", "⠔", "⠤", "⠄", "⠦"]
|
|
1576
|
+
const bouncingFrames = ["①", "②", "③", "④", "⑤", "⑥", "⑦", "⑧"]
|
|
1577
|
+
const pulseFrames = ["▖", "▗", "▘", "▙", "▚", "▛", "▜", "▝", "▞", "▟"]
|
|
1578
|
+
|
|
1579
|
+
if (style === "knight_rider_blocks") {
|
|
1580
|
+
return {
|
|
1581
|
+
frames: createFrames({
|
|
1582
|
+
color,
|
|
1583
|
+
style: "blocks",
|
|
1584
|
+
inactiveFactor: 0.6,
|
|
1585
|
+
minAlpha: 0.3,
|
|
1586
|
+
}),
|
|
1587
|
+
color: createColors({
|
|
1588
|
+
color,
|
|
1589
|
+
style: "blocks",
|
|
1590
|
+
inactiveFactor: 0.6,
|
|
1591
|
+
minAlpha: 0.3,
|
|
1592
|
+
}),
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
if (style === "knight_rider_diamonds") {
|
|
1597
|
+
return {
|
|
1598
|
+
frames: createFrames({
|
|
1599
|
+
color,
|
|
1600
|
+
style: "diamonds",
|
|
1601
|
+
inactiveFactor: 0.6,
|
|
1602
|
+
minAlpha: 0.3,
|
|
1603
|
+
}),
|
|
1604
|
+
color: createColors({
|
|
1605
|
+
color,
|
|
1606
|
+
style: "diamonds",
|
|
1607
|
+
inactiveFactor: 0.6,
|
|
1608
|
+
minAlpha: 0.3,
|
|
1609
|
+
}),
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
if (style === "braille") {
|
|
1614
|
+
return {
|
|
1615
|
+
frames: brailleFrames,
|
|
1616
|
+
color: undefined,
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
if (style === "dots") {
|
|
1621
|
+
return {
|
|
1622
|
+
frames: dotsFrames,
|
|
1623
|
+
color: undefined,
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
if (style === "line") {
|
|
1628
|
+
return {
|
|
1629
|
+
frames: lineFrames,
|
|
1630
|
+
color: undefined,
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
if (style === "bouncing") {
|
|
1635
|
+
return {
|
|
1636
|
+
frames: bouncingFrames,
|
|
1637
|
+
color: undefined,
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
if (style === "pulse") {
|
|
1642
|
+
return {
|
|
1643
|
+
frames: pulseFrames,
|
|
1644
|
+
color: undefined,
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// Default fallback
|
|
1649
|
+
return {
|
|
1650
|
+
frames: createFrames({
|
|
1651
|
+
color,
|
|
1652
|
+
style: "blocks",
|
|
1653
|
+
inactiveFactor: 0.6,
|
|
1654
|
+
minAlpha: 0.3,
|
|
1655
|
+
}),
|
|
1656
|
+
color: createColors({
|
|
1657
|
+
color,
|
|
1658
|
+
style: "blocks",
|
|
1659
|
+
inactiveFactor: 0.6,
|
|
1660
|
+
minAlpha: 0.3,
|
|
1661
|
+
}),
|
|
1662
|
+
}
|
|
1663
|
+
})
|
|
1664
|
+
|
|
1665
|
+
const placeholderText = createMemo(() => {
|
|
1666
|
+
if (props.sessionID) return undefined
|
|
1667
|
+
if (store.mode === "shell") {
|
|
1668
|
+
const example = SHELL_PLACEHOLDERS[store.placeholder % SHELL_PLACEHOLDERS.length]
|
|
1669
|
+
return `Run a command... "${example}"`
|
|
1670
|
+
}
|
|
1671
|
+
return `Ask anything... "${PLACEHOLDERS[store.placeholder % PLACEHOLDERS.length]}"`
|
|
1672
|
+
})
|
|
1673
|
+
|
|
1674
|
+
return (
|
|
1675
|
+
<>
|
|
1676
|
+
<Autocomplete
|
|
1677
|
+
sessionID={props.sessionID}
|
|
1678
|
+
ref={(r) => (autocomplete = r)}
|
|
1679
|
+
anchor={() => anchor}
|
|
1680
|
+
input={() => input}
|
|
1681
|
+
setPrompt={(cb) => {
|
|
1682
|
+
setStore("prompt", produce(cb))
|
|
1683
|
+
}}
|
|
1684
|
+
setExtmark={(partIndex, extmarkId) => {
|
|
1685
|
+
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
|
|
1686
|
+
const newMap = new Map(map)
|
|
1687
|
+
newMap.set(extmarkId, partIndex)
|
|
1688
|
+
return newMap
|
|
1689
|
+
})
|
|
1690
|
+
}}
|
|
1691
|
+
value={store.prompt.input}
|
|
1692
|
+
fileStyleId={fileStyleId}
|
|
1693
|
+
agentStyleId={agentStyleId}
|
|
1694
|
+
promptPartTypeId={() => promptPartTypeId}
|
|
1695
|
+
/>
|
|
1696
|
+
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
|
|
1697
|
+
<box
|
|
1698
|
+
border={["left"]}
|
|
1699
|
+
borderColor={highlight()}
|
|
1700
|
+
customBorderChars={{
|
|
1701
|
+
...EmptyBorder,
|
|
1702
|
+
vertical: "┃",
|
|
1703
|
+
bottomLeft: "╹",
|
|
1704
|
+
}}
|
|
1705
|
+
>
|
|
1706
|
+
<box
|
|
1707
|
+
paddingLeft={2}
|
|
1708
|
+
paddingRight={2}
|
|
1709
|
+
paddingTop={1}
|
|
1710
|
+
flexShrink={0}
|
|
1711
|
+
backgroundColor={theme.backgroundElement}
|
|
1712
|
+
flexGrow={1}
|
|
1713
|
+
>
|
|
1714
|
+
<textarea
|
|
1715
|
+
placeholder={placeholderText()}
|
|
1716
|
+
textColor={keybind.leader ? theme.textMuted : theme.text}
|
|
1717
|
+
focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
|
|
1718
|
+
minHeight={1}
|
|
1719
|
+
maxHeight={6}
|
|
1720
|
+
onContentChange={() => {
|
|
1721
|
+
const value = input.plainText
|
|
1722
|
+
setStore("prompt", "input", value)
|
|
1723
|
+
autocomplete.onInput(value)
|
|
1724
|
+
syncExtmarksWithPromptParts()
|
|
1725
|
+
}}
|
|
1726
|
+
keyBindings={textareaKeybindings()}
|
|
1727
|
+
onKeyDown={async (e) => {
|
|
1728
|
+
if (props.disabled) {
|
|
1729
|
+
e.preventDefault()
|
|
1730
|
+
return
|
|
1731
|
+
}
|
|
1732
|
+
// Handle clipboard paste (Ctrl+V) - check for images first on Windows
|
|
1733
|
+
// This is needed because Windows terminal doesn't properly send image data
|
|
1734
|
+
// through bracketed paste, so we need to intercept the keypress and
|
|
1735
|
+
// directly read from clipboard before the terminal handles it
|
|
1736
|
+
if (keybind.match("input_paste", e)) {
|
|
1737
|
+
const content = await Clipboard.read()
|
|
1738
|
+
if (content?.mime.startsWith("image/")) {
|
|
1739
|
+
e.preventDefault()
|
|
1740
|
+
await pasteImage({
|
|
1741
|
+
filename: "clipboard",
|
|
1742
|
+
mime: content.mime,
|
|
1743
|
+
content: content.data,
|
|
1744
|
+
})
|
|
1745
|
+
return
|
|
1746
|
+
}
|
|
1747
|
+
// If no image, let the default paste behavior continue
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
|
|
1751
|
+
input.clear()
|
|
1752
|
+
input.extmarks.clear()
|
|
1753
|
+
setStore("prompt", {
|
|
1754
|
+
input: "",
|
|
1755
|
+
parts: [],
|
|
1756
|
+
})
|
|
1757
|
+
setStore("extmarkToPartIndex", new Map())
|
|
1758
|
+
return
|
|
1759
|
+
}
|
|
1760
|
+
if (keybind.match("app_exit", e)) {
|
|
1761
|
+
if (store.prompt.input === "") {
|
|
1762
|
+
await exit()
|
|
1763
|
+
// Don't preventDefault - let textarea potentially handle the event
|
|
1764
|
+
e.preventDefault()
|
|
1765
|
+
return
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// Background subtasks picker (Down arrow when prompt is empty)
|
|
1770
|
+
if (
|
|
1771
|
+
!autocomplete.visible &&
|
|
1772
|
+
store.mode === "normal" &&
|
|
1773
|
+
props.sessionID &&
|
|
1774
|
+
store.prompt.input === "" &&
|
|
1775
|
+
backgroundedSubtaskCount() > 0 &&
|
|
1776
|
+
keybind.match("subtask_picker", e)
|
|
1777
|
+
) {
|
|
1778
|
+
e.preventDefault()
|
|
1779
|
+
openBackgroundSubtasks()
|
|
1780
|
+
return
|
|
1781
|
+
}
|
|
1782
|
+
if (e.name === "!" && input.visualCursor.offset === 0) {
|
|
1783
|
+
setStore("placeholder", Math.floor(Math.random() * SHELL_PLACEHOLDERS.length))
|
|
1784
|
+
setStore("mode", "shell")
|
|
1785
|
+
e.preventDefault()
|
|
1786
|
+
return
|
|
1787
|
+
}
|
|
1788
|
+
if (store.mode === "shell") {
|
|
1789
|
+
if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
|
|
1790
|
+
setStore("mode", "normal")
|
|
1791
|
+
e.preventDefault()
|
|
1792
|
+
return
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
if (store.mode === "normal") autocomplete.onKeyDown(e)
|
|
1796
|
+
if (!autocomplete.visible) {
|
|
1797
|
+
if (
|
|
1798
|
+
(keybind.match("history_previous", e) && input.cursorOffset === 0) ||
|
|
1799
|
+
(keybind.match("history_next", e) && input.cursorOffset === input.plainText.length)
|
|
1800
|
+
) {
|
|
1801
|
+
const direction = keybind.match("history_previous", e) ? -1 : 1
|
|
1802
|
+
const item = history.move(direction, input.plainText)
|
|
1803
|
+
|
|
1804
|
+
if (item) {
|
|
1805
|
+
input.setText(item.input)
|
|
1806
|
+
setStore("prompt", item)
|
|
1807
|
+
setStore("mode", item.mode ?? "normal")
|
|
1808
|
+
restoreExtmarksFromParts(item.parts)
|
|
1809
|
+
e.preventDefault()
|
|
1810
|
+
if (direction === -1) input.cursorOffset = 0
|
|
1811
|
+
if (direction === 1) input.cursorOffset = input.plainText.length
|
|
1812
|
+
}
|
|
1813
|
+
return
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0
|
|
1817
|
+
if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
|
|
1818
|
+
input.cursorOffset = input.plainText.length
|
|
1819
|
+
|
|
1820
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1821
|
+
if (keybind.match("voice_record" as any, e)) {
|
|
1822
|
+
e.preventDefault()
|
|
1823
|
+
void handleVoiceButtonDown()
|
|
1824
|
+
return
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
}}
|
|
1828
|
+
onSubmit={submit}
|
|
1829
|
+
onPaste={async (event: PasteEvent) => {
|
|
1830
|
+
if (props.disabled) {
|
|
1831
|
+
event.preventDefault()
|
|
1832
|
+
return
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
const text = new TextDecoder().decode(event.bytes)
|
|
1836
|
+
|
|
1837
|
+
// Normalize line endings at the boundary
|
|
1838
|
+
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
|
|
1839
|
+
// Replace CRLF first, then any remaining CR
|
|
1840
|
+
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
|
1841
|
+
const pastedContent = normalizedText.trim()
|
|
1842
|
+
if (!pastedContent) {
|
|
1843
|
+
command.trigger("prompt.paste")
|
|
1844
|
+
return
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
// trim ' from the beginning and end of the pasted content. just
|
|
1848
|
+
// ' and nothing else
|
|
1849
|
+
const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
|
|
1850
|
+
const isUrl = /^(https?):\/\//.test(filepath)
|
|
1851
|
+
if (!isUrl) {
|
|
1852
|
+
try {
|
|
1853
|
+
const file = Bun.file(filepath)
|
|
1854
|
+
// Handle SVG as raw text content, not as base64 image
|
|
1855
|
+
if (file.type === "image/svg+xml") {
|
|
1856
|
+
event.preventDefault()
|
|
1857
|
+
const content = await file.text().catch(() => {})
|
|
1858
|
+
if (content) {
|
|
1859
|
+
pasteText(content, `[SVG: ${file.name ?? "image"}]`)
|
|
1860
|
+
return
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
if (file.type.startsWith("image/")) {
|
|
1864
|
+
event.preventDefault()
|
|
1865
|
+
const content = await file
|
|
1866
|
+
.arrayBuffer()
|
|
1867
|
+
.then((buffer) => Buffer.from(buffer).toString("base64"))
|
|
1868
|
+
.catch(() => {})
|
|
1869
|
+
if (content) {
|
|
1870
|
+
await pasteImage({
|
|
1871
|
+
filename: file.name,
|
|
1872
|
+
mime: file.type,
|
|
1873
|
+
content,
|
|
1874
|
+
})
|
|
1875
|
+
return
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
} catch {}
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
|
1882
|
+
if (
|
|
1883
|
+
(lineCount >= 3 || pastedContent.length > 150) &&
|
|
1884
|
+
!sync.data.config.experimental?.disable_paste_summary
|
|
1885
|
+
) {
|
|
1886
|
+
event.preventDefault()
|
|
1887
|
+
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
|
1888
|
+
return
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// Force layout update and render for the pasted content
|
|
1892
|
+
setTimeout(() => {
|
|
1893
|
+
input.getLayoutNode().markDirty()
|
|
1894
|
+
renderer.requestRender()
|
|
1895
|
+
}, 0)
|
|
1896
|
+
}}
|
|
1897
|
+
ref={(r: TextareaRenderable) => {
|
|
1898
|
+
input = r
|
|
1899
|
+
if (promptPartTypeId === 0) {
|
|
1900
|
+
promptPartTypeId = input.extmarks.registerType("prompt-part")
|
|
1901
|
+
}
|
|
1902
|
+
props.ref?.(ref)
|
|
1903
|
+
setTimeout(() => {
|
|
1904
|
+
input.cursorColor = theme.text
|
|
1905
|
+
}, 0)
|
|
1906
|
+
}}
|
|
1907
|
+
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
|
1908
|
+
focusedBackgroundColor={theme.backgroundElement}
|
|
1909
|
+
cursorColor={theme.text}
|
|
1910
|
+
syntaxStyle={syntax()}
|
|
1911
|
+
/>
|
|
1912
|
+
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
|
|
1913
|
+
<Show when={kv.get("show_agent", true)}>
|
|
1914
|
+
<text fg={highlight()}>
|
|
1915
|
+
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
|
1916
|
+
</text>
|
|
1917
|
+
</Show>
|
|
1918
|
+
<Show when={store.mode === "normal" && kv.get("show_model", true)}>
|
|
1919
|
+
<box flexDirection="row" gap={1}>
|
|
1920
|
+
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
|
1921
|
+
{local.model.parsed().model}
|
|
1922
|
+
</text>
|
|
1923
|
+
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
|
1924
|
+
<Show when={showVariant()}>
|
|
1925
|
+
<text fg={theme.textMuted}>·</text>
|
|
1926
|
+
<text>
|
|
1927
|
+
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
|
1928
|
+
</text>
|
|
1929
|
+
</Show>
|
|
1930
|
+
</box>
|
|
1931
|
+
</Show>
|
|
1932
|
+
</box>
|
|
1933
|
+
</box>
|
|
1934
|
+
</box>
|
|
1935
|
+
<box
|
|
1936
|
+
height={1}
|
|
1937
|
+
border={["left"]}
|
|
1938
|
+
borderColor={highlight()}
|
|
1939
|
+
customBorderChars={{
|
|
1940
|
+
...EmptyBorder,
|
|
1941
|
+
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
|
|
1942
|
+
}}
|
|
1943
|
+
>
|
|
1944
|
+
<box
|
|
1945
|
+
height={1}
|
|
1946
|
+
border={["bottom"]}
|
|
1947
|
+
borderColor={theme.backgroundElement}
|
|
1948
|
+
customBorderChars={
|
|
1949
|
+
theme.backgroundElement.a !== 0
|
|
1950
|
+
? {
|
|
1951
|
+
...EmptyBorder,
|
|
1952
|
+
horizontal: "▀",
|
|
1953
|
+
}
|
|
1954
|
+
: {
|
|
1955
|
+
...EmptyBorder,
|
|
1956
|
+
horizontal: " ",
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
/>
|
|
1960
|
+
</box>
|
|
1961
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
1962
|
+
<Show
|
|
1963
|
+
when={status().type !== "idle"}
|
|
1964
|
+
fallback={
|
|
1965
|
+
<box flexDirection="row" gap={2} flexGrow={1}>
|
|
1966
|
+
<Show when={props.sessionID && backgroundedSubtaskCount() > 0}>
|
|
1967
|
+
<box
|
|
1968
|
+
onMouseUp={() => openBackgroundSubtasks()}
|
|
1969
|
+
backgroundColor={theme.primary}
|
|
1970
|
+
paddingLeft={1}
|
|
1971
|
+
paddingRight={1}
|
|
1972
|
+
flexShrink={0}
|
|
1973
|
+
>
|
|
1974
|
+
<text fg={theme.background}>
|
|
1975
|
+
<span style={{ bold: true }}>{backgroundedSubtaskCount()}</span> subtasks
|
|
1976
|
+
</text>
|
|
1977
|
+
</box>
|
|
1978
|
+
</Show>
|
|
1979
|
+
<text fg={theme.text}>
|
|
1980
|
+
esc <span style={{ fg: theme.textMuted }}>interrupt</span>
|
|
1981
|
+
</text>
|
|
1982
|
+
<Show when={sponsoredTip() && kv.get("show_sponsored", true)}>
|
|
1983
|
+
<text fg={theme.warning}>·</text>
|
|
1984
|
+
<text fg={theme.textMuted}>Sponsored:</text>
|
|
1985
|
+
<text fg={theme.text}>
|
|
1986
|
+
<For each={parseTipParts(sponsoredTip()!)}>
|
|
1987
|
+
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
|
|
1988
|
+
</For>
|
|
1989
|
+
</text>
|
|
1990
|
+
</Show>
|
|
1991
|
+
</box>
|
|
1992
|
+
}
|
|
1993
|
+
>
|
|
1994
|
+
<box
|
|
1995
|
+
flexDirection="row"
|
|
1996
|
+
gap={1}
|
|
1997
|
+
flexGrow={1}
|
|
1998
|
+
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
|
|
1999
|
+
>
|
|
2000
|
+
<box flexShrink={0} flexDirection="row" gap={1}>
|
|
2001
|
+
<box marginLeft={1}>
|
|
2002
|
+
<Show
|
|
2003
|
+
when={kv.get("animations_enabled", true) && spinnerDef()}
|
|
2004
|
+
fallback={<text fg={theme.textMuted}>[⋯]</text>}
|
|
2005
|
+
>
|
|
2006
|
+
<spinner color={spinnerDef()!.color} frames={spinnerDef()!.frames} interval={40} />
|
|
2007
|
+
</Show>
|
|
2008
|
+
</box>
|
|
2009
|
+
<box flexDirection="row" gap={1} flexShrink={0}>
|
|
2010
|
+
{(() => {
|
|
2011
|
+
const retry = createMemo(() => {
|
|
2012
|
+
const s = status()
|
|
2013
|
+
if (s.type !== "retry") return
|
|
2014
|
+
return s
|
|
2015
|
+
})
|
|
2016
|
+
const message = createMemo(() => {
|
|
2017
|
+
const r = retry()
|
|
2018
|
+
if (!r) return
|
|
2019
|
+
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
|
|
2020
|
+
return "gemini is way too hot right now"
|
|
2021
|
+
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
|
|
2022
|
+
return r.message
|
|
2023
|
+
})
|
|
2024
|
+
const isTruncated = createMemo(() => {
|
|
2025
|
+
const r = retry()
|
|
2026
|
+
if (!r) return false
|
|
2027
|
+
return r.message.length > 120
|
|
2028
|
+
})
|
|
2029
|
+
const [seconds, setSeconds] = createSignal(0)
|
|
2030
|
+
onMount(() => {
|
|
2031
|
+
const timer = setInterval(() => {
|
|
2032
|
+
const next = retry()?.next
|
|
2033
|
+
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
|
|
2034
|
+
}, 1000)
|
|
2035
|
+
|
|
2036
|
+
onCleanup(() => {
|
|
2037
|
+
clearInterval(timer)
|
|
2038
|
+
})
|
|
2039
|
+
})
|
|
2040
|
+
const handleMessageClick = () => {
|
|
2041
|
+
const r = retry()
|
|
2042
|
+
if (!r) return
|
|
2043
|
+
if (isTruncated()) {
|
|
2044
|
+
DialogAlert.show(dialog, "Retry Error", r.message)
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
const retryText = () => {
|
|
2049
|
+
const r = retry()
|
|
2050
|
+
if (!r) return ""
|
|
2051
|
+
const baseMessage = message()
|
|
2052
|
+
const truncatedHint = isTruncated() ? " (click to expand)" : ""
|
|
2053
|
+
const duration = formatDuration(seconds())
|
|
2054
|
+
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
|
|
2055
|
+
return baseMessage + truncatedHint + retryInfo
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
return (
|
|
2059
|
+
<Show when={retry()}>
|
|
2060
|
+
<box onMouseUp={handleMessageClick}>
|
|
2061
|
+
<text fg={theme.error}>{retryText()}</text>
|
|
2062
|
+
</box>
|
|
2063
|
+
</Show>
|
|
2064
|
+
)
|
|
2065
|
+
})()}
|
|
2066
|
+
</box>
|
|
2067
|
+
</box>
|
|
2068
|
+
<box flexDirection="row" gap={2} flexGrow={1}>
|
|
2069
|
+
<Show when={props.sessionID && backgroundedSubtaskCount() > 0}>
|
|
2070
|
+
<box
|
|
2071
|
+
onMouseUp={() => openBackgroundSubtasks()}
|
|
2072
|
+
backgroundColor={theme.primary}
|
|
2073
|
+
paddingLeft={1}
|
|
2074
|
+
paddingRight={1}
|
|
2075
|
+
flexShrink={0}
|
|
2076
|
+
>
|
|
2077
|
+
<text fg={theme.background}>
|
|
2078
|
+
<span style={{ bold: true }}>{backgroundedSubtaskCount()}</span> subtasks
|
|
2079
|
+
</text>
|
|
2080
|
+
</box>
|
|
2081
|
+
</Show>
|
|
2082
|
+
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
|
2083
|
+
esc{" "}
|
|
2084
|
+
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
|
|
2085
|
+
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
|
2086
|
+
</span>
|
|
2087
|
+
</text>
|
|
2088
|
+
<Show when={sponsoredTip() && kv.get("show_sponsored", true)}>
|
|
2089
|
+
<text fg={theme.warning}>·</text>
|
|
2090
|
+
<text fg={theme.textMuted}>Sponsored:</text>
|
|
2091
|
+
<text fg={theme.text}>
|
|
2092
|
+
<For each={parseTipParts(sponsoredTip()!)}>
|
|
2093
|
+
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
|
|
2094
|
+
</For>
|
|
2095
|
+
</text>
|
|
2096
|
+
</Show>
|
|
2097
|
+
</box>
|
|
2098
|
+
</box>
|
|
2099
|
+
</Show>
|
|
2100
|
+
<Show when={status().type !== "retry"}>
|
|
2101
|
+
<box gap={2} flexDirection="row">
|
|
2102
|
+
<box
|
|
2103
|
+
onMouseDown={() => {
|
|
2104
|
+
void handleVoiceButtonDown()
|
|
2105
|
+
}}
|
|
2106
|
+
onMouseUp={() => {
|
|
2107
|
+
void handleVoiceButtonUp()
|
|
2108
|
+
}}
|
|
2109
|
+
backgroundColor={theme.error}
|
|
2110
|
+
paddingLeft={1}
|
|
2111
|
+
paddingRight={1}
|
|
2112
|
+
flexShrink={0}
|
|
2113
|
+
>
|
|
2114
|
+
<text fg={theme.background}>
|
|
2115
|
+
<span style={{ bold: voiceStatus() === "recording" }}>
|
|
2116
|
+
{voiceStatus() === "recording"
|
|
2117
|
+
? "release to send"
|
|
2118
|
+
: voiceStatus() === "transcribing"
|
|
2119
|
+
? "transcribing..."
|
|
2120
|
+
: (() => {
|
|
2121
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2122
|
+
const shortcut = keybind.print("voice_record" as any)
|
|
2123
|
+
return shortcut ? (
|
|
2124
|
+
<>
|
|
2125
|
+
⏺ <span style={{ fg: theme.textMuted }}>rec</span>
|
|
2126
|
+
</>
|
|
2127
|
+
) : (
|
|
2128
|
+
"⏺"
|
|
2129
|
+
)
|
|
2130
|
+
})()}
|
|
2131
|
+
</span>
|
|
2132
|
+
</text>
|
|
2133
|
+
</box>
|
|
2134
|
+
|
|
2135
|
+
<Show when={kv.get("show_shortcuts", true)}>
|
|
2136
|
+
<box gap={2} flexDirection="row">
|
|
2137
|
+
<Switch>
|
|
2138
|
+
<Match when={store.mode === "normal"}>
|
|
2139
|
+
<Show when={local.model.variant.list().length > 0}>
|
|
2140
|
+
<text fg={theme.text}>
|
|
2141
|
+
{keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
|
|
2142
|
+
</text>
|
|
2143
|
+
</Show>
|
|
2144
|
+
<text fg={theme.text}>
|
|
2145
|
+
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
|
|
2146
|
+
</text>
|
|
2147
|
+
<text fg={theme.text}>
|
|
2148
|
+
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
|
|
2149
|
+
</text>
|
|
2150
|
+
</Match>
|
|
2151
|
+
<Match when={store.mode === "shell"}>
|
|
2152
|
+
<text fg={theme.text}>
|
|
2153
|
+
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
|
|
2154
|
+
</text>
|
|
2155
|
+
</Match>
|
|
2156
|
+
</Switch>
|
|
2157
|
+
</box>
|
|
2158
|
+
</Show>
|
|
2159
|
+
</box>
|
|
2160
|
+
</Show>
|
|
2161
|
+
</box>
|
|
2162
|
+
</box>
|
|
2163
|
+
</>
|
|
2164
|
+
)
|
|
2165
|
+
}
|