npcsh 1.1.21__py3-none-any.whl → 1.1.23__py3-none-any.whl

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.
Files changed (188) hide show
  1. npcsh/_state.py +282 -125
  2. npcsh/benchmark/npcsh_agent.py +77 -232
  3. npcsh/benchmark/templates/install-npcsh.sh.j2 +12 -4
  4. npcsh/config.py +5 -2
  5. npcsh/mcp_server.py +9 -1
  6. npcsh/npc_team/alicanto.npc +8 -6
  7. npcsh/npc_team/corca.npc +5 -12
  8. npcsh/npc_team/frederic.npc +6 -9
  9. npcsh/npc_team/guac.npc +4 -4
  10. npcsh/npc_team/jinxs/lib/core/delegate.jinx +1 -1
  11. npcsh/npc_team/jinxs/lib/core/edit_file.jinx +84 -62
  12. npcsh/npc_team/jinxs/lib/core/sh.jinx +1 -1
  13. npcsh/npc_team/jinxs/lib/core/skill.jinx +59 -0
  14. npcsh/npc_team/jinxs/lib/utils/help.jinx +194 -10
  15. npcsh/npc_team/jinxs/lib/utils/init.jinx +528 -37
  16. npcsh/npc_team/jinxs/lib/utils/jinxs.jinx +0 -1
  17. npcsh/npc_team/jinxs/lib/utils/serve.jinx +938 -21
  18. npcsh/npc_team/jinxs/modes/alicanto.jinx +102 -41
  19. npcsh/npc_team/jinxs/modes/build.jinx +378 -0
  20. npcsh-1.1.21.data/data/npcsh/npc_team/config_tui.jinx → npcsh/npc_team/jinxs/modes/config.jinx +1 -1
  21. npcsh/npc_team/jinxs/modes/convene.jinx +670 -0
  22. npcsh/npc_team/jinxs/modes/corca.jinx +777 -387
  23. npcsh/npc_team/jinxs/modes/crond.jinx +818 -0
  24. npcsh/npc_team/jinxs/modes/kg.jinx +69 -2
  25. npcsh/npc_team/jinxs/modes/plonk.jinx +86 -15
  26. npcsh/npc_team/jinxs/modes/roll.jinx +368 -55
  27. npcsh/npc_team/jinxs/modes/skills.jinx +621 -0
  28. npcsh/npc_team/jinxs/modes/yap.jinx +1092 -177
  29. npcsh/npc_team/jinxs/skills/code-review/SKILL.md +45 -0
  30. npcsh/npc_team/jinxs/skills/debugging/SKILL.md +44 -0
  31. npcsh/npc_team/jinxs/skills/git-workflow.jinx +44 -0
  32. npcsh/npc_team/kadiefa.npc +6 -6
  33. npcsh/npc_team/npcsh.ctx +16 -0
  34. npcsh/npc_team/plonk.npc +5 -9
  35. npcsh/npc_team/sibiji.npc +15 -7
  36. npcsh/npcsh.py +1 -0
  37. npcsh/routes.py +0 -4
  38. npcsh/yap.py +22 -4
  39. npcsh-1.1.23.data/data/npcsh/npc_team/SKILL.md +44 -0
  40. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/alicanto.jinx +102 -41
  41. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/alicanto.npc +8 -6
  42. npcsh-1.1.23.data/data/npcsh/npc_team/build.jinx +378 -0
  43. npcsh/npc_team/jinxs/modes/config_tui.jinx → npcsh-1.1.23.data/data/npcsh/npc_team/config.jinx +1 -1
  44. npcsh-1.1.23.data/data/npcsh/npc_team/convene.jinx +670 -0
  45. npcsh-1.1.23.data/data/npcsh/npc_team/corca.jinx +820 -0
  46. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/corca.npc +5 -12
  47. npcsh-1.1.23.data/data/npcsh/npc_team/crond.jinx +818 -0
  48. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/delegate.jinx +1 -1
  49. npcsh-1.1.23.data/data/npcsh/npc_team/edit_file.jinx +119 -0
  50. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/frederic.npc +6 -9
  51. npcsh-1.1.23.data/data/npcsh/npc_team/git-workflow.jinx +44 -0
  52. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/guac.npc +4 -4
  53. npcsh-1.1.23.data/data/npcsh/npc_team/help.jinx +236 -0
  54. npcsh-1.1.23.data/data/npcsh/npc_team/init.jinx +532 -0
  55. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/jinxs.jinx +0 -1
  56. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/kadiefa.npc +6 -6
  57. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/kg.jinx +69 -2
  58. npcsh-1.1.23.data/data/npcsh/npc_team/npcsh.ctx +34 -0
  59. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonk.jinx +86 -15
  60. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonk.npc +5 -9
  61. npcsh-1.1.23.data/data/npcsh/npc_team/roll.jinx +378 -0
  62. npcsh-1.1.23.data/data/npcsh/npc_team/serve.jinx +943 -0
  63. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sh.jinx +1 -1
  64. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sibiji.npc +15 -7
  65. npcsh-1.1.23.data/data/npcsh/npc_team/skill.jinx +59 -0
  66. npcsh-1.1.23.data/data/npcsh/npc_team/skills.jinx +621 -0
  67. npcsh-1.1.23.data/data/npcsh/npc_team/yap.jinx +1190 -0
  68. {npcsh-1.1.21.dist-info → npcsh-1.1.23.dist-info}/METADATA +404 -278
  69. npcsh-1.1.23.dist-info/RECORD +216 -0
  70. npcsh/npc_team/jinxs/incognide/add_tab.jinx +0 -11
  71. npcsh/npc_team/jinxs/incognide/close_pane.jinx +0 -9
  72. npcsh/npc_team/jinxs/incognide/close_tab.jinx +0 -10
  73. npcsh/npc_team/jinxs/incognide/confirm.jinx +0 -10
  74. npcsh/npc_team/jinxs/incognide/focus_pane.jinx +0 -9
  75. npcsh/npc_team/jinxs/incognide/list_panes.jinx +0 -8
  76. npcsh/npc_team/jinxs/incognide/navigate.jinx +0 -10
  77. npcsh/npc_team/jinxs/incognide/notify.jinx +0 -10
  78. npcsh/npc_team/jinxs/incognide/open_pane.jinx +0 -13
  79. npcsh/npc_team/jinxs/incognide/read_pane.jinx +0 -9
  80. npcsh/npc_team/jinxs/incognide/run_terminal.jinx +0 -10
  81. npcsh/npc_team/jinxs/incognide/send_message.jinx +0 -10
  82. npcsh/npc_team/jinxs/incognide/split_pane.jinx +0 -12
  83. npcsh/npc_team/jinxs/incognide/switch_npc.jinx +0 -10
  84. npcsh/npc_team/jinxs/incognide/switch_tab.jinx +0 -10
  85. npcsh/npc_team/jinxs/incognide/write_file.jinx +0 -11
  86. npcsh/npc_team/jinxs/incognide/zen_mode.jinx +0 -9
  87. npcsh/npc_team/jinxs/lib/core/convene.jinx +0 -232
  88. npcsh/npc_team/jinxs/lib/core/search/kg_search.jinx +0 -429
  89. npcsh/npc_team/jinxs/lib/core/search.jinx +0 -54
  90. npcsh/npc_team/jinxs/lib/utils/build.jinx +0 -65
  91. npcsh-1.1.21.data/data/npcsh/npc_team/add_tab.jinx +0 -11
  92. npcsh-1.1.21.data/data/npcsh/npc_team/build.jinx +0 -65
  93. npcsh-1.1.21.data/data/npcsh/npc_team/close_pane.jinx +0 -9
  94. npcsh-1.1.21.data/data/npcsh/npc_team/close_tab.jinx +0 -10
  95. npcsh-1.1.21.data/data/npcsh/npc_team/confirm.jinx +0 -10
  96. npcsh-1.1.21.data/data/npcsh/npc_team/convene.jinx +0 -232
  97. npcsh-1.1.21.data/data/npcsh/npc_team/corca.jinx +0 -430
  98. npcsh-1.1.21.data/data/npcsh/npc_team/edit_file.jinx +0 -97
  99. npcsh-1.1.21.data/data/npcsh/npc_team/focus_pane.jinx +0 -9
  100. npcsh-1.1.21.data/data/npcsh/npc_team/help.jinx +0 -52
  101. npcsh-1.1.21.data/data/npcsh/npc_team/init.jinx +0 -41
  102. npcsh-1.1.21.data/data/npcsh/npc_team/kg_search.jinx +0 -429
  103. npcsh-1.1.21.data/data/npcsh/npc_team/list_panes.jinx +0 -8
  104. npcsh-1.1.21.data/data/npcsh/npc_team/navigate.jinx +0 -10
  105. npcsh-1.1.21.data/data/npcsh/npc_team/notify.jinx +0 -10
  106. npcsh-1.1.21.data/data/npcsh/npc_team/npcsh.ctx +0 -18
  107. npcsh-1.1.21.data/data/npcsh/npc_team/open_pane.jinx +0 -13
  108. npcsh-1.1.21.data/data/npcsh/npc_team/read_pane.jinx +0 -9
  109. npcsh-1.1.21.data/data/npcsh/npc_team/roll.jinx +0 -65
  110. npcsh-1.1.21.data/data/npcsh/npc_team/run_terminal.jinx +0 -10
  111. npcsh-1.1.21.data/data/npcsh/npc_team/search.jinx +0 -54
  112. npcsh-1.1.21.data/data/npcsh/npc_team/send_message.jinx +0 -10
  113. npcsh-1.1.21.data/data/npcsh/npc_team/serve.jinx +0 -26
  114. npcsh-1.1.21.data/data/npcsh/npc_team/split_pane.jinx +0 -12
  115. npcsh-1.1.21.data/data/npcsh/npc_team/switch_npc.jinx +0 -10
  116. npcsh-1.1.21.data/data/npcsh/npc_team/switch_tab.jinx +0 -10
  117. npcsh-1.1.21.data/data/npcsh/npc_team/write_file.jinx +0 -11
  118. npcsh-1.1.21.data/data/npcsh/npc_team/yap.jinx +0 -275
  119. npcsh-1.1.21.data/data/npcsh/npc_team/zen_mode.jinx +0 -9
  120. npcsh-1.1.21.dist-info/RECORD +0 -243
  121. /npcsh/npc_team/jinxs/lib/{core → utils}/chat.jinx +0 -0
  122. /npcsh/npc_team/jinxs/lib/{core → utils}/cmd.jinx +0 -0
  123. /npcsh/npc_team/jinxs/{incognide → lib/utils}/incognide.jinx +0 -0
  124. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/alicanto.png +0 -0
  125. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/arxiv.jinx +0 -0
  126. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/benchmark.jinx +0 -0
  127. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/browser_action.jinx +0 -0
  128. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/browser_screenshot.jinx +0 -0
  129. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/chat.jinx +0 -0
  130. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/click.jinx +0 -0
  131. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/close_browser.jinx +0 -0
  132. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/cmd.jinx +0 -0
  133. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/compile.jinx +0 -0
  134. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/compress.jinx +0 -0
  135. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/corca.png +0 -0
  136. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/corca_example.png +0 -0
  137. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/db_search.jinx +0 -0
  138. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/file_search.jinx +0 -0
  139. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/frederic4.png +0 -0
  140. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/git.jinx +0 -0
  141. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/guac.jinx +0 -0
  142. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/guac.png +0 -0
  143. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/incognide.jinx +0 -0
  144. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/kadiefa.png +0 -0
  145. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/key_press.jinx +0 -0
  146. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/launch_app.jinx +0 -0
  147. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/load_file.jinx +0 -0
  148. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/memories.jinx +0 -0
  149. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/models.jinx +0 -0
  150. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
  151. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/nql.jinx +0 -0
  152. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/open_browser.jinx +0 -0
  153. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/ots.jinx +0 -0
  154. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/papers.jinx +0 -0
  155. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/paste.jinx +0 -0
  156. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonk.png +0 -0
  157. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonkjr.png +0 -0
  158. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/pti.jinx +0 -0
  159. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/python.jinx +0 -0
  160. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/reattach.jinx +0 -0
  161. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sample.jinx +0 -0
  162. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/screenshot.jinx +0 -0
  163. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/set.jinx +0 -0
  164. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/setup.jinx +0 -0
  165. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/shh.jinx +0 -0
  166. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sibiji.png +0 -0
  167. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sleep.jinx +0 -0
  168. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/spool.jinx +0 -0
  169. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/spool.png +0 -0
  170. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sql.jinx +0 -0
  171. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/switch.jinx +0 -0
  172. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/switches.jinx +0 -0
  173. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sync.jinx +0 -0
  174. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/team.jinx +0 -0
  175. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/teamviz.jinx +0 -0
  176. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/trigger.jinx +0 -0
  177. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/type_text.jinx +0 -0
  178. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/usage.jinx +0 -0
  179. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/verbose.jinx +0 -0
  180. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/vixynt.jinx +0 -0
  181. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/wait.jinx +0 -0
  182. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/wander.jinx +0 -0
  183. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/web_search.jinx +0 -0
  184. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/yap.png +0 -0
  185. {npcsh-1.1.21.dist-info → npcsh-1.1.23.dist-info}/WHEEL +0 -0
  186. {npcsh-1.1.21.dist-info → npcsh-1.1.23.dist-info}/entry_points.txt +0 -0
  187. {npcsh-1.1.21.dist-info → npcsh-1.1.23.dist-info}/licenses/LICENSE +0 -0
  188. {npcsh-1.1.21.dist-info → npcsh-1.1.23.dist-info}/top_level.txt +0 -0
