Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ apps/
packages/
db/ # Prisma database client
auth/ # Better-Auth authentication
secrets/ # Infisical secrets wrapper (@super/secrets)
ui/ # Shared UI components (empty)
sdk/ # SDK package (empty)
config/ # Shared config (empty)
Expand All @@ -109,6 +110,8 @@ packages/
- Web app: `apps/web/.env` or `apps/.env.local`
- Required for auth: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `NEXT_PUBLIC_BETTER_AUTH_URL`
- Required for DB: `DATABASE_URL`
- Voice/STT: `ELEVENLABS_API_KEY` (default provider), `STT_PROVIDER` (`elevenlabs`|`groq`), `ELEVENLABS_MODEL`, `STT_LANGUAGE`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add the Groq key to the Voice/STT env list.

STT_PROVIDER=groq is supported, and speech.ts requires GROQ_API_KEY on that path, so leaving it out of the env guidance makes the fallback provider look unsupported.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@AGENTS.md` at line 113, The Voice/STT environment guidance is missing the
Groq credential even though speech.ts supports the groq path via STT_PROVIDER
and requires GROQ_API_KEY. Update the env list in AGENTS.md alongside
ELEVENLABS_API_KEY, STT_PROVIDER, ELEVENLABS_MODEL, and STT_LANGUAGE to include
GROQ_API_KEY so the fallback provider is clearly documented.

- Secrets: `INFISICAL_CLIENT_ID`, `INFISICAL_CLIENT_SECRET` (when Infisical is configured)

### Linting
- ESLint configured for web app only (`apps/web/eslint.config.mjs`)
Expand Down
14 changes: 14 additions & 0 deletions apps/supercode-cli/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ The AI has access to file reading, searching, web fetching, and code execution t
| `MINIMAX_API_KEY` | MiniMax API key | — |
| `NVIDIA_API_KEY` | NVIDIA NIM API key | — |

### Voice Input

Voice capture requires `ffmpeg` and an STT provider API key.

| Env Var | Description | Default |
|---|---|---|
| `STT_PROVIDER` | STT provider (`elevenlabs` or `groq`) | `elevenlabs` |
| `ELEVENLABS_API_KEY` | ElevenLabs API key (required for ElevenLabs STT) | — |
| `ELEVENLABS_MODEL` | ElevenLabs model ID | `scribe_v1` |
| `GROQ_API_KEY` | Groq API key (required when `STT_PROVIDER=groq`) | — |
| `STT_LANGUAGE` | Transcription language | `en` |

Press **Ctrl+Shift+V** during a chat session to start voice capture.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Match the documented shortcut to the CLI binding.

chat.ts now uses Ctrl+V as the primary trigger, with F2 as fallback, so documenting Ctrl+Shift+V points users at the wrong key combo.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/README.md` at line 57, The README shortcut
documentation is out of sync with the actual chat key binding in chat.ts. Update
the voice capture shortcut text to match the CLI binding used by the chat
trigger logic, which uses Ctrl+V as the primary shortcut with F2 as the
fallback, so users are instructed to press the correct keys.


## License