@@ -1,44 +1,40 @@
1
1
  jinx_name: yap
2
- description: Voice chat mode - speech-to-text input, text-to-speech output
2
+ description: Voice chat TUI - speech-to-text input, text-to-speech output
3
+ interactive: true
3
4
  inputs:
4
5
  - model: null
5
6
  - provider: null
6
7
  - tts_model: kokoro
7
8
  - voice: af_heart
8
9
  - files: null
10
+ - show_setup: false
9
11
 
10
12
  steps:
11
- - name: yap_repl
13
+ - name: yap_tui
12
14
  engine: python
13
15
  code: |
14
- import os
15
- import sys
16
- import time
17
- import tempfile
18
- import threading
19
- import queue
16
+ import os, sys, tty, termios, time, tempfile, threading, queue
17
+ import select as _sel
20
18
  from termcolor import colored
21
19
 
22
- # Audio imports with graceful fallback
20
+ # Audio imports
23
21
  try:
24
22
  import torch
25
23
  import pyaudio
26
24
  import wave
27
25
  import numpy as np
28
26
  from faster_whisper import WhisperModel
29
- from gtts import gTTS
30
27
  from npcpy.data.audio import (
31
28
  FORMAT, CHANNELS, RATE, CHUNK,
32
29
  transcribe_recording, convert_mp3_to_wav
33
30
  )
31
+ from npcpy.gen.audio_gen import text_to_speech, get_available_engines, get_available_voices
34
32
  AUDIO_AVAILABLE = True
35
- except ImportError as e:
33
+ except ImportError:
36
34
  AUDIO_AVAILABLE = False
37
- print(colored(f"Audio dependencies not available: {e}", "yellow"))
38
- print("Install with: pip install npcsh[audio]")
39
35
 
40
36
  from npcpy.llm_funcs import get_llm_response
41
- from npcpy.npc_sysenv import get_system_message, render_markdown
37
+ from npcpy.npc_sysenv import get_system_message, render_markdown, get_locally_available_models
42
38
  from npcpy.data.load import load_file_contents
43
39
  from npcpy.data.text import rag_search
44
40
 
@@ -46,10 +42,10 @@ steps:
46
42
  team = context.get('team')
47
43
  messages = context.get('messages', [])
48
44
  files = context.get('files')
49
- tts_model = context.get('tts_model', 'kokoro')
50
- voice = context.get('voice', 'af_heart')
45
+ tts_model_name = context.get('tts_model', 'kokoro')
46
+ voice_name = context.get('voice') or None
47
+ show_setup = context.get('show_setup', False)
51
48
 
52
- # Resolve npc if it's a string (npc name) rather than NPC object
53
49
  if isinstance(npc, str) and team:
54
50
  npc = team.get(npc) if hasattr(team, 'get') else None
55
51
  elif isinstance(npc, str):
@@ -57,53 +53,303 @@ steps:
57
53
 
58
54
  model = context.get('model') or (npc.model if npc and hasattr(npc, 'model') else None)
59
55
  provider = context.get('provider') or (npc.provider if npc and hasattr(npc, 'provider') else None)
56
+ npc_name = npc.name if npc else "yap"
60
57
 
61
- print("""
62
- ██╗ ██╗ █████╗ ██████╗
63
- ╚██╗ ██╔╝██╔══██╗██╔══██╗
64
- ╚████╔╝ ███████║██████╔╝
65
- ╚██╔╝ ██╔══██║██╔═══╝
66
- ██║ ██║ ██║██║
67
- ╚═╝ ╚═╝ ╚═╝╚═╝
58
+ # ================================================================
59
+ # Non-interactive fallback
60
+ # ================================================================
61
+ if not sys.stdin.isatty():
62
+ context['output'] = "Yap requires an interactive terminal."
63
+ context['messages'] = messages
64
+ exit()
68
65
 
69
- Voice Chat Mode
70
- """)
66
+ # ================================================================
67
+ # Gather available options for setup/modal
68
+ # ================================================================
69
+ _all_engines = []
70
+ _engine_voices = {}
71
+ try:
72
+ _engines_info = get_available_engines()
73
+ for ename, einfo in _engines_info.items():
74
+ _all_engines.append(ename)
75
+ try:
76
+ _engine_voices[ename] = [v['id'] if isinstance(v, dict) else str(v) for v in get_available_voices(ename)]
77
+ except Exception:
78
+ _engine_voices[ename] = []
79
+ except Exception:
80
+ _all_engines = ['kokoro', 'qwen3', 'elevenlabs', 'openai', 'gemini', 'gtts']
81
+ _engine_voices = {e: [] for e in _all_engines}
71
82
 
72
- npc_name = npc.name if npc else "yap"
73
- print(f"Entering yap mode (NPC: {npc_name}). Type '/yq' to exit.")
83
+ if not _all_engines:
84
+ _all_engines = ['kokoro']
74
85
 
75
- if not AUDIO_AVAILABLE:
76
- print(colored("Audio not available. Falling back to text mode.", "yellow"))
86
+ _all_models = []
87
+ _all_providers = []
88
+ try:
89
+ _local = get_locally_available_models(os.getcwd())
90
+ _seen_models = set()
91
+ _seen_providers = set()
92
+ for entry in _local:
93
+ m = entry.get('model', '') if isinstance(entry, dict) else str(entry)
94
+ p = entry.get('provider', '') if isinstance(entry, dict) else ''
95
+ if m and m not in _seen_models:
96
+ _all_models.append(m)
97
+ _seen_models.add(m)
98
+ if p and p not in _seen_providers:
99
+ _all_providers.append(p)
100
+ _seen_providers.add(p)
101
+ except Exception:
102
+ pass
77
103
 
78
- # Load files for RAG context
79
- loaded_chunks = {}
80
- if files:
81
- if isinstance(files, str):
82
- files = [f.strip() for f in files.split(',')]
83
- for file_path in files:
84
- file_path = os.path.expanduser(file_path)
85
- if os.path.exists(file_path):
86
- try:
87
- chunks = load_file_contents(file_path)
88
- loaded_chunks[file_path] = chunks
89
- print(colored(f"Loaded: {file_path}", "green"))
90
- except Exception as e:
91
- print(colored(f"Error loading {file_path}: {e}", "red"))
104
+ if not _all_models:
105
+ _all_models = [model or 'gemma3:4b']
106
+ if not _all_providers:
107
+ _all_providers = [provider or 'ollama']
92
108
 
93
- # System message for concise voice responses
94
- sys_msg = get_system_message(npc) if npc else "You are a helpful assistant."
95
- sys_msg += "\n\nProvide brief responses of 1-2 sentences unless asked for more detail. Keep responses clear and conversational for voice."
109
+ # Ensure current selections are in the lists
110
+ if tts_model_name not in _all_engines:
111
+ _all_engines.insert(0, tts_model_name)
112
+ if model and model not in _all_models:
113
+ _all_models.insert(0, model)
114
+ if provider and provider not in _all_providers:
115
+ _all_providers.insert(0, provider)
96
116
 