MIT
2 changes: 1 addition & 1 deletion apps/supercode-cli/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "supercode-cli",
"version": "0.1.38",
"version": "0.1.39",
"description": "AI-powered coding agent CLI",
"main": "dist/main.js",
"bin": {
Expand Down
39 changes: 22 additions & 17 deletions apps/supercode-cli/server/src/cli/ai/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import { saveCliConfig } from "src/lib/cli-config"
import {
voiceCaptureFlow,
canVoiceCapture,
abortCapture,
stopCapture,
} from "src/voice/speech.ts"


Expand Down Expand Up @@ -809,7 +809,7 @@ function stdinKeypress(_str: string, key: any) {

// Enter/Escape during voice capture stops recording, doesn't submit
if (voiceCaptureActive && (key.name === "return" || key.name === "enter" || key.name === "escape")) {
abortCapture()
stopCapture()
return
}

Expand Down Expand Up @@ -1008,26 +1008,26 @@ function stdinKeypress(_str: string, key: any) {
return
}

// Voice capture — F2 is the primary trigger (most reliable across terminals).
// Ctrl+Shift+V is also accepted but some terminals intercept it as a paste
// shortcut, and Node.js readline treats Ctrl+V as a "literal next" key
// which prevents the Shift+V combination from being detected reliably.
// Voice capture — Ctrl+V is the primary trigger (bottom-left corner of most
// keyboards, reliably detected in any terminal). F2 is also accepted as a
// fallback. Note: Shift is intentionally omitted — terminals fold Shift into
// Ctrl+letter combos, so Ctrl+Shift+V sends the same byte as Ctrl+V.
const isVoiceKey =
key.name === "F2" ||
(key.ctrl && key.shift && (key.name === "v" || key.name === "V"))
(key.ctrl && (key.name === "v" || key.name === "V")) ||
key.name === "f2"
if (isVoiceKey) {
if (!voiceCaptureActive) {
voiceCaptureActive = true
activeFooter?.setStatusMessage("🎤 Recording... (Enter to stop)")
startVoiceCapture().finally(() => {
voiceCaptureActive = false
activeFooter?.setStatusMessage("")
})
} else {
stopCapture()
}
return
}

if (_str && _str.length === 1 && !key.ctrl && !key.meta) {
activeFooter?.setStatusMessage("")
stdinInput = stdinInput.slice(0, stdinCursor) + _str + stdinInput.slice(stdinCursor)
stdinCursor++
slashSelected = -1
Expand All @@ -1040,23 +1040,28 @@ function stdinKeypress(_str: string, key: any) {
async function startVoiceCapture() {
const check = canVoiceCapture()
if (!check.ok) {
activeFooter?.setStatusMessage("⛭ Voice unavailable: " + (check.reason ?? "unknown"))
const reason = check.reason ?? "unknown"
activeFooter?.setStatusMessage("⛭ Voice unavailable: " + reason)
setTimeout(() => activeFooter?.setStatusMessage(""), 4000)
return
}
const prevMode = voiceCaptureActive
voiceCaptureActive = true
activeFooter?.setStatusMessage("🎤 Recording... (voice key or Enter to stop)")
try {
const text = await voiceCaptureFlow()
if (text) {
stdinInput =
stdinInput.slice(0, stdinCursor) + text + " " + stdinInput.slice(stdinCursor)
stdinCursor += text.length + 1
// Don't call renderInput here — the chat loop will call chatInput() next,
// which preserves stdinInput (via voiceJustCaptured) and renders it once.
} else {
activeFooter?.setStatusMessage("🎤 No speech detected — press voice key to retry")
}
Comment on lines 1052 to 1059

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

"Recording..." footer message never cleared on successful transcription.

When voiceCaptureFlow() returns non-empty text, the code inserts it into stdinInput but never clears the earlier "🎤 Recording... (voice key or Enter to stop)" footer message. It lingers until the user types a character (line 1030) or another status update overwrites it, which can confuse the user into thinking recording is still active right after a successful capture.

🧹 Proposed fix
     const text = await voiceCaptureFlow()
     if (text) {
+      activeFooter?.setStatusMessage("")
       stdinInput =
         stdinInput.slice(0, stdinCursor) + text + " " + stdinInput.slice(stdinCursor)
       stdinCursor += text.length + 1
     } else {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const text = await voiceCaptureFlow()
if (text) {
stdinInput =
stdinInput.slice(0, stdinCursor) + text + " " + stdinInput.slice(stdinCursor)
stdinCursor += text.length + 1
// Don't call renderInput here — the chat loop will call chatInput() next,
// which preserves stdinInput (via voiceJustCaptured) and renders it once.
} else {
activeFooter?.setStatusMessage("🎤 No speech detected — press voice key to retry")
}
const text = await voiceCaptureFlow()
if (text) {
activeFooter?.setStatusMessage("")
stdinInput =
stdinInput.slice(0, stdinCursor) + text + " " + stdinInput.slice(stdinCursor)
stdinCursor += text.length + 1
} else {
activeFooter?.setStatusMessage("🎤 No speech detected — press voice key to retry")
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/cli/ai/chat/chat.ts` around lines 1052 - 1059,
The voice capture success path in chat.ts leaves the “Recording...” footer stale
after transcription completes. In the voice capture handling around
voiceCaptureFlow(), clear or reset activeFooter’s status message when text is
returned, before or after updating stdinInput/stdinCursor, so the footer no
longer suggests recording is still active; keep the existing “No speech
detected” message only for the empty-text branch.

} catch (err) {
const msg = err instanceof Error ? err.message : "Voice capture failed"
activeFooter?.setStatusMessage("⛭ " + msg)
activeFooter?.setStatusMessage("⛭ Voice failed: " + (err instanceof Error ? err.message : err))
setTimeout(() => activeFooter?.setStatusMessage(""), 4000)
} finally {
voiceCaptureActive = prevMode
}
}

Expand Down Expand Up @@ -1616,7 +1621,7 @@ export async function startChat(

// ── Quick-start hint ────────────────────────────────────────
console.log(
` ${chalk.hex(theme.greenDim)("hint")} ${chalk.hex(theme.green)("·")} ${chalk.hex(theme.greenGlow)("/model")} to switch ${chalk.hex(theme.greenDim)("·")} ${chalk.hex(theme.greenGlow)("F2")} voice ${chalk.hex(theme.greenDim)("·")} ${chalk.hex(theme.greenGlow)("/help")} for commands ${chalk.hex(theme.greenDim)("·")} ${chalk.hex(theme.greenGlow)("Tab")} to cycle mode`,
` ${chalk.hex(theme.greenDim)("hint")} ${chalk.hex(theme.green)("·")} ${chalk.hex(theme.greenGlow)("/model")} to switch ${chalk.hex(theme.greenDim)("·")} ${chalk.hex(theme.greenGlow)("Ctrl+V")} voice ${chalk.hex(theme.greenDim)("·")} ${chalk.hex(theme.greenGlow)("/help")} for commands ${chalk.hex(theme.greenDim)("·")} ${chalk.hex(theme.greenGlow)("Tab")} to cycle mode`,
)
console.log()

Expand Down
84 changes: 84 additions & 0 deletions apps/supercode-cli/server/src/voice/__tests__/speech.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"

describe("canVoiceCapture", () => {
const origEnv = { ...process.env }

beforeEach(() => {
process.env = { PATH: origEnv.PATH, FFMPEG_PATH: origEnv.FFMPEG_PATH }
delete process.env.ELEVENLABS_API_KEY
delete process.env.GROQ_API_KEY
delete process.env.STT_PROVIDER
})

afterEach(() => {
process.env = { ...origEnv }
})

it("returns ok=false when ELEVENLABS_API_KEY is missing and STT_PROVIDER=elevenlabs", async () => {
process.env.STT_PROVIDER = "elevenlabs"
const { canVoiceCapture } = await import("../speech.ts")
const result = canVoiceCapture()
expect(result.ok).toBe(false)
expect(result.reason).toContain("ELEVENLABS_API_KEY")
})

it("returns ok=false when GROQ_API_KEY is missing and STT_PROVIDER=groq", async () => {
process.env.STT_PROVIDER = "groq"
const { canVoiceCapture } = await import("../speech.ts")
const result = canVoiceCapture()
expect(result.ok).toBe(false)
expect(result.reason).toContain("GROQ_API_KEY")
})

it("uses elevenlabs by default when STT_PROVIDER is unset", async () => {
const { canVoiceCapture } = await import("../speech.ts")
const result = canVoiceCapture()
expect(result.ok).toBe(false)
expect(result.reason).toContain("ELEVENLABS_API_KEY")
})

it("returns ok=false with ffmpeg reason when ffmpeg is missing", async () => {
process.env.ELEVENLABS_API_KEY = "sk-test"
process.env.FFMPEG_PATH = "/nonexistent/ffmpeg"
const { canVoiceCapture } = await import("../speech.ts")
const result = canVoiceCapture()
expect(result.ok).toBe(false)
expect(result.reason).toContain("ffmpeg")
})
})

describe("getSttProvider", () => {
const origEnv = { ...process.env }

beforeEach(() => {
process.env = { PATH: origEnv.PATH }
delete process.env.STT_PROVIDER
})

afterEach(() => {
process.env = { ...origEnv }
})

it('returns "elevenlabs" when STT_PROVIDER is unset', async () => {
const mod = await import("../speech.ts")
expect((mod as any).getSttProvider()).toBe("elevenlabs")
})

it('returns "elevenlabs" when STT_PROVIDER is "elevenlabs"', async () => {
process.env.STT_PROVIDER = "elevenlabs"
const mod = await import("../speech.ts")
expect((mod as any).getSttProvider()).toBe("elevenlabs")
})

it('returns "groq" when STT_PROVIDER is "groq"', async () => {
process.env.STT_PROVIDER = "groq"
const mod = await import("../speech.ts")
expect((mod as any).getSttProvider()).toBe("groq")
})

it('returns "elevenlabs" for unknown STT_PROVIDER values', async () => {
process.env.STT_PROVIDER = "invalid"
const mod = await import("../speech.ts")
expect((mod as any).getSttProvider()).toBe("elevenlabs")
})
})
Loading
Loading