97
- if not messages or messages[0].get("role") != "system":
98
- messages.insert(0, {"role": "system", "content": sys_msg})
117
+ def _voices_for_engine(eng):
118
+ v = _engine_voices.get(eng, [])
119
+ if not v:
120
+ defaults = {'kokoro': ['af_heart'], 'qwen3': ['ryan'], 'elevenlabs': ['rachel'],
121
+ 'openai': ['alloy'], 'gemini': ['en-US-Standard-A'], 'gtts': ['en']}
122
+ v = defaults.get(eng, ['default'])
123
+ return v
124
+
125
+ # ================================================================
126
+ # Setup screen
127
+ # ================================================================
128
+ def run_setup():
129
+ nonlocal tts_model_name, voice_name, model, provider
130
+
131
+ TURQ = '\033[38;2;64;224;208m'
132
+ PURPLE = '\033[38;2;180;130;255m'
133
+ ORANGE = '\033[38;2;255;165;0m'
134
+ GREEN = '\033[32m'
135
+ DIM = '\033[90m'
136
+ BOLD = '\033[1m'
137
+ REV = '\033[7m'
138
+ RST = '\033[0m'
139
+
140
+ def _sz():
141
+ try:
142
+ s = os.get_terminal_size()
143
+ return s.columns, s.lines
144
+ except:
145
+ return 80, 24
146
+
147
+ # Setup state
148
+ fields = ['model', 'provider', 'engine', 'voice']
149
+ field_labels = {'model': 'LLM Model', 'provider': 'LLM Provider',
150
+ 'engine': 'TTS Engine', 'voice': 'Voice'}
151
+
152
+ model_idx = _all_models.index(model) if model in _all_models else 0
153
+ provider_idx = _all_providers.index(provider) if provider in _all_providers else 0
154
+ engine_idx = _all_engines.index(tts_model_name) if tts_model_name in _all_engines else 0
155
+ cur_voices = _voices_for_engine(_all_engines[engine_idx])
156
+ voice_idx = 0
157
+ if voice_name and voice_name in cur_voices:
158
+ voice_idx = cur_voices.index(voice_name)
159
+
160
+ field_options = {
161
+ 'model': (_all_models, model_idx),
162
+ 'provider': (_all_providers, provider_idx),
163
+ 'engine': (_all_engines, engine_idx),
164
+ 'voice': (cur_voices, voice_idx),
165
+ }
166
+
167
+ sel = 0 # 0-3 = fields, 4 = save default, 5 = dont show again
168
+ save_default = True
169
+ dont_show = False
170
+ total_rows = 6 # 4 fields + 2 checkboxes
171
+
172
+ def _get_val(f):
173
+ opts, idx = field_options[f]
174
+ return opts[idx] if opts else '?'
175
+
176
+ def _render_setup():
177
+ w, h = _sz()
178
+ box_w = min(50, w - 4)
179
+ box_h = 18
180
+ sx = max(1, (w - box_w) // 2)
181
+ sy = max(1, (h - box_h) // 2)
99
182
 
100
- # Audio state
183
+ buf = ['\033[2J\033[H']
184
+
185
+ # Box border
186
+ buf.append(f'\033[{sy};{sx}H{PURPLE}{"─" * box_w}{RST}')
187
+ title = " YAP Voice Chat Setup "
188
+ tp = sx + (box_w - len(title)) // 2
189
+ buf.append(f'\033[{sy};{tp}H{BOLD}{PURPLE}{title}{RST}')
190
+
191
+ y = sy + 2
192
+ for i, f in enumerate(fields):
193
+ opts, idx = field_options[f]
194
+ val = opts[idx] if opts else '?'
195
+ label = field_labels[f]
196
+ lpad = sx + 2
197
+
198
+ if i == sel:
199
+ arrow_l = f'{TURQ}\u25c4{RST}'
200
+ arrow_r = f'{TURQ}\u25ba{RST}'
201
+ buf.append(f'\033[{y};{lpad}H{REV} {label}: {RST} {arrow_l} {BOLD}{val}{RST} {arrow_r}')
202
+ else:
203
+ buf.append(f'\033[{y};{lpad}H {DIM}{label}:{RST} {val}')
204
+ y += 2
205
+
206
+ # Separator
207
+ buf.append(f'\033[{y};{sx + 2}H{DIM}{"─" * (box_w - 4)}{RST}')
208
+ y += 1
209
+
210
+ # Checkboxes
211
+ ck_save = f'{GREEN}[x]{RST}' if save_default else '[ ]'
212
+ ck_dont = f'{GREEN}[x]{RST}' if dont_show else '[ ]'
213
+
214
+ if sel == 4:
215
+ buf.append(f'\033[{y};{sx + 2}H{REV} {ck_save} Save as default {RST}')
216
+ else:
217
+ buf.append(f'\033[{y};{sx + 2}H {ck_save} Save as default')
218
+ y += 1
219
+
220
+ if sel == 5:
221
+ buf.append(f'\033[{y};{sx + 2}H{REV} {ck_dont} Don\'t show again {RST}')
222
+ else:
223
+ buf.append(f'\033[{y};{sx + 2}H {ck_dont} Don\'t show again')
224
+ y += 2
225
+
226
+ # Hints
227
+ buf.append(f'\033[{y};{sx + 2}H{DIM}\u2191/\u2193:Navigate \u2190/\u2192:Change Space:Toggle Enter:Start Ctrl+Q:Quit{RST}')
228
+ y += 1
229
+ buf.append(f'\033[{y};{sx}H{PURPLE}{"─" * box_w}{RST}')
230
+
231
+ sys.stdout.write(''.join(buf))
232
+ sys.stdout.flush()
233
+
234
+ fd = sys.stdin.fileno()
235
+ old = termios.tcgetattr(fd)
236
+ try:
237
+ tty.setcbreak(fd)
238
+ sys.stdout.write('\033[?25l')
239
+ running = True
240
+ while running:
241
+ _render_setup()
242
+ if _sel.select([fd], [], [], 0.1)[0]:
243
+ c = os.read(fd, 1).decode('latin-1')
244
+ if c == '\x11' or c == '\x03': # Ctrl+Q / Ctrl+C
245
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
246
+ sys.stdout.write('\033[?25h\033[2J\033[H')
247
+ sys.stdout.flush()
248
+ context['output'] = "Setup cancelled."
249
+ context['messages'] = messages
250
+ exit()
251
+
252
+ elif c == '\x1b': # Escape sequence
253
+ if _sel.select([fd], [], [], 0.05)[0]:
254
+ c2 = os.read(fd, 1).decode('latin-1')
255
+ if c2 == '[':
256
+ c3 = os.read(fd, 1).decode('latin-1')
257
+ if c3 == 'A': # Up
258
+ sel = max(0, sel - 1)
259
+ elif c3 == 'B': # Down
260
+ sel = min(total_rows - 1, sel + 1)
261
+ elif c3 == 'D': # Left
262
+ if sel < 4:
263
+ f = fields[sel]
264
+ opts, idx = field_options[f]
265
+ if opts:
266
+ new_idx = (idx - 1) % len(opts)
267
+ field_options[f] = (opts, new_idx)
268
+ if f == 'engine':
269
+ nv = _voices_for_engine(opts[new_idx])
270
+ field_options['voice'] = (nv, 0)
271
+ elif c3 == 'C': # Right
272
+ if sel < 4:
273
+ f = fields[sel]
274
+ opts, idx = field_options[f]
275
+ if opts:
276
+ new_idx = (idx + 1) % len(opts)
277
+ field_options[f] = (opts, new_idx)
278
+ if f == 'engine':
279
+ nv = _voices_for_engine(opts[new_idx])
280
+ field_options['voice'] = (nv, 0)
281
+
282
+ elif c == 'k':
283
+ sel = max(0, sel - 1)
284
+ elif c == 'j':
285
+ sel = min(total_rows - 1, sel + 1)
286
+ elif c == 'h':
287
+ if sel < 4:
288
+ f = fields[sel]
289
+ opts, idx = field_options[f]
290
+ if opts:
291
+ new_idx = (idx - 1) % len(opts)
292
+ field_options[f] = (opts, new_idx)
293
+ if f == 'engine':
294
+ nv = _voices_for_engine(opts[new_idx])
295
+ field_options['voice'] = (nv, 0)
296
+ elif c == 'l':
297
+ if sel < 4:
298
+ f = fields[sel]
299
+ opts, idx = field_options[f]
300
+ if opts:
301
+ new_idx = (idx + 1) % len(opts)
302
+ field_options[f] = (opts, new_idx)
303
+ if f == 'engine':
304
+ nv = _voices_for_engine(opts[new_idx])
305
+ field_options['voice'] = (nv, 0)
306
+ elif c == ' ':
307
+ if sel == 4:
308
+ save_default = not save_default
309
+ elif sel == 5:
310
+ dont_show = not dont_show
311
+ elif c in ('\r', '\n'):
312
+ running = False
313
+
314
+ finally:
315
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
316
+ sys.stdout.write('\033[?25h\033[2J\033[H')
317
+ sys.stdout.flush()
318
+
319
+ # Apply selections
320
+ model = _get_val('model')
321
+ provider = _get_val('provider')
322
+ tts_model_name = _get_val('engine')
323
+ voice_name = _get_val('voice')
324
+
325
+ # Persist if requested
326
+ if save_default or dont_show:
327
+ from npcsh.config import set_npcsh_config_value
328
+ if save_default:
329
+ set_npcsh_config_value("NPCSH_TTS_ENGINE", tts_model_name)
330
+ set_npcsh_config_value("NPCSH_TTS_VOICE", voice_name)
331
+ set_npcsh_config_value("NPCSH_CHAT_MODEL", model)
332
+ set_npcsh_config_value("NPCSH_CHAT_PROVIDER", provider)
333
+ if dont_show:
334
+ set_npcsh_config_value("NPCSH_YAP_SETUP_DONE", "1")
335
+
336
+ if show_setup:
337
+ run_setup()
338
+
339
+ # Set default voice if still None
340
+ if not voice_name:
341
+ defaults = {'kokoro': 'af_heart', 'qwen3': 'ryan', 'elevenlabs': 'rachel',
342
+ 'openai': 'alloy', 'gemini': 'en-US-Standard-A', 'gtts': 'en'}
343
+ voice_name = defaults.get(tts_model_name, 'default')
344
+
345
+ # ================================================================
346
+ # Audio models
347
+ # ================================================================
101
348
  vad_model = None
102
349
  whisper_model = None
103
350
 
104
351
  if AUDIO_AVAILABLE:
105
352
  try:
106
- # Load VAD model for voice activity detection
107
353
  vad_model, _ = torch.hub.load(
108
354
  repo_or_dir="snakers4/silero-vad",
109
355
  model="silero_vad",
@@ -112,164 +358,833 @@ steps:
112
358
  verbose=False
113
359
  )
114
360
  vad_model.to('cpu')
115
- print(colored("VAD model loaded.", "green"))
116
-
117
- # Load Whisper for STT
361
+ except Exception:
362
+ pass
363
+ try:
118
364
  whisper_model = WhisperModel("base", device="cpu", compute_type="int8")
119
- print(colored("Whisper model loaded.", "green"))
120
- except Exception as e:
121
- print(colored(f"Error loading audio models: {e}", "red"))
365
+ except Exception:
122
366
  AUDIO_AVAILABLE = False
123
367
 
124
- def speak_text(text, tts_model='kokoro', voice='af_heart'):
125
- """Convert text to speech and play it"""
126
- if not AUDIO_AVAILABLE:
127
- return
368
+ # ================================================================
369
+ # File loading for RAG
370
+ # ================================================================
371
+ loaded_chunks = {}
372
+ if files:
373
+ if isinstance(files, str):
374
+ files = [f.strip() for f in files.split(',')]
375
+ for fp in files:
376
+ fp = os.path.expanduser(fp)
377
+ if os.path.exists(fp):
378
+ try:
379
+ loaded_chunks[fp] = load_file_contents(fp)
380
+ except Exception:
381
+ pass
382
+
383
+ # System message
384
+ sys_msg = get_system_message(npc) if npc else "You are a helpful assistant."
385
+ sys_msg += "\n\nProvide brief responses of 1-2 sentences unless asked for more detail. Keep responses clear and conversational for voice."
386
+ if not messages or messages[0].get("role") != "system":
387
+ messages.insert(0, {"role": "system", "content": sys_msg})
388
+
389
+ # ================================================================
390
+ # State
391
+ # ================================================================
392
+ class UI:
393
+ tab = 0 # 0=chat, 1=settings
394
+ TAB_NAMES = ['Chat', 'Settings']
395
+
396
+ # chat
397
+ chat_log = [] # [(role, text)]
398
+ chat_scroll = -1
399
+ input_buf = ""
400
+ thinking = False
401
+ spinner_frame = 0
402
+ recording = False
403
+ rec_seconds = 0.0
404
+ transcribing = False
405
+ speaking = False
406
+
407
+ # VAD listening
408
+ listening = AUDIO_AVAILABLE # auto-listen by default
409
+ listen_stop = False # signal to stop listener thread
410
+
411
+ # settings
412
+ set_sel = 0
413
+ tts_enabled = AUDIO_AVAILABLE
414
+ auto_speak = True
415
+ vad_threshold = 0.4 # speech probability threshold
416
+ silence_timeout = 1.5 # seconds of silence before cut
417
+ min_speech = 0.3 # minimum speech duration to process
418
+ editing = False
419
+ edit_buf = ""
420
+ edit_key = ""
421
+
422
+ # Modal state
423
+ modal_open = False
424
+ modal_sel = 0
425
+ modal_engine_idx = 0
426
+ modal_voice_idx = 0
427
+ modal_model_idx = 0
428
+ modal_provider_idx = 0
429
+ modal_save = False
430
+
431
+ ui = UI()
432
+
433
+ # Initialize modal indices to current selections
434
+ if tts_model_name in _all_engines:
435
+ ui.modal_engine_idx = _all_engines.index(tts_model_name)
436
+ if model and model in _all_models:
437
+ ui.modal_model_idx = _all_models.index(model)
438
+ if provider and provider in _all_providers:
439
+ ui.modal_provider_idx = _all_providers.index(provider)
440
+ cur_v = _voices_for_engine(tts_model_name)
441
+ if voice_name in cur_v:
442
+ ui.modal_voice_idx = cur_v.index(voice_name)
443
+
444
+ # ================================================================
445
+ # Helpers
446
+ # ================================================================
447
+ def sz():
448
+ try:
449
+ s = os.get_terminal_size()
450
+ return s.columns, s.lines
451
+ except:
452
+ return 80, 24
453
+
454
+ TURQ = '\033[38;2;64;224;208m'
455
+ PURPLE = '\033[38;2;180;130;255m'
456
+ ORANGE = '\033[38;2;255;165;0m'
457
+ GREEN = '\033[32m'
458
+ DIM = '\033[90m'
459
+ BOLD = '\033[1m'
460
+ REV = '\033[7m'
461
+ RST = '\033[0m'
462
+ RED = '\033[31m'
463
+ SPINNERS = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f']
464
+
465
+ def wrap_text(text, width):
466
+ lines = []
467
+ for line in text.split('\n'):
468
+ while len(line) > width:
469
+ lines.append(line[:width])
470
+ line = line[width:]
471
+ lines.append(line)
472
+ return lines
128
473
 
474
+ # ================================================================
475
+ # Audio functions
476
+ # ================================================================
477
+ def transcribe_audio(audio_path):
478
+ if not whisper_model or not audio_path:
479
+ return ""
129
480
  try:
130
- # Use gTTS as fallback
131
- tts = gTTS(text=text, lang='en')
132
- with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f:
133
- tts.save(f.name)
134
- wav_path = convert_mp3_to_wav(f.name)
481
+ segments, _ = whisper_model.transcribe(audio_path, beam_size=5)
482
+ text = " ".join([seg.text for seg in segments]).strip()
483
+ try: os.remove(audio_path)
484
+ except: pass
485
+ return text
486
+ except Exception as e:
487
+ ui.chat_log.append(('error', f'Transcribe error: {e}'))
488
+ return ""
135
489
 
136
- # Play audio
490
+ def speak_text(text):
491
+ if not AUDIO_AVAILABLE or not ui.tts_enabled:
492
+ return
493
+ try:
494
+ ui.speaking = True
137
495
  import subprocess
496
+
497
+ audio_bytes = text_to_speech(text, engine=tts_model_name, voice=voice_name)
498
+
499
+ # Determine file format from engine
500
+ if tts_model_name in ('elevenlabs', 'gtts'):
501
+ suffix = '.mp3'
502
+ else:
503
+ suffix = '.wav'
504
+
505
+ tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
506
+ tmp_path = tmp.name
507
+ tmp.write(audio_bytes)
508
+ tmp.close()
509
+
510
+ # If mp3, convert to wav for playback
511
+ play_path = tmp_path
512
+ if suffix == '.mp3':
513
+ wav_path = tmp_path.replace('.mp3', '.wav')
514
+ convert_mp3_to_wav(tmp_path, wav_path)
515
+ play_path = wav_path
516
+
138
517
  if sys.platform == 'darwin':
139
- subprocess.run(['afplay', wav_path], check=True)
518
+ subprocess.run(['afplay', play_path], check=True, timeout=60)
140
519
  elif sys.platform == 'linux':
141
- subprocess.run(['aplay', wav_path], check=True)
142
- else:
143
- # Windows
144
- import winsound
145
- winsound.PlaySound(wav_path, winsound.SND_FILENAME)
520
+ subprocess.run(['aplay', play_path], check=True, timeout=60,
521
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
146
522
 
147
- for _p in [f.name, wav_path]:
148
- try:
149
- os.remove(_p)
150
- except:
151
- pass
523
+ for _p in set([tmp_path, play_path]):
524
+ try: os.remove(_p)
525
+ except: pass
152
526
  except Exception as e:
153
- print(colored(f"TTS error: {e}", "red"))
527
+ ui.chat_log.append(('error', f'TTS error: {e}'))
528
+ finally:
529
+ ui.speaking = False
154
530
 
155
- def record_audio(duration=5):
156
- """Record audio from microphone"""
157
- if not AUDIO_AVAILABLE:
158
- return None
531
+ def save_frames_to_wav(frames, sample_width):
532
+ f = tempfile.NamedTemporaryFile(suffix='.wav', delete=False)
533
+ path = f.name
534
+ f.close()
535
+ wf = wave.open(path, 'wb')
536
+ wf.setnchannels(CHANNELS)
537
+ wf.setsampwidth(sample_width)
538
+ wf.setframerate(RATE)
539
+ wf.writeframes(b''.join(frames))
540
+ wf.close()
541
+ return path
159
542
 
543
+ # ================================================================
544
+ # VAD continuous listener
545
+ # ================================================================
546
+ def vad_listener_loop():
547
+ """Background thread: continuously monitors mic, detects speech via
548
+ VAD, records until silence, then transcribes and sends."""
160
549
  try:
161
550
  p = pyaudio.PyAudio()
162
- stream = p.open(format=FORMAT, channels=CHANNELS, rate=RATE, input=True, frames_per_buffer=CHUNK)
551
+ sw = p.get_sample_size(FORMAT)
552
+ stream = p.open(format=FORMAT, channels=CHANNELS, rate=RATE,
553
+ input=True, frames_per_buffer=CHUNK)
554
+ except Exception as e:
555
+ ui.chat_log.append(('error', f'Mic open failed: {e}'))
556
+ ui.listening = False
557
+ return
558
+
559
+ chunk_dur = CHUNK / RATE # duration of one chunk in seconds
560
+
561
+ while not ui.listen_stop:
562
+ # Skip if busy
563
+ if ui.thinking or ui.speaking or ui.transcribing:
564
+ time.sleep(0.1)
565
+ continue
566
+ if not ui.listening:
567
+ time.sleep(0.1)
568
+ continue
569
+
570
+ # Read a chunk and run VAD
571
+ try:
572
+ data = stream.read(CHUNK, exception_on_overflow=False)
573
+ except Exception:
574
+ time.sleep(0.05)
575
+ continue
163
576
 
164
- print(colored("Recording...", "cyan"), end='', flush=True)
165
- frames = []
166
- for _ in range(0, int(RATE / CHUNK * duration)):
167
- data = stream.read(CHUNK)
168
- frames.append(data)
169
- print(colored(" Done.", "cyan"))
577
+ audio_np = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
578
+ if len(audio_np) != CHUNK:
579
+ continue
170
580
 
581
+ try:
582
+ tensor = torch.from_numpy(audio_np)
583
+ prob = vad_model(tensor, RATE).item()
584
+ except Exception:
585
+ continue
586
+
587
+ if prob < ui.vad_threshold:
588
+ continue
589
+
590
+ # Speech detected — start collecting frames
591
+ ui.recording = True
592
+ ui.rec_seconds = 0.0
593
+ ui.chat_scroll = -1
594
+ speech_frames = [data]
595
+ speech_dur = chunk_dur
596
+ silence_dur = 0.0
597
+
598
+ while not ui.listen_stop:
599
+ try:
600
+ data = stream.read(CHUNK, exception_on_overflow=False)
601
+ except Exception:
602
+ break
603
+
604
+ speech_frames.append(data)
605
+ speech_dur += chunk_dur
606
+ ui.rec_seconds = speech_dur
607
+
608
+ audio_np = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
609
+ try:
610
+ tensor = torch.from_numpy(audio_np)
611
+ prob = vad_model(tensor, RATE).item()
612
+ except Exception:
613
+ prob = 0.0
614
+
615
+ if prob < ui.vad_threshold:
616
+ silence_dur += chunk_dur
617
+ else:
618
+ silence_dur = 0.0
619
+
620
+ if silence_dur >= ui.silence_timeout:
621
+ break
622
+
623
+ # Safety: max 60 seconds
624
+ if speech_dur > 60.0:
625
+ break
626
+
627
+ ui.recording = False
628
+
629
+ # Only process if enough speech
630
+ if speech_dur - silence_dur < ui.min_speech:
631
+ continue
632
+
633
+ # Transcribe
634
+ ui.transcribing = True
635
+ audio_path = save_frames_to_wav(speech_frames, sw)
636
+ text = transcribe_audio(audio_path)
637
+ ui.transcribing = False
638
+
639
+ if text and text.strip():
640
+ ui.chat_log.append(('info', f'Heard: "{text}"'))
641
+ ui.chat_scroll = -1
642
+ send_message(text)
643
+
644
+ # Cleanup
645
+ try:
171
646
  stream.stop_stream()
172
647
  stream.close()
173
648
  p.terminate()
649
+ except Exception:
650
+ pass
174
651
 
175
- # Save to temp file
176
- with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
177
- wf = wave.open(f.name, 'wb')
178
- wf.setnchannels(CHANNELS)
179
- wf.setsampwidth(p.get_sample_size(FORMAT))
180
- wf.setframerate(RATE)
181
- wf.writeframes(b''.join(frames))
182
- wf.close()
183
- return f.name
184
- except Exception as e:
185
- print(colored(f"Recording error: {e}", "red"))
186
- return None
652
+ # ================================================================
653
+ # Chat send
654
+ # ================================================================
655
+ def send_message(text):
656
+ ui.chat_log.append(('user', text))
657
+ ui.thinking = True
658
+ ui.chat_scroll = -1
187
659
 
188
- def transcribe_audio(audio_path):
189
- """Transcribe audio to text using Whisper"""
190
- if not whisper_model or not audio_path:
191
- return ""
192
-
193
- try:
194
- segments, _ = whisper_model.transcribe(audio_path, beam_size=5)
195
- text = " ".join([seg.text for seg in segments])
660
+ def worker():
196
661
  try:
197
- os.remove(audio_path)
198
- except:
199
- pass
200
- return text.strip()
201
- except Exception as e:
202
- print(colored(f"Transcription error: {e}", "red"))
203
- return ""
662
+ current_prompt = text
663
+ if loaded_chunks:
664
+ ctx_content = ""
665
+ for fn, chunks in loaded_chunks.items():
666
+ full = "\n".join(chunks)
667
+ ret = rag_search(text, full, similarity_threshold=0.3)
668
+ if ret:
669
+ ctx_content += f"\n{ret}\n"
670
+ if ctx_content:
671
+ current_prompt += f"\n\nContext:{ctx_content}"
204
672
 
205
- # REPL loop
206
- while True:
207
- try:
208
- # Voice input or text input
209
- if AUDIO_AVAILABLE:
210
- prompt_str = f"{npc_name}:yap> [Press Enter to speak, or type] "
673
+ resp = get_llm_response(
674
+ current_prompt, model=model, provider=provider,
675
+ messages=messages, stream=False, npc=npc
676
+ )
677
+ messages[:] = resp.get('messages', messages)
678
+ response_text = str(resp.get('response', ''))
679
+ if response_text:
680
+ ui.chat_log.append(('assistant', response_text))
681
+ if ui.auto_speak and ui.tts_enabled:
682
+ speak_text(response_text)
683
+ except Exception as e:
684
+ ui.chat_log.append(('error', str(e)))
685
+ ui.thinking = False
686
+
687
+ threading.Thread(target=worker, daemon=True).start()
688
+
689
+ # ================================================================
690
+ # Rendering
691
+ # ================================================================
692
+ def render():
693
+ w, h = sz()
694
+ buf = ['\033[H']
695
+
696
+ # Tab bar
697
+ tabs = ''
698
+ for i, name in enumerate(ui.TAB_NAMES):
699
+ if i == ui.tab:
700
+ tabs += f' {REV}{BOLD} {name} {RST} '
211
701
  else:
212
- prompt_str = f"{npc_name}:yap> "
213
-
214
- user_input = input(prompt_str).strip()
215
-
216
- if user_input.lower() == "/yq":
217
- print("Exiting yap mode.")
218
- break
219
-
220
- # Empty input = record audio
221
- if not user_input and AUDIO_AVAILABLE:
222
- audio_path = record_audio(5)
223
- if audio_path:
224
- user_input = transcribe_audio(audio_path)
225
- if user_input:
226
- print(colored(f"You said: {user_input}", "cyan"))
227
- else:
228
- print(colored("Could not transcribe audio.", "yellow"))
229
- continue
702
+ tabs += f' {DIM} {name} {RST} '
703
+
704
+ mic = ''
705
+ if ui.recording:
706
+ mic = f'{RED}\u25cf REC {ui.rec_seconds:.1f}s{RST}'
707
+ elif ui.transcribing:
708
+ mic = f'{ORANGE}\u25cf transcribing...{RST}'
709
+ elif ui.speaking:
710
+ mic = f'{GREEN}\u25cf speaking...{RST}'
711
+ elif ui.thinking:
712
+ sp = SPINNERS[ui.spinner_frame % len(SPINNERS)]
713
+ mic = f'{ORANGE}{sp} thinking...{RST}'
714
+ elif ui.listening:
715
+ mic = f'{TURQ}\u25cf listening{RST}'
716
+
717
+ audio_st = '\U0001f3a4' if ui.listening else ('\U0001f507' if not AUDIO_AVAILABLE else '\u23f8')
718
+ right = f'{npc_name} | {audio_st} | {model or "?"}@{provider or "?"}'
719
+ pad = w - 12 - len(right) - 20
720
+ header = f'{PURPLE}YAP{RST} {tabs}{" " * max(0, pad)}{mic} {DIM}{right}{RST}'
721
+ buf.append(f'\033[1;1H{REV} {header[:w-2].ljust(w-2)} {RST}')
722
+
723
+ if ui.tab == 0:
724
+ render_chat(buf, w, h)
725
+ elif ui.tab == 1:
726
+ render_settings(buf, w, h)
727
+
728
+ # Draw modal on top if open
729
+ if ui.modal_open:
730
+ render_modal(buf, w, h)
731
+
732
+ sys.stdout.write(''.join(buf))
733
+ sys.stdout.flush()
734
+
735
+ def render_chat(buf, w, h):
736
+ input_h = 3
737
+ chat_h = h - 2 - input_h
738
+
739
+ all_lines = []
740
+ _asst_pw = len(npc_name) + 2 # "name: "
741
+ _cont_pw = _asst_pw # continuation indent matches
742
+ for role, text in ui.chat_log:
743
+ if role == 'user':
744
+ tw = w - 6
745
+ wrapped = wrap_text(text, tw)
746
+ for i, l in enumerate(wrapped):
747
+ prefix = f'{BOLD}you:{RST} ' if i == 0 else ' '
748
+ all_lines.append(f'{prefix}{l}')
749
+ elif role == 'assistant':
750
+ tw = w - _asst_pw - 1
751
+ wrapped = wrap_text(text, tw)
752
+ pad = ' ' * _asst_pw
753
+ for i, l in enumerate(wrapped):
754
+ prefix = f'{PURPLE}{BOLD}{npc_name}:{RST} ' if i == 0 else pad
755
+ all_lines.append(f'{prefix}{l}')
756
+ elif role == 'info':
757
+ tw = w - 5
758
+ wrapped = wrap_text(text, tw)
759
+ for i, l in enumerate(wrapped):
760
+ prefix = f' {TURQ}\u2139 ' if i == 0 else ' '
761
+ all_lines.append(f'{prefix}{l}{RST}' if i == 0 else f' {l}')
762
+ elif role == 'error':
763
+ tw = w - 5
764
+ wrapped = wrap_text(text, tw)
765
+ for i, l in enumerate(wrapped):
766
+ prefix = f' {RED}\u2717 ' if i == 0 else ' '
767
+ all_lines.append(f'{prefix}{l}{RST}' if i == 0 else f' {l}')
768
+
769
+ if ui.recording:
770
+ secs = ui.rec_seconds
771
+ all_lines.append(f' {RED}\U0001f399 Recording... {secs:.1f}s{RST}')
772
+ elif ui.transcribing:
773
+ sp = SPINNERS[ui.spinner_frame % len(SPINNERS)]
774
+ all_lines.append(f' {ORANGE}{sp} Transcribing...{RST}')
775
+ elif ui.thinking:
776
+ sp = SPINNERS[ui.spinner_frame % len(SPINNERS)]
777
+ all_lines.append(f' {ORANGE}{sp} thinking...{RST}')
778
+ elif ui.speaking:
779
+ all_lines.append(f' {GREEN}\U0001f50a Speaking...{RST}')
780
+
781
+ # Scrolling
782
+ if ui.chat_scroll == -1:
783
+ scroll = max(0, len(all_lines) - chat_h)
784
+ else:
785
+ scroll = ui.chat_scroll
786
+
787
+ for i in range(chat_h):
788
+ y = 2 + i
789
+ li = scroll + i
790
+ buf.append(f'\033[{y};1H\033[K')
791
+ if li < len(all_lines):
792
+ buf.append(all_lines[li])
793
+
794
+ # Input area
795
+ div_y = 2 + chat_h
796
+ buf.append(f'\033[{div_y};1H\033[K{DIM}{"\u2500" * w}{RST}')
797
+ input_y = div_y + 1
798
+ visible = ui.input_buf[-(w-4):] if len(ui.input_buf) > w - 4 else ui.input_buf
799
+ buf.append(f'\033[{input_y};1H\033[K {BOLD}>{RST} {visible}\033[?25h')
800
+
801
+ # Status bar
802
+ if AUDIO_AVAILABLE:
803
+ ltog = 'Ctrl+L:Pause' if ui.listening else 'Ctrl+L:Listen'
804
+ hints = f'Enter:Send {ltog} Ctrl+S:Settings Tab:Settings Ctrl+Q:Quit'
805
+ else:
806
+ hints = 'Enter:Send Ctrl+S:Settings Tab:Settings Ctrl+Q:Quit'
807
+ buf.append(f'\033[{h};1H\033[K{REV} {hints[:w-2].ljust(w-2)} {RST}')
808
+
809
+ def render_settings(buf, w, h):
810
+ settings = [
811
+ ('tts_enabled', 'TTS Enabled', 'On' if ui.tts_enabled else 'Off'),
812
+ ('auto_speak', 'Auto-Speak', 'On' if ui.auto_speak else 'Off'),
813
+ ('listening', 'Auto-Listen', 'On' if ui.listening else 'Off'),
814
+ ('silence_timeout', 'Silence Timeout', f'{ui.silence_timeout}s'),
815
+ ('vad_threshold', 'VAD Sensitivity', f'{ui.vad_threshold:.1f}'),
816
+ ]
817
+
818
+ buf.append(f'\033[3;3H{BOLD}Voice Settings{RST}')
819
+ buf.append(f'\033[4;3H{DIM}{"\u2500" * (w - 6)}{RST}')
820
+
821
+ y = 6
822
+ for i, (key, label, val) in enumerate(settings):
823
+ if ui.editing and ui.edit_key == key:
824
+ buf.append(f'\033[{y};3H{ORANGE}{label}:{RST} {REV} {ui.edit_buf}_ {RST}')
825
+ elif i == ui.set_sel:
826
+ buf.append(f'\033[{y};3H{REV} {label}: {val} {RST}')
827
+ else:
828
+ buf.append(f'\033[{y};3H {BOLD}{label}:{RST} {val}')
829
+ y += 2
830
+
831
+ y += 1
832
+ buf.append(f'\033[{y};3H{DIM}TTS Engine: {tts_model_name} Voice: {voice_name or "default"}{RST}')
833
+ y += 1
834
+ buf.append(f'\033[{y};3H{DIM}LLM: {model or "?"}@{provider or "?"}{RST}')
835
+ y += 1
836
+ buf.append(f'\033[{y};3H{DIM}Audio: {"Available" if AUDIO_AVAILABLE else "Not available"}{RST}')
837
+ y += 1
838
+ if loaded_chunks:
839
+ buf.append(f'\033[{y};3H{DIM}Files loaded: {len(loaded_chunks)}{RST}')
840
+ y += 1
841
+ buf.append(f'\033[{y};3H{DIM}Whisper: {"Loaded" if whisper_model else "Not loaded"}{RST}')
842
+
843
+ for cy in range(y + 1, h - 1):
844
+ buf.append(f'\033[{cy};1H\033[K')
845
+
846
+ if ui.editing:
847
+ buf.append(f'\033[{h};1H\033[K{REV} Enter:Save Esc:Cancel {RST}')
848
+ else:
849
+ buf.append(f'\033[{h};1H\033[K{REV} j/k:Navigate Space:Toggle e:Edit Ctrl+S:Quick Switch Tab:Chat Ctrl+Q:Quit {RST}')
850
+
851
+ # ================================================================
852
+ # Modal rendering and handling
853
+ # ================================================================
854
+ def render_modal(buf, w, h):
855
+ box_w = min(48, w - 4)
856
+ box_h = 16
857
+ sx = max(1, (w - box_w) // 2)
858
+ sy = max(1, (h - box_h) // 2)
859
+
860
+ # Clear box area
861
+ for y in range(sy, sy + box_h):
862
+ buf.append(f'\033[{y};{sx}H{" " * box_w}')
863
+
864
+ # Border
865
+ buf.append(f'\033[{sy};{sx}H{PURPLE}\u250c{"\u2500" * (box_w - 2)}\u2510{RST}')
866
+ for y in range(sy + 1, sy + box_h - 1):
867
+ buf.append(f'\033[{y};{sx}H{PURPLE}\u2502{RST}{" " * (box_w - 2)}{PURPLE}\u2502{RST}')
868
+ buf.append(f'\033[{sy + box_h - 1};{sx}H{PURPLE}\u2514{"\u2500" * (box_w - 2)}\u2518{RST}')
869
+
870
+ # Title
871
+ title = " Quick Settings "
872
+ tp = sx + (box_w - len(title)) // 2
873
+ buf.append(f'\033[{sy};{tp}H{BOLD}{PURPLE}{title}{RST}')
874
+
875
+ lpad = sx + 3
876
+ y = sy + 2
877
+
878
+ modal_fields = [
879
+ ('LLM Model', _all_models, ui.modal_model_idx),
880
+ ('Provider', _all_providers, ui.modal_provider_idx),
881
+ ('TTS Engine', _all_engines, ui.modal_engine_idx),
882
+ ('Voice', _voices_for_engine(_all_engines[ui.modal_engine_idx]), ui.modal_voice_idx),
883
+ ]
884
+
885
+ for i, (label, opts, idx) in enumerate(modal_fields):
886
+ val = opts[idx] if idx < len(opts) else '?'
887
+ if i == ui.modal_sel:
888
+ al = f'{TURQ}\u25c4{RST}'
889
+ ar = f'{TURQ}\u25ba{RST}'
890
+ buf.append(f'\033[{y};{lpad}H{REV} {label}: {RST} {al} {BOLD}{val}{RST} {ar}')
891
+ else:
892
+ buf.append(f'\033[{y};{lpad}H {DIM}{label}:{RST} {val}')
893
+ y += 2
894
+
895
+ # Save checkbox
896
+ ck = f'{GREEN}[x]{RST}' if ui.modal_save else '[ ]'
897
+ if ui.modal_sel == 4:
898
+ buf.append(f'\033[{y};{lpad}H{REV} {ck} Save as default {RST}')
899
+ else:
900
+ buf.append(f'\033[{y};{lpad}H {ck} Save as default')
901
+ y += 2
902
+
903
+ # Hints
904
+ buf.append(f'\033[{y};{lpad}H{DIM}\u2191\u2193:Nav \u2190\u2192:Change Spc:Toggle Enter:Apply Esc:Cancel{RST}')
905
+
906
+ def open_modal():
907
+ ui.modal_open = True
908
+ ui.modal_sel = 0
909
+ ui.modal_save = False
910
+ # Sync indices to current values
911
+ if tts_model_name in _all_engines:
912
+ ui.modal_engine_idx = _all_engines.index(tts_model_name)
913
+ if model and model in _all_models:
914
+ ui.modal_model_idx = _all_models.index(model)
915
+ if provider and provider in _all_providers:
916
+ ui.modal_provider_idx = _all_providers.index(provider)
917
+ cur_v = _voices_for_engine(_all_engines[ui.modal_engine_idx])
918
+ if voice_name in cur_v:
919
+ ui.modal_voice_idx = cur_v.index(voice_name)
920
+ else:
921
+ ui.modal_voice_idx = 0
922
+
923
+ def apply_modal():
924
+ nonlocal tts_model_name, voice_name, model, provider
925
+ model = _all_models[ui.modal_model_idx] if ui.modal_model_idx < len(_all_models) else model
926
+ provider = _all_providers[ui.modal_provider_idx] if ui.modal_provider_idx < len(_all_providers) else provider
927
+ tts_model_name = _all_engines[ui.modal_engine_idx] if ui.modal_engine_idx < len(_all_engines) else tts_model_name
928
+ cur_v = _voices_for_engine(tts_model_name)
929
+ voice_name = cur_v[ui.modal_voice_idx] if ui.modal_voice_idx < len(cur_v) else voice_name
930
+
931
+ ui.chat_log.append(('info', f'Settings: {model}@{provider}, TTS: {tts_model_name}/{voice_name}'))
932
+
933
+ if ui.modal_save:
934
+ from npcsh.config import set_npcsh_config_value
935
+ set_npcsh_config_value("NPCSH_TTS_ENGINE", tts_model_name)
936
+ set_npcsh_config_value("NPCSH_TTS_VOICE", voice_name)
937
+ set_npcsh_config_value("NPCSH_CHAT_MODEL", model)
938
+ set_npcsh_config_value("NPCSH_CHAT_PROVIDER", provider)
939
+ ui.chat_log.append(('info', 'Saved as defaults.'))
940
+
941
+ ui.modal_open = False
942
+
943
+ def handle_modal_key(c, fd):
944
+ if c == '\x1b': # Esc
945
+ if _sel.select([fd], [], [], 0.05)[0]:
946
+ c2 = os.read(fd, 1).decode('latin-1')
947
+ if c2 == '[':
948
+ c3 = os.read(fd, 1).decode('latin-1')
949
+ if c3 == 'A': # Up
950
+ ui.modal_sel = max(0, ui.modal_sel - 1)
951
+ elif c3 == 'B': # Down
952
+ ui.modal_sel = min(4, ui.modal_sel + 1)
953
+ elif c3 == 'D': # Left
954
+ _modal_cycle(-1)
955
+ elif c3 == 'C': # Right
956
+ _modal_cycle(1)
957
+ return True
958
+ # bare Esc = close
959
+ ui.modal_open = False
960
+ return True
961
+
962
+ if c == 'k':
963
+ ui.modal_sel = max(0, ui.modal_sel - 1)
964
+ elif c == 'j':
965
+ ui.modal_sel = min(4, ui.modal_sel + 1)
966
+ elif c == 'h':
967
+ _modal_cycle(-1)
968
+ elif c == 'l':
969
+ _modal_cycle(1)
970
+ elif c == ' ':
971
+ if ui.modal_sel == 4:
972
+ ui.modal_save = not ui.modal_save
973
+ elif c in ('\r', '\n'):
974
+ apply_modal()
975
+ elif c == '\x11': # Ctrl+Q in modal just closes it
976
+ ui.modal_open = False
977
+ return True
978
+
979
+ def _modal_cycle(direction):
980
+ if ui.modal_sel == 0:
981
+ ui.modal_model_idx = (ui.modal_model_idx + direction) % len(_all_models)
982
+ elif ui.modal_sel == 1:
983
+ ui.modal_provider_idx = (ui.modal_provider_idx + direction) % len(_all_providers)
984
+ elif ui.modal_sel == 2:
985
+ ui.modal_engine_idx = (ui.modal_engine_idx + direction) % len(_all_engines)
986
+ # Reset voice index when engine changes
987
+ ui.modal_voice_idx = 0
988
+ elif ui.modal_sel == 3:
989
+ cur_v = _voices_for_engine(_all_engines[ui.modal_engine_idx])
990
+ if cur_v:
991
+ ui.modal_voice_idx = (ui.modal_voice_idx + direction) % len(cur_v)
992
+
993
+ # ================================================================
994
+ # Input handling
995
+ # ================================================================
996
+ def handle_key(c, fd):
997
+ # Modal intercepts all keys
998
+ if ui.modal_open:
999
+ return handle_modal_key(c, fd)
1000
+
1001
+ if c == '\t':
1002
+ if not ui.editing:
1003
+ ui.tab = (ui.tab + 1) % 2
1004
+ return True
1005
+ if c == '\x11': # Ctrl+Q
1006
+ return False
1007
+ if c == '\x03': # Ctrl+C
1008
+ return True
1009
+ if c == '\x13': # Ctrl+S = open settings modal
1010
+ open_modal()
1011
+ return True
1012
+
1013
+ # Escape sequences
1014
+ if c == '\x1b':
1015
+ if _sel.select([fd], [], [], 0.05)[0]:
1016
+ c2 = os.read(fd, 1).decode('latin-1')
1017
+ if c2 == '[':
1018
+ c3 = os.read(fd, 1).decode('latin-1')
1019
+ if c3 == 'A': # Up
1020
+ if ui.tab == 0: _chat_scroll_up()
1021
+ elif ui.tab == 1 and not ui.editing and ui.set_sel > 0: ui.set_sel -= 1
1022
+ elif c3 == 'B': # Down
1023
+ if ui.tab == 0: _chat_scroll_down()
1024
+ elif ui.tab == 1 and not ui.editing and ui.set_sel < 4: ui.set_sel += 1
1025
+ elif c3 == '5': # PgUp
1026
+ os.read(fd, 1)
1027
+ if ui.tab == 0: _chat_page_up()
1028
+ elif c3 == '6': # PgDn
1029
+ os.read(fd, 1)
1030
+ if ui.tab == 0: _chat_page_down()
1031
+ elif c2 == 'O':
1032
+ c3 = os.read(fd, 1).decode('latin-1')
1033
+ if c3 == 'P': ui.tab = 0 # F1
1034
+ elif c3 == 'Q': ui.tab = 1 # F2
230
1035
  else:
231
- continue
1036
+ # bare Esc
1037
+ if ui.tab == 1 and ui.editing:
1038
+ ui.editing = False
1039
+ ui.edit_buf = ""
1040
+ else:
1041
+ if ui.tab == 1 and ui.editing:
1042
+ ui.editing = False
1043
+ ui.edit_buf = ""
1044
+ return True
232
1045
 
233
- if not user_input:
234
- continue
1046
+ if ui.tab == 0:
1047
+ return handle_chat(c, fd)
1048
+ elif ui.tab == 1:
1049
+ return handle_settings(c, fd)
1050
+ return True
235
1051
 
236
- # Add RAG context if files loaded
237
- current_prompt = user_input
238
- if loaded_chunks:
239
- context_content = ""
240
- for filename, chunks in loaded_chunks.items():
241
- full_text = "\n".join(chunks)
242
- retrieved = rag_search(user_input, full_text, similarity_threshold=0.3)
243
- if retrieved:
244
- context_content += f"\n{retrieved}\n"
245
- if context_content:
246
- current_prompt += f"\n\nContext:{context_content}"
247
-
248
- # Get response
249
- resp = get_llm_response(
250
- current_prompt,
251
- model=model,
252
- provider=provider,
253
- messages=messages,
254
- stream=False, # Don't stream for voice
255
- npc=npc
256
- )
1052
+ def _chat_scroll_up():
1053
+ _, h = sz()
1054
+ chat_h = h - 5
1055
+ if ui.chat_scroll == -1:
1056
+ ui.chat_scroll = max(0, len(ui.chat_log) * 2 - chat_h - 1)
1057
+ ui.chat_scroll = max(0, ui.chat_scroll - 1)
257
1058
 
258
- messages = resp.get('messages', messages)
259
- response_text = str(resp.get('response', ''))
1059
+ def _chat_scroll_down():
1060
+ ui.chat_scroll = -1 if ui.chat_scroll == -1 else ui.chat_scroll + 1
260
1061
 
261
- # Display and speak response
262
- print(colored(f"{npc_name}: ", "green") + response_text)
1062
+ def _chat_page_up():
1063
+ _, h = sz()
1064
+ chat_h = h - 5
1065
+ if ui.chat_scroll == -1:
1066
+ ui.chat_scroll = max(0, len(ui.chat_log) * 2 - chat_h - chat_h)
1067
+ else:
1068
+ ui.chat_scroll = max(0, ui.chat_scroll - chat_h)
263
1069
 
1070
+ def _chat_page_down():
1071
+ ui.chat_scroll = -1
1072
+
1073
+ def handle_chat(c, fd):
1074
+ # Ctrl+L = toggle listening
1075
+ if c == '\x0c': # Ctrl+L
264
1076
  if AUDIO_AVAILABLE:
265
- speak_text(response_text, tts_model, voice)
266
-
267
- except KeyboardInterrupt:
268
- print("\nUse '/yq' to exit or continue.")
269
- continue
270
- except EOFError:
271
- print("\nExiting yap mode.")
272
- break
1077
+ ui.listening = not ui.listening
1078
+ st = 'on' if ui.listening else 'off'
1079
+ ui.chat_log.append(('info', f'Listening {st}.'))
1080
+ return True
1081
+
1082
+ if ui.recording or ui.transcribing:
1083
+ return True
1084
+
1085
+ if ui.thinking:
1086
+ return True
1087
+
1088
+ if c in ('\r', '\n'):
1089
+ text = ui.input_buf.strip()
1090
+ ui.input_buf = ""
1091
+ if text:
1092
+ send_message(text)
1093
+ return True
1094
+
1095
+ if c == '\x7f' or c == '\x08':
1096
+ ui.input_buf = ui.input_buf[:-1]
1097
+ return True
1098
+
1099
+ if c >= ' ' and c <= '~':
1100
+ ui.input_buf += c
1101
+ ui.chat_scroll = -1
1102
+ return True
1103
+
1104
+ return True
1105
+
1106
+ def handle_settings(c, fd):
1107
+ SETTINGS_KEYS = ['tts_enabled', 'auto_speak', 'listening', 'silence_timeout', 'vad_threshold']
1108
+
1109
+ if ui.editing:
1110
+ if c in ('\r', '\n'):
1111
+ val = ui.edit_buf.strip()
1112
+ if ui.edit_key == 'silence_timeout':
1113
+ try: ui.silence_timeout = max(0.3, min(10.0, float(val)))
1114
+ except: pass
1115
+ elif ui.edit_key == 'vad_threshold':
1116
+ try: ui.vad_threshold = max(0.1, min(0.9, float(val)))
1117
+ except: pass
1118
+ ui.editing = False
1119
+ ui.edit_buf = ""
1120
+ elif c == '\x7f' or c == '\x08':
1121
+ ui.edit_buf = ui.edit_buf[:-1]
1122
+ elif c >= ' ' and c <= '~':
1123
+ ui.edit_buf += c
1124
+ return True
1125
+
1126
+ if c == 'j' and ui.set_sel < len(SETTINGS_KEYS) - 1:
1127
+ ui.set_sel += 1
1128
+ elif c == 'k' and ui.set_sel > 0:
1129
+ ui.set_sel -= 1
1130
+ elif c == ' ':
1131
+ key = SETTINGS_KEYS[ui.set_sel]
1132
+ if key == 'tts_enabled':
1133
+ ui.tts_enabled = not ui.tts_enabled
1134
+ elif key == 'auto_speak':
1135
+ ui.auto_speak = not ui.auto_speak
1136
+ elif key == 'listening':
1137
+ ui.listening = not ui.listening
1138
+ st = 'on' if ui.listening else 'off'
1139
+ ui.chat_log.append(('info', f'Listening {st}.'))
1140
+ elif c == 'e':
1141
+ key = SETTINGS_KEYS[ui.set_sel]
1142
+ if key in ('silence_timeout', 'vad_threshold'):
1143
+ ui.editing = True
1144
+ ui.edit_key = key
1145
+ ui.edit_buf = str(ui.silence_timeout if key == 'silence_timeout' else ui.vad_threshold)
1146
+ return True
1147
+
1148
+ # ================================================================
1149
+ # Welcome
1150
+ # ================================================================
1151
+ ui.chat_log.append(('info', f'YAP voice chat. NPC: {npc_name}.'))
1152
+ ui.chat_log.append(('info', f'TTS: {tts_model_name}/{voice_name} LLM: {model or "?"}@{provider or "?"}'))
1153
+ if AUDIO_AVAILABLE:
1154
+ ui.chat_log.append(('info', 'Listening for speech. Just start talking, or type text.'))
1155
+ ui.chat_log.append(('info', 'Ctrl+L to pause/resume listening. Ctrl+S to change settings.'))
1156
+ else:
1157
+ ui.chat_log.append(('info', 'Audio not available. Text mode only. Ctrl+S to change settings.'))
1158
+ if loaded_chunks:
1159
+ ui.chat_log.append(('info', f'{len(loaded_chunks)} files loaded for context.'))
1160
+
1161
+ # Start VAD listener thread
1162
+ _listener_thread = None
1163
+ if AUDIO_AVAILABLE and vad_model is not None:
1164
+ _listener_thread = threading.Thread(target=vad_listener_loop, daemon=True)
1165
+ _listener_thread.start()
1166
+
1167
+ # ================================================================
1168
+ # Main loop
1169
+ # ================================================================
1170
+ fd = sys.stdin.fileno()
1171
+ old_settings = termios.tcgetattr(fd)
1172
+ try:
1173
+ tty.setcbreak(fd)
1174
+ sys.stdout.write('\033[?25l\033[2J')
1175
+ running = True
1176
+ while running:
1177
+ render()
1178
+ if ui.thinking or ui.recording or ui.transcribing or ui.speaking or ui.listening:
1179
+ ui.spinner_frame += 1
1180
+ if _sel.select([fd], [], [], 0.15)[0]:
1181
+ c = os.read(fd, 1).decode('latin-1')
1182
+ running = handle_key(c, fd)
1183
+ finally:
1184
+ ui.listen_stop = True
1185
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
1186
+ sys.stdout.write('\033[?25h\033[2J\033[H')
1187
+ sys.stdout.flush()
273
1188
 
274
1189
  context['output'] = "Exited yap mode."
275
1190
  context['messages'] = messages